Skip to content

Commit 812b739

Browse files
committed
feat(dashboard): 趋势图空桶零填充与图表空状态优化
- 后端 Trend 按范围生成完整时间桶序列做零填充,无请求时段也出现在折线上; 超 400 桶时退回稀疏输出防响应膨胀 - 小时粒度收敛:跨多天窗口自动降为按天聚合,前端移除粒度选择器(粒度随范围决定) - 前端图表空状态统一为 ChartEmptyState(图标+暂无数据),模型分布卡空数据时 不再同时出现空表头与空饼图
1 parent 7c9fdd9 commit 812b739

5 files changed

Lines changed: 183 additions & 116 deletions

File tree

backend/internal/app/dashboard/service.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ func (s *Service) Trend(ctx context.Context, query TrendQuery) (Trend, error) {
111111
loc := timezone.Resolve(query.TZ)
112112
now := s.now().In(loc)
113113
startTime, endTime := resolveTrendTimeRange(query, now)
114+
// 小时粒度仅在单日窗口内可读(today / 单日 custom);跨多天时小时桶的
115+
// X 轴标签会跨天重复且点数过多,一律收敛为按天聚合。
116+
if query.Granularity == "hour" && endTime.Sub(startTime) > maxHourlySpan {
117+
query.Granularity = "day"
118+
}
114119
cacheKey := trendCacheKey(query, loc, startTime, endTime)
115120
if trend, ok := s.loadTrendCache(ctx, cacheKey); ok {
116121
return trend, nil
@@ -159,11 +164,12 @@ func (s *Service) loadTrendFresh(ctx context.Context, query TrendQuery, loc *tim
159164
return Trend{}, err
160165
}
161166

167+
fillKeys := trendBucketKeys(startTime, endTime, query.Granularity, loc)
162168
return Trend{
163169
ModelDistribution: aggregateModelDistribution(logs),
164170
UserRanking: aggregateUserRanking(logs),
165-
TokenTrend: aggregateTokenTrend(logs, query.Granularity, loc),
166-
TopUsers: aggregateTopUsers(logs, query.Granularity, loc),
171+
TokenTrend: aggregateTokenTrend(logs, query.Granularity, loc, fillKeys),
172+
TopUsers: aggregateTopUsers(logs, query.Granularity, loc, fillKeys),
167173
}, nil
168174
}
169175

@@ -352,7 +358,7 @@ func aggregateUserRanking(logs []TrendLog) []UserRanking {
352358
return result
353359
}
354360

355-
func aggregateTokenTrend(logs []TrendLog, granularity string, loc *time.Location) []TimeBucket {
361+
func aggregateTokenTrend(logs []TrendLog, granularity string, loc *time.Location, fillKeys []string) []TimeBucket {
356362
layout := trendTimeLayout(granularity)
357363
bucketMap := make(map[string]*TimeBucket)
358364
for _, item := range logs {
@@ -369,6 +375,12 @@ func aggregateTokenTrend(logs []TrendLog, granularity string, loc *time.Location
369375
bucket.ActualCost += item.ActualCost
370376
bucket.StandardCost += item.StandardCost
371377
}
378+
// 空桶零填充:没有请求的时段也要出现在折线图上,避免 category 轴压缩时间轴。
379+
for _, key := range fillKeys {
380+
if bucketMap[key] == nil {
381+
bucketMap[key] = &TimeBucket{Time: key}
382+
}
383+
}
372384

373385
result := make([]TimeBucket, 0, len(bucketMap))
374386
for _, item := range bucketMap {
@@ -380,7 +392,7 @@ func aggregateTokenTrend(logs []TrendLog, granularity string, loc *time.Location
380392
return result
381393
}
382394

383-
func aggregateTopUsers(logs []TrendLog, granularity string, loc *time.Location) []UserTrend {
395+
func aggregateTopUsers(logs []TrendLog, granularity string, loc *time.Location, fillKeys []string) []UserTrend {
384396
type userTotal struct {
385397
UserID int
386398
Email string
@@ -425,6 +437,14 @@ func aggregateTopUsers(logs []TrendLog, granularity string, loc *time.Location)
425437
}
426438
userBuckets[item.UserID][key] += item.InputTokens + item.OutputTokens + item.CachedInputTokens + item.CacheCreationTokens
427439
}
440+
// 空桶零填充:每个用户的折线补齐无请求时段,保证各条线共享同一条时间轴。
441+
for _, buckets := range userBuckets {
442+
for _, key := range fillKeys {
443+
if _, ok := buckets[key]; !ok {
444+
buckets[key] = 0
445+
}
446+
}
447+
}
428448

429449
result := make([]UserTrend, 0, len(totals))
430450
for _, item := range totals {
@@ -454,3 +474,37 @@ func trendTimeLayout(granularity string) string {
454474
}
455475
return "2006-01-02"
456476
}
477+
478+
const (
479+
// maxHourlySpan 小时粒度允许的最大时间窗口(单日 + DST 余量)。
480+
maxHourlySpan = 36 * time.Hour
481+
// maxTrendFillBuckets 零填充的桶数上限;超限(如跨多年的 custom 范围)时
482+
// 放弃填充退回稀疏输出,避免响应体膨胀。
483+
maxTrendFillBuckets = 400
484+
)
485+
486+
// trendBucketKeys 生成 [startTime, endTime) 区间内完整的时间桶 key 序列(空桶零填充用)。
487+
// 超出 maxTrendFillBuckets 时返回 nil,表示跳过填充。
488+
func trendBucketKeys(startTime, endTime time.Time, granularity string, loc *time.Location) []string {
489+
layout := trendTimeLayout(granularity)
490+
cur := startTime.In(loc)
491+
if granularity == "hour" {
492+
cur = time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), 0, 0, 0, loc)
493+
} else {
494+
cur = timezone.StartOfDay(cur)
495+
}
496+
end := endTime.In(loc)
497+
keys := make([]string, 0, 128)
498+
for cur.Before(end) {
499+
if len(keys) >= maxTrendFillBuckets {
500+
return nil
501+
}
502+
keys = append(keys, cur.Format(layout))
503+
if granularity == "hour" {
504+
cur = cur.Add(time.Hour)
505+
} else {
506+
cur = cur.AddDate(0, 0, 1)
507+
}
508+
}
509+
return keys
510+
}

backend/internal/app/dashboard/service_test.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ func TestTrendAggregatesTopUsersAndBuckets(t *testing.T) {
9999
CachedInputTokens: 5,
100100
ActualCost: 1.2,
101101
StandardCost: 1.5,
102-
CreatedAt: time.Date(2026, 4, 1, 10, 15, 0, 0, time.UTC),
102+
CreatedAt: time.Date(2026, 4, 2, 10, 15, 0, 0, time.UTC),
103103
},
104104
{
105105
UserID: 1,
@@ -110,7 +110,7 @@ func TestTrendAggregatesTopUsersAndBuckets(t *testing.T) {
110110
CachedInputTokens: 0,
111111
ActualCost: 0.2,
112112
StandardCost: 0.3,
113-
CreatedAt: time.Date(2026, 4, 1, 10, 45, 0, 0, time.UTC),
113+
CreatedAt: time.Date(2026, 4, 2, 10, 45, 0, 0, time.UTC),
114114
},
115115
{
116116
UserID: 2,
@@ -121,14 +121,14 @@ func TestTrendAggregatesTopUsersAndBuckets(t *testing.T) {
121121
CachedInputTokens: 0,
122122
ActualCost: 0.5,
123123
StandardCost: 0.8,
124-
CreatedAt: time.Date(2026, 4, 1, 11, 0, 0, 0, time.UTC),
124+
CreatedAt: time.Date(2026, 4, 2, 11, 0, 0, 0, time.UTC),
125125
},
126126
}, nil
127127
},
128128
})
129129
service.now = func() time.Time { return now }
130130

131-
result, err := service.Trend(t.Context(), TrendQuery{Range: "today", Granularity: "hour"})
131+
result, err := service.Trend(t.Context(), TrendQuery{Range: "today", Granularity: "hour", TZ: "UTC"})
132132
if err != nil {
133133
t.Fatalf("Trend() returned error: %v", err)
134134
}
@@ -138,12 +138,51 @@ func TestTrendAggregatesTopUsersAndBuckets(t *testing.T) {
138138
if result.ModelDistribution[0].Model != "gpt-4.1" || result.ModelDistribution[0].Requests != 2 {
139139
t.Fatalf("unexpected first model stat: %+v", result.ModelDistribution[0])
140140
}
141-
if len(result.TokenTrend) != 2 {
142-
t.Fatalf("len(TokenTrend) = %d, want 2", len(result.TokenTrend))
141+
// 零填充:today 12:00 时应有 00:00~11:00 共 12 个小时桶,其中 10:00/11:00 有数据。
142+
if len(result.TokenTrend) != 12 {
143+
t.Fatalf("len(TokenTrend) = %d, want 12", len(result.TokenTrend))
144+
}
145+
if result.TokenTrend[0].Time != "2026-04-02 00:00" || result.TokenTrend[0].InputTokens != 0 {
146+
t.Fatalf("unexpected zero-filled first bucket: %+v", result.TokenTrend[0])
147+
}
148+
if result.TokenTrend[10].Time != "2026-04-02 10:00" || result.TokenTrend[10].InputTokens != 12 {
149+
t.Fatalf("unexpected data bucket: %+v", result.TokenTrend[10])
143150
}
144151
if len(result.TopUsers) == 0 || result.TopUsers[0].UserID != 1 {
145152
t.Fatalf("unexpected top users: %+v", result.TopUsers)
146153
}
154+
if len(result.TopUsers[0].Trend) != 12 {
155+
t.Fatalf("len(TopUsers[0].Trend) = %d, want 12(零填充)", len(result.TopUsers[0].Trend))
156+
}
157+
}
158+
159+
func TestTrendCoercesHourToDayForMultiDayRange(t *testing.T) {
160+
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
161+
service := NewService(dashboardStubRepository{
162+
listTrendLogs: func(_ context.Context, _, _ time.Time) ([]TrendLog, error) {
163+
return []TrendLog{{
164+
UserID: 1,
165+
UserEmail: "a@test.com",
166+
Model: "gpt-4.1",
167+
InputTokens: 10,
168+
OutputTokens: 20,
169+
CreatedAt: time.Date(2026, 4, 5, 10, 15, 0, 0, time.UTC),
170+
}}, nil
171+
},
172+
})
173+
service.now = func() time.Time { return now }
174+
175+
result, err := service.Trend(t.Context(), TrendQuery{Range: "7d", Granularity: "hour", TZ: "UTC"})
176+
if err != nil {
177+
t.Fatalf("Trend() returned error: %v", err)
178+
}
179+
// 多天范围的小时粒度应收敛为按天:4/1~4/8 共 8 个天桶,key 为日期格式。
180+
if len(result.TokenTrend) != 8 {
181+
t.Fatalf("len(TokenTrend) = %d, want 8", len(result.TokenTrend))
182+
}
183+
if result.TokenTrend[0].Time != "2026-04-01" {
184+
t.Fatalf("bucket key 应为天粒度日期: %+v", result.TokenTrend[0])
185+
}
147186
}
148187

149188
type dashboardStubRepository struct {

web/src/i18n/en.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,6 @@
161161
"range_30d": "30 Days",
162162
"range_90d": "90 Days",
163163
"range_custom": "Custom",
164-
"granularity": "Granularity",
165-
"granularity_hour": "Hourly",
166-
"granularity_day": "Daily",
167164
"model_distribution": "Model Distribution",
168165
"user_ranking": "User Consumption",
169166
"model": "Model",

web/src/i18n/zh.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,6 @@
161161
"range_30d": "近 30 天",
162162
"range_90d": "近 90 天",
163163
"range_custom": "自定义",
164-
"granularity": "粒度",
165-
"granularity_hour": "按小时",
166-
"granularity_day": "按天",
167164
"model_distribution": "模型分布",
168165
"user_ranking": "用户消费榜",
169166
"model": "模型",

0 commit comments

Comments
 (0)