feat(analytics): 重构统计分析模块,统一 API 与前端视图

close #260

- 新增 `src/services/analytics/query_service.py`,集中实现排行榜、性能、时间序列等查询逻辑
- 新增 `src/api/analytics/routes.py`,替代原 `stats/` 和 `dashboard/` 的分散路由
- 删除旧 `src/api/admin/stats/`、`src/api/dashboard/` 模块
- 重构 `src/api/user_me/routes.py` 与 `src/api/admin/usage/routes.py`,精简用量查询接口
- 新增 Alembic 迁移,修正 token 语义字段
- 前端新增 Analytics.vue、LeaderboardTab、PerformanceTab、ReportsTab 及 Reports 用户视图
- 新增 composables(useAnalyticsFilters、useReportsData、useLeaderboardData、usePerformanceData)
- 新增工具函数:analyticsGranularity、analyticsTimeseries、chartTheme、csvExport、usageBreakdown
- 删除旧 CostAnalysis、PerformanceAnalysis、UserStats 页面及相关组件
- 前端 API 层重组:新增 analytics.ts、request-details.ts,删除 dashboard.ts 和 usage.ts

Co-authored-by: NyaDoo <[email protected]>
This commit is contained in:
fawney19
2026-03-24 01:51:53 +08:00
co-authored by NyaDoo
parent 165d9eab8f
commit bd4e5f3a5d
123 changed files with 16753 additions and 14149 deletions
@@ -0,0 +1,191 @@
"""usage token semantics v2
Revision ID: c3d4e5f6a7b8
Revises: c9d8e7f6a5b4
Create Date: 2026-03-24 14:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: str | None = "c9d8e7f6a5b4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if column_exists("usage", "total_tokens") and not column_exists("usage", "input_output_total_tokens"):
with op.batch_alter_table("usage") as batch_op:
batch_op.alter_column(
"total_tokens",
new_column_name="input_output_total_tokens",
existing_type=sa.Integer(),
existing_nullable=True,
)
with op.batch_alter_table("usage") as batch_op:
if not column_exists("usage", "input_context_tokens"):
batch_op.add_column(sa.Column("input_context_tokens", sa.Integer(), nullable=False, server_default="0"))
if not column_exists("usage", "total_tokens"):
batch_op.add_column(sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_cost_usd_5m"):
batch_op.add_column(sa.Column("cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_cost_usd_1h"):
batch_op.add_column(sa.Column("cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_creation_cost_usd_5m"):
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_creation_cost_usd_1h"):
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_cost_usd"):
batch_op.add_column(sa.Column("actual_cache_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_price_per_1m_5m"):
batch_op.add_column(sa.Column("cache_creation_price_per_1m_5m", sa.Numeric(20, 8), nullable=True))
if not column_exists("usage", "cache_creation_price_per_1m_1h"):
batch_op.add_column(sa.Column("cache_creation_price_per_1m_1h", sa.Numeric(20, 8), nullable=True))
conn = op.get_bind()
batch_size = 5000
while True:
result = conn.execute(
sa.text(
"""
UPDATE usage
SET
input_output_total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)),
input_context_tokens = COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0),
total_tokens = COALESCE(input_output_total_tokens, 0)
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0),
cache_creation_cost_usd_5m = CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END,
cache_creation_cost_usd_1h = CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END,
actual_cache_creation_cost_usd_5m = CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END,
actual_cache_creation_cost_usd_1h = CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END,
actual_cache_cost_usd = COALESCE(actual_cache_creation_cost_usd, 0) + COALESCE(actual_cache_read_cost_usd, 0),
cache_creation_price_per_1m_5m = CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END,
cache_creation_price_per_1m_1h = CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END,
cache_cost_usd = COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
WHERE id IN (
SELECT id FROM usage
WHERE input_context_tokens = 0 AND total_tokens = 0
LIMIT :batch_size
)
"""
),
{"batch_size": batch_size},
)
if result.rowcount == 0:
break
def downgrade() -> None:
conn = op.get_bind()
batch_size = 5000
while True:
result = conn.execute(
sa.text(
"""
UPDATE usage
SET total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
WHERE id IN (
SELECT id FROM usage
WHERE total_tokens != COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
LIMIT :batch_size
)
"""
),
{"batch_size": batch_size},
)
if result.rowcount == 0:
break
with op.batch_alter_table("usage") as batch_op:
if column_exists("usage", "cache_creation_price_per_1m_1h"):
batch_op.drop_column("cache_creation_price_per_1m_1h")
if column_exists("usage", "cache_creation_price_per_1m_5m"):
batch_op.drop_column("cache_creation_price_per_1m_5m")
if column_exists("usage", "actual_cache_cost_usd"):
batch_op.drop_column("actual_cache_cost_usd")
if column_exists("usage", "actual_cache_creation_cost_usd_1h"):
batch_op.drop_column("actual_cache_creation_cost_usd_1h")
if column_exists("usage", "actual_cache_creation_cost_usd_5m"):
batch_op.drop_column("actual_cache_creation_cost_usd_5m")
if column_exists("usage", "cache_creation_cost_usd_1h"):
batch_op.drop_column("cache_creation_cost_usd_1h")
if column_exists("usage", "cache_creation_cost_usd_5m"):
batch_op.drop_column("cache_creation_cost_usd_5m")
if column_exists("usage", "input_context_tokens"):
batch_op.drop_column("input_context_tokens")
if column_exists("usage", "total_tokens"):
batch_op.drop_column("total_tokens")
if column_exists("usage", "input_output_total_tokens"):
batch_op.alter_column(
"input_output_total_tokens",
new_column_name="total_tokens",
existing_type=sa.Integer(),
existing_nullable=True,
)
-309
View File
@@ -380,102 +380,6 @@ export interface AdminApiKeysResponse {
skip: number skip: number
} }
export interface LeaderboardItem {
rank: number
id: string
name: string
value: number
requests: number
tokens: number
cost: number
}
export interface LeaderboardResponse {
items: LeaderboardItem[]
total: number
metric: string
start_date?: string | null
end_date?: string | null
}
export interface CostForecastResponse {
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
slope: number
intercept: number
start_date: string
end_date: string
}
export interface CostSavingsResponse {
cache_read_tokens: number
cache_read_cost: number
cache_creation_cost: number
estimated_full_cost: number
cache_savings: number
}
export interface QuotaUsageProvider {
id: string
name: string
quota_usd: number
used_usd: number
remaining_usd: number
usage_percent: number
quota_expires_at?: string | null
estimated_exhaust_at?: string | null
}
export interface QuotaUsageResponse {
providers: QuotaUsageProvider[]
}
export interface PercentileItem {
date: string
p50_response_time_ms?: number | null
p90_response_time_ms?: number | null
p99_response_time_ms?: number | null
p50_first_byte_time_ms?: number | null
p90_first_byte_time_ms?: number | null
p99_first_byte_time_ms?: number | null
}
export interface ErrorDistributionItem {
category: string
count: number
}
export interface ErrorTrendItem {
date: string
total: number
categories: Record<string, number>
}
export interface ErrorDistributionResponse {
distribution: ErrorDistributionItem[]
trend: ErrorTrendItem[]
}
export interface ComparisonMetric {
total_requests: number
total_tokens: number
total_cost: number
actual_total_cost: number
avg_response_time_ms: number
error_requests: number
}
export interface ComparisonResponse {
current: ComparisonMetric
comparison: ComparisonMetric
change_percent: Record<string, number | null>
current_start: string
current_end: string
comparison_start: string
comparison_end: string
}
export interface ApiKeyToggleResponse { export interface ApiKeyToggleResponse {
id: string // UUID id: string // UUID
is_active: boolean is_active: boolean
@@ -753,197 +657,6 @@ export const adminApi = {
return response.data return response.data
}, },
// Stats / Leaderboards
async getLeaderboardUsers(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
params
})
return response.data
},
20 * 1000
)
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
params
})
return response.data
},
20 * 1000
)
},
async getLeaderboardModels(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
params
})
return response.data
},
20 * 1000
)
},
async getCostForecast(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
days?: number
forecast_days?: number
}): Promise<CostForecastResponse> {
const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
params
})
return response.data
},
30 * 1000
)
},
async getCostSavings(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
provider_name?: string
model?: string
}): Promise<CostSavingsResponse> {
const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
params
})
return response.data
},
30 * 1000
)
},
async getQuotaUsage(): Promise<QuotaUsageResponse> {
return cachedRequest(
'admin:stats:providers:quota-usage',
async () => {
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
return response.data
},
30 * 1000
)
},
async getPercentiles(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<PercentileItem[]> {
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
params
})
return response.data
},
20 * 1000
)
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<ErrorDistributionResponse> {
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
params
})
return response.data
},
20 * 1000
)
},
async getComparison(params: {
current_start: string
current_end: string
comparison_type?: 'period' | 'year'
timezone?: string
tz_offset_minutes?: number
}): Promise<ComparisonResponse> {
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
params
})
return response.data
},
// 数据清空 // 数据清空
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'), purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'), purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
@@ -952,26 +665,4 @@ export const adminApi = {
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'), purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
purgeStats: () => purge<{ message: string }>('stats'), purgeStats: () => purge<{ message: string }>('stats'),
async getTimeSeries(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
user_id?: string
model?: string
provider_name?: string
}): Promise<Array<Record<string, unknown>>> {
const cacheKey = buildCacheKey('admin:stats:time-series', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
return response.data
},
20 * 1000
)
},
} }
+396
View File
@@ -0,0 +1,396 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
export type AnalyticsScopeKind = 'global' | 'me' | 'user' | 'api_key'
export type AnalyticsGranularity = 'hour' | 'day' | 'week' | 'month'
export type AnalyticsBreakdownDimension = 'model' | 'provider' | 'api_format' | 'api_key' | 'user'
export type AnalyticsBreakdownMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd' | 'actual_total_cost_usd'
export type AnalyticsLeaderboardEntity = 'user' | 'api_key'
export type AnalyticsLeaderboardMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd'
export interface AnalyticsScope {
kind: AnalyticsScopeKind
user_id?: string | null
api_key_id?: string | null
}
export interface AnalyticsTimeRange {
start_date?: string
end_date?: string
preset?: string
granularity?: AnalyticsGranularity
timezone?: string | null
tz_offset_minutes?: number
}
export interface AnalyticsFilters {
user_ids?: string[]
provider_names?: string[]
models?: string[]
target_models?: string[]
api_key_ids?: string[]
api_formats?: string[]
request_types?: string[]
statuses?: string[]
error_categories?: string[]
is_stream?: boolean | null
has_format_conversion?: boolean | null
}
export interface AnalyticsBaseRequest {
scope: AnalyticsScope
time_range: AnalyticsTimeRange
filters?: AnalyticsFilters
}
export interface AnalyticsSummary {
requests_total: number
requests_success: number
requests_error: number
requests_stream: number
success_rate: number
input_tokens: number
output_tokens: number
input_output_total_tokens: number
cache_creation_input_tokens: number
cache_creation_input_tokens_5m: number
cache_creation_input_tokens_1h: number
cache_read_input_tokens: number
input_context_tokens: number
total_tokens: number
cache_hit_rate: number
input_cost_usd: number
output_cost_usd: number
cache_creation_cost_usd: number
cache_creation_cost_usd_5m: number
cache_creation_cost_usd_1h: number
cache_read_cost_usd: number
cache_cost_usd: number
request_cost_usd: number
total_cost_usd: number
actual_total_cost_usd: number
actual_cache_cost_usd: number
avg_response_time_ms: number
avg_first_byte_time_ms: number
format_conversion_count: number
models_used_count: number
}
export interface AnalyticsCompositionSegment {
key: string
value: number
percentage: number
}
export interface AnalyticsOverviewResponse {
query_context: {
scope: AnalyticsScope
time_range: AnalyticsTimeRange
}
summary: AnalyticsSummary
composition: {
token_segments: AnalyticsCompositionSegment[]
cost_segments: AnalyticsCompositionSegment[]
}
}
export interface AnalyticsTimeseriesBucket extends AnalyticsSummary {
bucket_start: string
bucket_end: string
}
export interface AnalyticsTimeseriesResponse {
buckets: AnalyticsTimeseriesBucket[]
}
export interface AnalyticsBreakdownRequest extends AnalyticsBaseRequest {
dimension: AnalyticsBreakdownDimension
metric?: AnalyticsBreakdownMetric
limit?: number
}
export interface AnalyticsBreakdownRow extends AnalyticsSummary {
key: string
label: string
share_of_total_cost: number
share_of_total_tokens: number
}
export interface AnalyticsBreakdownResponse {
dimension: AnalyticsBreakdownDimension
metric: AnalyticsBreakdownMetric
rows: AnalyticsBreakdownRow[]
}
export interface AnalyticsRecordsRequest extends AnalyticsBaseRequest {
search?: {
text?: string | null
request_id?: string | null
}
pagination?: {
limit?: number
offset?: number
}
}
export interface AnalyticsRecord {
id: string
request_id: string
created_at: string | null
user_id: string | null
username: string | null
api_key_id: string | null
api_key_name: string | null
provider_api_key_name: string | null
provider_name: string | null
model: string
target_model: string | null
api_format: string | null
request_type: string | null
status: string
billing_status: string
is_stream: boolean
has_format_conversion: boolean | null
has_fallback?: boolean
has_retry?: boolean
status_code: number | null
error_message: string | null
error_category: string | null
response_time_ms: number | null
first_byte_time_ms: number | null
input_tokens: number
output_tokens: number
input_output_total_tokens: number
cache_creation_input_tokens: number
cache_creation_input_tokens_5m: number
cache_creation_input_tokens_1h: number
cache_read_input_tokens: number
input_context_tokens: number
total_tokens: number
input_cost_usd: number
output_cost_usd: number
cache_creation_cost_usd: number
cache_creation_cost_usd_5m: number
cache_creation_cost_usd_1h: number
cache_read_cost_usd: number
cache_cost_usd: number
request_cost_usd: number
total_cost_usd: number
actual_total_cost_usd: number
actual_cache_cost_usd: number
rate_multiplier: number
}
export interface AnalyticsRecordsResponse {
total: number
limit: number
offset: number
records: AnalyticsRecord[]
}
export interface AnalyticsFilterOption {
value: string
label: string
}
export interface AnalyticsFilterOptionsResponse {
providers: AnalyticsFilterOption[]
models: AnalyticsFilterOption[]
target_models: AnalyticsFilterOption[]
api_formats: AnalyticsFilterOption[]
request_types: AnalyticsFilterOption[]
error_categories: AnalyticsFilterOption[]
statuses: AnalyticsFilterOption[]
users?: AnalyticsFilterOption[]
api_keys?: AnalyticsFilterOption[]
}
export interface AnalyticsLeaderboardRequest extends AnalyticsBaseRequest {
entity: AnalyticsLeaderboardEntity
metric: AnalyticsLeaderboardMetric
limit?: number
}
export interface AnalyticsLeaderboardItem {
rank: number
id: string
label: string
requests_total: number
total_tokens: number
total_cost_usd: number
actual_total_cost_usd: number
metric_value: number
}
export interface AnalyticsLeaderboardResponse {
entity: AnalyticsLeaderboardEntity
metric: AnalyticsLeaderboardMetric
items: AnalyticsLeaderboardItem[]
}
export interface AnalyticsPercentilePoint {
date: string
p50_response_time_ms: number | null
p90_response_time_ms: number | null
p99_response_time_ms: number | null
p50_first_byte_time_ms: number | null
p90_first_byte_time_ms: number | null
p99_first_byte_time_ms: number | null
}
export interface AnalyticsErrorCategory {
category: string
label: string
count: number
}
export interface AnalyticsErrorTrendItem {
date: string
total: number
}
export interface AnalyticsProviderHealthItem {
provider_name: string
requests_total: number
success_rate: number
error_rate: number
avg_response_time_ms: number
avg_first_byte_time_ms: number
}
export interface AnalyticsPerformanceResponse {
latency: {
response_time_ms: {
avg: number
p50: number | null
p90: number | null
p99: number | null
}
first_byte_time_ms: {
avg: number
p50: number | null
p90: number | null
p99: number | null
}
}
percentiles: AnalyticsPercentilePoint[]
errors: {
total: number
rate: number
categories: AnalyticsErrorCategory[]
trend: AnalyticsErrorTrendItem[]
}
provider_health: AnalyticsProviderHealthItem[]
}
export interface AnalyticsActiveRequest {
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost?: number
total_cost_usd?: number
actual_cost?: number | null
actual_total_cost_usd?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
provider?: string | null
provider_name?: string | null
api_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
target_model?: string | null
}
export interface AnalyticsActiveRequestsResponse {
requests: AnalyticsActiveRequest[]
}
export const analyticsApi = {
async getOverview(payload: AnalyticsBaseRequest): Promise<AnalyticsOverviewResponse> {
const response = await apiClient.post<AnalyticsOverviewResponse>('/api/analytics/overview', payload)
return response.data
},
async getTimeseries(payload: AnalyticsBaseRequest): Promise<AnalyticsTimeseriesResponse> {
const response = await apiClient.post<AnalyticsTimeseriesResponse>('/api/analytics/timeseries', payload)
return response.data
},
async getBreakdown(payload: AnalyticsBreakdownRequest): Promise<AnalyticsBreakdownResponse> {
const response = await apiClient.post<AnalyticsBreakdownResponse>('/api/analytics/breakdown', payload)
return response.data
},
async getRecords(payload: AnalyticsRecordsRequest): Promise<AnalyticsRecordsResponse> {
const response = await apiClient.post<AnalyticsRecordsResponse>('/api/analytics/records', payload)
return response.data
},
async getFilterOptions(payload: AnalyticsBaseRequest): Promise<AnalyticsFilterOptionsResponse> {
const response = await apiClient.post<AnalyticsFilterOptionsResponse>('/api/analytics/filter-options', payload)
return response.data
},
async getLeaderboard(payload: AnalyticsLeaderboardRequest): Promise<AnalyticsLeaderboardResponse> {
const response = await apiClient.post<AnalyticsLeaderboardResponse>('/api/analytics/leaderboard', payload)
return response.data
},
async getPerformance(payload: AnalyticsBaseRequest): Promise<AnalyticsPerformanceResponse> {
const response = await apiClient.post<AnalyticsPerformanceResponse>('/api/analytics/performance', payload)
return response.data
},
async getHeatmap(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
}): Promise<ActivityHeatmap> {
const cacheKey = buildCacheKey('analytics:heatmap', payload as Record<string, unknown>)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.post<ActivityHeatmap>('/api/analytics/heatmap', payload)
return response.data
},
60000,
)
},
async getActiveRequests(payload: {
scope: AnalyticsScope
ids?: string[]
}): Promise<AnalyticsActiveRequestsResponse> {
const response = await apiClient.post<AnalyticsActiveRequestsResponse>('/api/analytics/active-requests', {
...payload,
ids: payload.ids ?? [],
})
return response.data
},
async analyzeCacheAffinityTTL(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
hours?: number
}): Promise<Record<string, unknown>> {
const response = await apiClient.post('/api/analytics/cache-affinity/ttl-analysis', payload)
return response.data
},
async analyzeCacheAffinityHit(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
hours?: number
}): Promise<Record<string, unknown>> {
const response = await apiClient.post('/api/analytics/cache-affinity/hit-analysis', payload)
return response.data
},
}
+202
View File
@@ -33,6 +33,203 @@ export interface AuditFilters {
offset?: number offset?: number
} }
export type MonitoringMetricStatus = 'ok' | 'warning' | 'danger' | 'degraded' | 'error' | 'unknown'
export interface MonitoringCpuMetric {
status: MonitoringMetricStatus
label: string
usage_percent: number | null
load_percent: number | null
core_count: number
message?: string | null
}
export interface MonitoringMemoryMetric {
status: MonitoringMetricStatus
label: string
used_percent: number | null
used_bytes: number | null
available_bytes: number | null
total_bytes: number | null
message?: string | null
}
export interface MonitoringServiceMetric {
status: MonitoringMetricStatus
label: string
latency_ms: number | null
memory_status?: MonitoringMetricStatus
memory_label?: string | null
used_memory_bytes?: number | null
peak_memory_bytes?: number | null
maxmemory_bytes?: number | null
memory_ceiling_bytes?: number | null
memory_source?: 'configured' | 'maxmemory' | 'system' | 'unknown'
available_memory_bytes?: number | null
memory_percent?: number | null
message?: string | null
}
export interface MonitoringPostgresMetric {
status: MonitoringMetricStatus
label: string
usage_percent: number | null
pool_usage_percent?: number | null
checked_out: number
pool_size: number
overflow: number
max_capacity: number
pool_timeout: number
server_connections?: number | null
server_max_connections?: number | null
server_usage_percent?: number | null
storage_status?: MonitoringMetricStatus
storage_label?: string | null
storage_total_bytes?: number | null
storage_free_bytes?: number | null
storage_free_percent?: number | null
database_size_bytes?: number | null
storage_message?: string | null
message?: string | null
}
export interface MonitoringSystemStatus {
timestamp: string
users: {
total: number
active: number
}
providers: {
total: number
active: number
}
api_keys: {
total: number
active: number
}
today_stats: {
requests: number
tokens: number
cost_usd: number
}
recent_errors: number
system_metrics?: {
cpu: MonitoringCpuMetric
memory: MonitoringMemoryMetric
redis: MonitoringServiceMetric
postgres: MonitoringPostgresMetric
}
}
function parseNumericLike(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value === 'string') {
const normalized = value.trim().replace(/[$,\s]/g, '')
if (!normalized) return 0
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function parseNullableNumericLike(value: unknown): number | null {
if (value == null) {
return null
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value === 'string') {
const normalized = value.trim().replace(/[$,\s]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function normalizeMonitoringSystemStatus(data: MonitoringSystemStatus): MonitoringSystemStatus {
return {
...data,
users: {
total: parseNumericLike(data.users?.total),
active: parseNumericLike(data.users?.active),
},
providers: {
total: parseNumericLike(data.providers?.total),
active: parseNumericLike(data.providers?.active),
},
api_keys: {
total: parseNumericLike(data.api_keys?.total),
active: parseNumericLike(data.api_keys?.active),
},
today_stats: {
requests: parseNumericLike(data.today_stats?.requests),
tokens: parseNumericLike(data.today_stats?.tokens),
cost_usd: parseNumericLike(data.today_stats?.cost_usd),
},
recent_errors: parseNumericLike(data.recent_errors),
system_metrics: data.system_metrics ? {
cpu: {
status: data.system_metrics.cpu?.status ?? 'unknown',
label: data.system_metrics.cpu?.label ?? '未知',
usage_percent: parseNullableNumericLike(data.system_metrics.cpu?.usage_percent),
load_percent: parseNullableNumericLike(data.system_metrics.cpu?.load_percent),
core_count: parseNumericLike(data.system_metrics.cpu?.core_count),
message: data.system_metrics.cpu?.message ?? null,
},
memory: {
status: data.system_metrics.memory?.status ?? 'unknown',
label: data.system_metrics.memory?.label ?? '未知',
used_percent: parseNullableNumericLike(data.system_metrics.memory?.used_percent),
used_bytes: parseNullableNumericLike(data.system_metrics.memory?.used_bytes),
available_bytes: parseNullableNumericLike(data.system_metrics.memory?.available_bytes),
total_bytes: parseNullableNumericLike(data.system_metrics.memory?.total_bytes),
message: data.system_metrics.memory?.message ?? null,
},
redis: {
status: data.system_metrics.redis?.status ?? 'unknown',
label: data.system_metrics.redis?.label ?? '未知',
latency_ms: parseNullableNumericLike(data.system_metrics.redis?.latency_ms),
memory_status: data.system_metrics.redis?.memory_status ?? 'unknown',
memory_label: data.system_metrics.redis?.memory_label ?? '未知',
used_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.used_memory_bytes),
peak_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.peak_memory_bytes),
maxmemory_bytes: parseNullableNumericLike(data.system_metrics.redis?.maxmemory_bytes),
memory_ceiling_bytes: parseNullableNumericLike(data.system_metrics.redis?.memory_ceiling_bytes),
memory_source: (data.system_metrics.redis?.memory_source as MonitoringServiceMetric['memory_source']) ?? 'unknown',
available_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.available_memory_bytes),
memory_percent: parseNullableNumericLike(data.system_metrics.redis?.memory_percent),
message: data.system_metrics.redis?.message ?? null,
},
postgres: {
status: data.system_metrics.postgres?.status ?? 'unknown',
label: data.system_metrics.postgres?.label ?? '未知',
usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.usage_percent),
pool_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.pool_usage_percent),
checked_out: parseNumericLike(data.system_metrics.postgres?.checked_out),
pool_size: parseNumericLike(data.system_metrics.postgres?.pool_size),
overflow: parseNumericLike(data.system_metrics.postgres?.overflow),
max_capacity: parseNumericLike(data.system_metrics.postgres?.max_capacity),
pool_timeout: parseNumericLike(data.system_metrics.postgres?.pool_timeout),
server_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_connections),
server_max_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_max_connections),
server_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.server_usage_percent),
storage_status: data.system_metrics.postgres?.storage_status ?? 'unknown',
storage_label: data.system_metrics.postgres?.storage_label ?? '未知',
storage_total_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_total_bytes),
storage_free_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_bytes),
storage_free_percent: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_percent),
database_size_bytes: parseNullableNumericLike(data.system_metrics.postgres?.database_size_bytes),
storage_message: data.system_metrics.postgres?.storage_message ?? null,
message: data.system_metrics.postgres?.message ?? null,
},
} : undefined,
}
}
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse { function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[] const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? { const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
@@ -78,6 +275,11 @@ export const auditApi = {
return response.data return response.data
}, },
async getSystemStatus(): Promise<MonitoringSystemStatus> {
const response = await apiClient.get<MonitoringSystemStatus>('/api/admin/monitoring/system-status')
return normalizeMonitoringSystemStatus(response.data)
},
// 分析用户行为 (管理员) // 分析用户行为 (管理员)
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{ async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
analysis: Record<string, unknown> analysis: Record<string, unknown>
-11
View File
@@ -24,13 +24,6 @@ export interface UserPreferences {
[key: string]: unknown // 允许扩展其他偏好设置 [key: string]: unknown // 允许扩展其他偏好设置
} }
export interface UserStats {
total_requests?: number
total_cost?: number
last_request_at?: string
[key: string]: unknown // 允许扩展其他统计数据
}
export interface SendVerificationCodeRequest { export interface SendVerificationCodeRequest {
email: string email: string
} }
@@ -113,13 +106,9 @@ export interface User {
role: string // 'admin' or 'user' role: string // 'admin' or 'user'
is_active: boolean is_active: boolean
billing?: BillingSummary billing?: BillingSummary
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string created_at: string
last_login_at?: string last_login_at?: string
preferences?: UserPreferences preferences?: UserPreferences
stats?: UserStats
} }
export const authApi = { export const authApi = {
+19 -3
View File
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
api_key_id?: string api_key_id?: string
hours?: number hours?: number
}): Promise<TTLAnalysisResponse> { }): Promise<TTLAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params }) const response = await api.post('/api/analytics/cache-affinity/ttl-analysis', {
scope: { kind: 'global' },
user_id: params?.user_id ?? null,
api_key_id: params?.api_key_id ?? null,
hours: params?.hours,
})
return response.data return response.data
}, },
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
api_key_id?: string api_key_id?: string
hours?: number hours?: number
}): Promise<CacheHitAnalysisResponse> { }): Promise<CacheHitAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params }) const response = await api.post('/api/analytics/cache-affinity/hit-analysis', {
scope: { kind: 'global' },
user_id: params?.user_id ?? null,
api_key_id: params?.api_key_id ?? null,
hours: params?.hours,
})
return response.data return response.data
}, },
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
return cachedRequest( return cachedRequest(
cacheKey, cacheKey,
async () => { async () => {
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) const response = await api.post('/api/analytics/interval-timeline', {
scope: params?.user_id ? { kind: 'user', user_id: params.user_id } : { kind: 'global' },
user_id: params?.user_id ?? null,
hours: params?.hours,
limit: params?.limit,
include_user_info: params?.include_user_info ?? false,
})
return response.data return response.data
}, },
30000 30000
-382
View File
@@ -1,382 +0,0 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
export interface DashboardStat {
name: string
value: string
subValue?: string
change?: string
changeType?: 'increase' | 'decrease' | 'neutral'
extraBadge?: string
icon: string
}
export interface RecentRequest {
id: string // UUID
user: string
model: string
tokens: number
time: string
}
export interface ProviderStatus {
name: string
status: 'active' | 'inactive'
requests: number
}
// 系统健康指标(管理员专用)
export interface SystemHealth {
avg_response_time: number
error_rate: number
error_requests: number
fallback_count: number
total_requests: number
}
// 成本统计(管理员专用)
export interface CostStats {
total_cost: number
total_actual_cost: number
cost_savings: number
}
// 缓存统计
export interface CacheStats {
cache_creation_tokens: number
cache_read_tokens: number
cache_creation_cost?: number
cache_read_cost?: number
cache_hit_rate?: number
total_cache_tokens: number
}
// 用户统计(管理员专用)
export interface UserStats {
total: number
active: number
}
// Token 详细分类
export interface TokenBreakdown {
input: number
output: number
cache_creation: number
cache_read: number
}
export interface DashboardStatsResponse {
stats: DashboardStat[]
today?: {
requests: number
tokens: number
cost: number
actual_cost?: number
cache_creation_tokens?: number
cache_read_tokens?: number
}
api_keys?: {
total: number
active: number
}
tokens?: {
month: number
}
// 管理员专用字段
system_health?: SystemHealth
cost_stats?: CostStats
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
}
export interface RecentRequestsResponse {
requests: RecentRequest[]
}
export interface ProviderStatusResponse {
providers: ProviderStatus[]
}
// 视频/图像/音频计费信息
export interface VideoBilling {
task_type: 'video' | 'image' | 'audio'
duration_seconds?: number // 视频时长(秒)
resolution?: string // 分辨率
video_price_per_second?: number // 每秒单价
video_cost?: number // 视频费用
cost?: number // 总费用
rule_name?: string // 计费规则名称
expression?: string // 计费公式
status?: string // 计费状态
}
export interface RequestDetail {
id: string // UUID
request_id: string
user: {
id: string // UUID
username: string
email: string
}
api_key: {
id: string // UUID
name: string
display: string
}
provider: string
api_format?: string
model: string
target_model?: string | null // 映射后的目标模型名
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
// Additional token fields
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: number
cache_read_input_tokens?: number
// Additional cost fields
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number // 按次计费费用
// Historical pricing fields (per 1M tokens)
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
request_type: string
is_stream: boolean
status_code: number
status?: string // pending, streaming, completed, failed, cancelled
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
has_client_response_body?: boolean
metadata?: Record<string, unknown>
// 阶梯计费信息
tiered_pricing?: {
total_input_context: number // 总输入上下文 (input + cache_read)
tier_index: number // 命中的阶梯索引 (0-based)
tier_count: number // 阶梯总数
source?: 'provider' | 'global' // 定价来源: 提供商或全局
current_tier: { // 当前命中的阶梯配置
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
} | null
// 视频/图像/音频计费信息
video_billing?: VideoBilling | null
}
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, unknown>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, unknown>
response_time_ms: number
mapping?: {
source_model: string
original_target_model?: string | null
resolved_model: string
target_provider_id: string
target_provider: string
target_endpoint_id: string
target_api_format: string
replay_mode: string
mapping_applied: boolean
mapping_source: string
}
}
export interface ModelBreakdown {
model: string
requests: number
tokens: number
cost: number
}
export interface ModelSummary {
model: string
requests: number
tokens: number
cost: number
avg_response_time: number
cost_per_request: number
tokens_per_request: number
}
export interface ProviderSummary {
provider: string
requests: number
tokens: number
cost: number
}
export interface DailyStat {
date: string // ISO date string
requests: number
tokens: number
cost: number
avg_response_time: number // in seconds
unique_models: number
unique_providers?: number // 仅管理员返回
model_breakdown: ModelBreakdown[]
}
export interface DailyStatsResponse {
daily_stats: DailyStat[]
model_summary: ModelSummary[]
provider_summary?: ProviderSummary[] // 仅管理员返回
period: {
start_date: string
end_date: string
days: number
}
}
export interface TimeRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const cacheKey = buildCacheKey('dashboard:stats', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
return response.data
},
10 * 1000
)
},
// 获取最近的请求记录
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
params: { limit }
})
return response.data.requests
},
// 获取提供商状态
async getProviderStatus(): Promise<ProviderStatus[]> {
return cachedRequest(
'dashboard:provider-status',
async () => {
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
return response.data.providers
},
20 * 1000
)
},
// 获取请求详情
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: options.includeBodies ?? true },
})
return response.data
},
// 获取每日统计数据
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
params
})
return response.data
},
20 * 1000
)
},
// 获取 cURL 命令数据(含明文 API Key)
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
// 回放请求到提供商
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {}
)
return response.data
}
}
+1 -1
View File
@@ -594,7 +594,7 @@ export interface HealthSummary {
total: number total: number
active: number active: number
unhealthy: number unhealthy: number
circuit_open: number circuit_open?: number
} }
} }
-192
View File
@@ -1,7 +1,5 @@
import apiClient from './client' import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types' import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth' import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session' import type { UserSession } from '@/types/session'
@@ -25,8 +23,6 @@ export interface Profile {
export interface UserPreferences { export interface UserPreferences {
avatar_url?: string avatar_url?: string
bio?: string bio?: string
default_provider_id?: string // UUID
default_provider?: Record<string, unknown>
theme: string theme: string
language: string language: string
timezone?: string timezone?: string
@@ -37,103 +33,6 @@ export interface UserPreferences {
} }
} }
// 提供商配置接口
export interface ProviderConfig {
provider_id: string
priority: number // 优先级(越高越优先)
weight: number // 负载均衡权重
enabled: boolean // 是否启用
}
// 使用记录接口
export interface UsageRecordDetail {
id: string
provider: string
model: string
input_tokens: number
output_tokens: number
total_tokens: number
cost: number // 官方费率
actual_cost?: number // 倍率消耗(仅管理员可见)
rate_multiplier?: number // 成本倍率(仅管理员可见)
response_time_ms?: number
is_stream: boolean
created_at: string
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
status_code: number
error_message?: string
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
api_key?: {
id: string
name: string
display: string
}
}
// 模型统计接口
export interface ModelSummary {
model: string
requests: number
input_tokens: number
output_tokens: number
total_tokens: number
cache_read_tokens?: number
cache_hit_rate?: number
total_cost_usd: number
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
}
// 提供商统计接口
export interface ProviderSummary {
provider: string
requests: number
total_tokens: number
cache_read_tokens?: number
cache_hit_rate?: number
total_cost_usd: number
success_rate: number | null
avg_response_time_ms: number | null
}
// API 格式统计接口
export interface ApiFormatSummary {
api_format: string
request_count: number
total_tokens: number
cache_read_tokens: number
cache_hit_rate: number
total_cost_usd: number
avg_response_time_ms: number
}
// 使用统计响应接口
export interface UsageResponse {
total_requests: number
total_input_tokens: number
total_output_tokens: number
total_tokens: number
total_cost: number // 官方费率
total_actual_cost?: number // 倍率消耗(仅管理员可见)
avg_response_time: number
billing: BillingSummary
summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[]
summary_by_api_format?: ApiFormatSummary[]
pagination?: {
total: number
limit: number
offset: number
has_more: boolean
}
records: UsageRecordDetail[]
activity_heatmap?: ActivityHeatmap | null
}
export interface ApiKey { export interface ApiKey {
id: string // UUID id: string // UUID
name: string name: string
@@ -146,7 +45,6 @@ export interface ApiKey {
total_requests?: number total_requests?: number
total_cost_usd?: number total_cost_usd?: number
rate_limit?: number | null rate_limit?: number | null
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置 force_capabilities?: Record<string, boolean> | null // 强制能力配置
} }
@@ -249,51 +147,6 @@ export const meApi = {
return response.data return response.data
}, },
// 使用统计
async getUsage(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:密钥名、模型名
limit?: number
offset?: number
}): Promise<UsageResponse> {
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data
},
// 获取活跃请求状态(用于轮询更新)
async getActiveRequests(ids?: string): Promise<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
}>
}> {
const params = ids ? { ids } : {}
const response = await apiClient.get('/api/users/me/usage/active', { params })
return response.data
},
// 获取可用的提供商
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get('/api/users/me/providers')
return response.data
},
// 获取用户可用的模型列表 // 获取用户可用的模型列表
async getAvailableModels(params?: { async getAvailableModels(params?: {
skip?: number skip?: number
@@ -334,16 +187,6 @@ export const meApi = {
return response.data return response.data
}, },
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
// API密钥提供商关联
async updateApiKeyProviders(keyId: string, data: {
allowed_providers?: ProviderConfig[]
}): Promise<{ message: string }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
return response.data
},
// API密钥能力配置 // API密钥能力配置
async updateApiKeyCapabilities(keyId: string, data: { async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null force_capabilities?: Record<string, boolean> | null
@@ -370,39 +213,4 @@ export const meApi = {
return response.data return response.data
}, },
// 获取请求间隔时间线(用于散点图)
async getIntervalTimeline(params?: {
hours?: number
limit?: number
}): Promise<{
analysis_period_hours: number
total_points: number
points: Array<{ x: string; y: number; model?: string }>
models?: string[]
}> {
const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data
},
30000
)
},
/**
* 获取活跃度热力图数据(用户)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest(
'me-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
},
60000
)
}
} }
+161
View File
@@ -0,0 +1,161 @@
import apiClient from './client'
export interface VideoBilling {
task_type: 'video' | 'image' | 'audio'
duration_seconds?: number
resolution?: string
video_price_per_second?: number
video_cost?: number
cost?: number
rule_name?: string
expression?: string
status?: string
}
export interface RequestDetail {
id: string
request_id: string
user: {
id: string | null
username: string | null
email: string | null
}
api_key: {
id: string | null
name: string | null
display: string | null
}
provider_api_key: {
id: string | null
name: string | null
}
provider: string
api_format?: string
model: string
target_model?: string | null
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: number
cache_read_input_tokens?: number
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number
request_type: string
is_stream: boolean
status_code: number
status?: string
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
has_client_response_body?: boolean
metadata?: Record<string, unknown>
tiered_pricing?: {
total_input_context: number
tier_index: number
tier_count: number
source?: 'provider' | 'global'
current_tier: {
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
} | null
video_billing?: VideoBilling | null
}
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, unknown>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, unknown>
response_time_ms: number
}
export const requestDetailsApi = {
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: options.includeBodies ?? true },
})
return response.data
},
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {},
)
return response.data
},
}
-247
View File
@@ -1,247 +0,0 @@
import apiClient from './client'
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
export interface UsageRecord {
id: string // UUID
user_id: string // UUID
username?: string
provider_id?: string // UUID
provider_name?: string
model: string
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost?: number
response_time?: number
created_at: string
has_fallback?: boolean // 🆕 是否发生了 fallback
}
export interface UsageStats {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost?: number
avg_response_time: number
today?: {
requests: number
tokens: number
cost: number
}
activity_heatmap?: ActivityHeatmap | null
}
export interface UsageByModel {
model: string
request_count: number
total_tokens: number
total_cost: number
avg_response_time?: number
cache_read_tokens?: number
cache_hit_rate?: number
}
export interface UsageByUser {
user_id: string // UUID
email: string
username: string
request_count: number
total_tokens: number
total_cost: number
}
export interface UsageByProvider {
provider_id: string
provider: string
request_count: number
total_tokens: number
total_cost: number
actual_cost: number
avg_response_time_ms: number
success_rate: number
error_count: number
cache_read_tokens?: number
cache_hit_rate?: number
}
export interface UsageByApiFormat {
api_format: string
request_count: number
total_tokens: number
total_cost: number
actual_cost: number
avg_response_time_ms: number
cache_read_tokens?: number
cache_hit_rate?: number
}
export interface UsageFilters {
user_id?: string // UUID
provider_id?: string // UUID
model?: string
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
page?: number
page_size?: number
}
export const usageApi = {
async getUsageRecords(filters?: UsageFilters): Promise<{
records: UsageRecord[]
total: number
page: number
page_size: number
}> {
const response = await apiClient.get('/api/usage', { params: filters })
return response.data
},
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
// 为统计数据添加30秒缓存
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
return response.data
},
30000 // 30秒缓存
)
},
/**
* Get usage aggregation by dimension (RESTful API)
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
* @param filters Optional filters
*/
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
groupBy: 'model' | 'user' | 'provider' | 'api_format',
filters?: UsageFilters & { limit?: number }
): Promise<T> {
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
params: { group_by: groupBy, ...filters }
})
return response.data
},
30000 // 30秒缓存
)
},
// Shorthand methods using getUsageAggregation
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters)
},
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters)
},
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
},
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
},
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
records: UsageRecord[]
stats: UsageStats
}> {
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
return response.data
},
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
const response = await apiClient.get('/api/usage/export', {
params: { ...filters, format },
responseType: 'blob'
})
return response.data
},
async getAllUsageRecords(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
user_id?: string // UUID
username?: string
model?: string
provider?: string
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
status?: string // 'stream' | 'standard' | 'error'
limit?: number
offset?: number
}): Promise<{
records: Array<Record<string, unknown>>
total: number
limit: number
offset: number
}> {
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
return dedupedRequest(key, async () => {
const response = await apiClient.get('/api/admin/usage/records', { params })
return response.data
})
},
/**
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
* @param ids 可选,逗号分隔的请求 ID 列表
*/
async getActiveRequests(ids?: string[]): Promise<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
provider?: string | null
api_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
target_model?: string | null
}>
}> {
const params = ids?.length ? { ids: ids.join(',') } : {}
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
},
/**
* 获取活跃度热力图数据(管理员)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest(
'admin-usage-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
return response.data
},
60000
)
}
}
-5
View File
@@ -141,10 +141,5 @@ export const usersApi = {
`/api/admin/users/${userId}/api-keys/${keyId}/full-key` `/api/admin/users/${userId}/api-keys/${keyId}/full-key`
) )
return response.data return response.data
},
// 管理员统计
async getUsageStats(): Promise<Record<string, unknown>> {
const response = await apiClient.get('/api/admin/usage/stats')
return response.data
} }
} }
+30 -16
View File
@@ -1,5 +1,8 @@
<template> <template>
<div class="w-full h-full"> <div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" /> <canvas ref="chartRef" />
</div> </div>
</template> </template>
@@ -18,6 +21,7 @@ import {
type ChartData, type ChartData,
type ChartOptions type ChartOptions
} from 'chart.js' } from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: 300, height: 300,
@@ -44,6 +48,7 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>() const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'bar'> | null = null let chart: ChartJS<'bar'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'bar'> = { const defaultOptions: ChartOptions<'bar'> = {
responsive: true, responsive: true,
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
} }
} }
function createChart() { function buildChartOptions(): ChartOptions<'bar'> {
if (!chartRef.value) return
const stackedOptions = props.stacked ? { const stackedOptions = props.stacked ? {
scales: { scales: {
x: { ...defaultOptions.scales?.x, stacked: true }, x: { ...defaultOptions.scales?.x, stacked: true },
@@ -106,20 +109,26 @@ function createChart() {
} }
} }
return resolveChartTheme({
...defaultOptions,
...stackedOptions,
...props.options
})
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, { chart = new ChartJS(chartRef.value, {
type: 'bar', type: 'bar',
data: props.data, data: resolveChartTheme(props.data),
options: { options: buildChartOptions()
...defaultOptions,
...stackedOptions,
...props.options
}
}) })
} }
function updateChart() { function updateChart() {
if (chart) { if (chart) {
chart.data = props.data chart.data = resolveChartTheme(props.data)
chart.update('none') chart.update('none')
} }
} }
@@ -127,9 +136,17 @@ function updateChart() {
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
createChart() createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = buildChartOptions()
chart.update('none')
})
}) })
onUnmounted(() => { onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) { if (chart) {
chart.destroy() chart.destroy()
chart = null chart = null
@@ -139,11 +156,8 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true }) watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => { watch(() => props.options, () => {
if (chart) { if (chart) {
chart.options = { chart.options = buildChartOptions()
...defaultOptions, chart.update('none')
...props.options
}
chart.update()
} }
}, { deep: true }) }, { deep: true })
</script> </script>
@@ -1,5 +1,8 @@
<template> <template>
<div class="w-full h-full"> <div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" /> <canvas ref="chartRef" />
</div> </div>
</template> </template>
@@ -16,10 +19,12 @@ import {
type ChartData, type ChartData,
type ChartOptions type ChartOptions
} from 'chart.js' } from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: 300, height: 300,
options: undefined options: undefined,
showLegend: true,
}) })
ChartJS.register( ChartJS.register(
@@ -34,15 +39,22 @@ interface Props {
data: ChartData<'doughnut'> data: ChartData<'doughnut'>
options?: ChartOptions<'doughnut'> options?: ChartOptions<'doughnut'>
height?: number height?: number
showLegend?: boolean
} }
const chartRef = ref<HTMLCanvasElement>() const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'doughnut'> | null = null let chart: ChartJS<'doughnut'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'doughnut'> = { const defaultOptions: ChartOptions<'doughnut'> = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
cutout: '60%', cutout: '60%',
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
interaction: {
mode: 'nearest',
intersect: true,
},
plugins: { plugins: {
legend: { legend: {
position: 'right', position: 'right',
@@ -71,22 +83,59 @@ const defaultOptions: ChartOptions<'doughnut'> = {
} }
} }
function resolvedOptions(): ChartOptions<'doughnut'> {
const incomingOptions = props.options ?? {}
const incomingPlugins = (incomingOptions.plugins ?? {}) as Record<string, unknown>
const incomingLegend = (incomingPlugins.legend ?? {}) as Record<string, unknown>
const incomingTooltip = (incomingPlugins.tooltip ?? {}) as Record<string, unknown>
const defaultPlugins = (defaultOptions.plugins ?? {}) as Record<string, unknown>
const defaultLegend = (defaultPlugins.legend ?? {}) as Record<string, unknown>
const defaultTooltip = (defaultPlugins.tooltip ?? {}) as Record<string, unknown>
return resolveChartTheme({
...defaultOptions,
...incomingOptions,
interaction: {
...defaultOptions.interaction,
...incomingOptions.interaction,
},
plugins: {
...defaultPlugins,
...incomingPlugins,
legend: {
...defaultLegend,
...incomingLegend,
display: incomingLegend.display ?? props.showLegend,
labels: {
...(defaultLegend.labels as Record<string, unknown> | undefined),
...(incomingLegend.labels as Record<string, unknown> | undefined),
},
},
tooltip: {
...defaultTooltip,
...incomingTooltip,
callbacks: {
...(defaultTooltip.callbacks as Record<string, unknown> | undefined),
...(incomingTooltip.callbacks as Record<string, unknown> | undefined),
},
},
},
})
}
function createChart() { function createChart() {
if (!chartRef.value) return if (!chartRef.value) return
chart = new ChartJS(chartRef.value, { chart = new ChartJS(chartRef.value, {
type: 'doughnut', type: 'doughnut',
data: props.data, data: resolveChartTheme(props.data),
options: { options: resolvedOptions(),
...defaultOptions,
...props.options
}
}) })
} }
function updateChart() { function updateChart() {
if (chart) { if (chart) {
chart.data = props.data chart.data = resolveChartTheme(props.data)
chart.update('none') chart.update('none')
} }
} }
@@ -94,9 +143,17 @@ function updateChart() {
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
createChart() createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = resolvedOptions()
chart.update('none')
})
}) })
onUnmounted(() => { onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) { if (chart) {
chart.destroy() chart.destroy()
chart = null chart = null
@@ -106,8 +163,15 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true }) watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => { watch(() => props.options, () => {
if (chart) { if (chart) {
chart.options = { ...defaultOptions, ...props.options } chart.options = resolvedOptions()
chart.update() chart.update('none')
} }
}, { deep: true }) }, { deep: true })
watch(() => props.showLegend, () => {
if (chart) {
chart.options = resolvedOptions()
chart.update('none')
}
})
</script> </script>
+25 -6
View File
@@ -1,5 +1,8 @@
<template> <template>
<div class="w-full h-full"> <div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" /> <canvas ref="chartRef" />
</div> </div>
</template> </template>
@@ -16,9 +19,11 @@ import {
Title, Title,
Tooltip, Tooltip,
Legend, Legend,
Filler,
type ChartData, type ChartData,
type ChartOptions type ChartOptions
} from 'chart.js' } from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: 300, height: 300,
@@ -34,7 +39,8 @@ ChartJS.register(
LineController, LineController,
Title, Title,
Tooltip, Tooltip,
Legend Legend,
Filler
) )
interface Props { interface Props {
@@ -45,12 +51,17 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>() const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'line'> | null = null let chart: ChartJS<'line'> | null = null
let stopThemeObserver: (() => void) | null = null
function buildChartOptions(): ChartOptions<'line'> { function buildChartOptions(): ChartOptions<'line'> {
return { return resolveChartTheme({
...defaultOptions, ...defaultOptions,
...props.options ...props.options
} })
}
function buildChartData(): ChartData<'line'> {
return resolveChartTheme(props.data)
} }
const defaultOptions: ChartOptions<'line'> = { const defaultOptions: ChartOptions<'line'> = {
@@ -95,14 +106,14 @@ function createChart() {
chart = new ChartJS(chartRef.value, { chart = new ChartJS(chartRef.value, {
type: 'line', type: 'line',
data: props.data, data: buildChartData(),
options: buildChartOptions() options: buildChartOptions()
}) })
} }
function updateChart() { function updateChart() {
if (chart) { if (chart) {
chart.data = props.data chart.data = buildChartData()
chart.update('none') // 禁用动画以提高性能 chart.update('none') // 禁用动画以提高性能
} }
} }
@@ -110,9 +121,17 @@ function updateChart() {
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
createChart() createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = buildChartData()
chart.options = buildChartOptions()
chart.update('none')
})
}) })
onUnmounted(() => { onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) { if (chart) {
chart.destroy() chart.destroy()
chart = null chart = null
@@ -3,39 +3,24 @@
<Select <Select
v-model="selectedPreset" v-model="selectedPreset"
> >
<SelectTrigger class="h-8 w-32 text-xs border-border/60"> <SelectTrigger :class="presetTriggerClass">
<SelectValue placeholder="选择时间段" /> <SelectValue placeholder="选择时间段" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="today"> <SelectItem value="today">
今天 今天
</SelectItem> </SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days"> <SelectItem value="last7days">
最近7天 最近7天
</SelectItem> </SelectItem>
<SelectItem value="last30days"> <SelectItem value="last30days">
最近30天 最近30天
</SelectItem> </SelectItem>
<SelectItem value="last90days"> <SelectItem value="last180days">
最近90天 最近180天
</SelectItem> </SelectItem>
<SelectItem value="this_week"> <SelectItem value="last1year">
本周 最近一年
</SelectItem>
<SelectItem value="last_week">
上周
</SelectItem>
<SelectItem value="this_month">
本月
</SelectItem>
<SelectItem value="last_month">
上月
</SelectItem>
<SelectItem value="this_year">
今年
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
自定义 自定义
@@ -50,13 +35,13 @@
<Input <Input
v-model="startDate" v-model="startDate"
type="date" type="date"
class="h-8 w-36 text-xs border-border/60" :class="dateInputClass"
/> />
<span class="text-xs text-muted-foreground">至</span> <span class="text-xs text-muted-foreground">至</span>
<Input <Input
v-model="endDate" v-model="endDate"
type="date" type="date"
class="h-8 w-36 text-xs border-border/60" :class="dateInputClass"
/> />
</div> </div>
@@ -64,10 +49,16 @@
v-if="showGranularity" v-if="showGranularity"
v-model="selectedGranularity" v-model="selectedGranularity"
> >
<SelectTrigger class="h-8 w-24 text-xs border-border/60"> <SelectTrigger :class="granularityTriggerClass">
<SelectValue placeholder="粒度" /> <SelectValue placeholder="粒度" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem
v-if="includeAutoGranularity"
value="auto"
>
自动
</SelectItem>
<SelectItem <SelectItem
v-if="allowHourly && canUseHourly" v-if="allowHourly && canUseHourly"
value="hour" value="hour"
@@ -100,26 +91,51 @@ import {
} from '@/components/ui' } from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types' import type { DateRangeParams } from '@/features/usage/types'
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
const props = defineProps<{ const props = defineProps<{
modelValue: DateRangeParams modelValue: DateRangeParams
showGranularity?: boolean showGranularity?: boolean
allowHourly?: boolean allowHourly?: boolean
includeAutoGranularity?: boolean
compact?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams] 'update:modelValue': [value: DateRangeParams]
}>() }>()
const selectedPreset = ref(props.modelValue.preset || 'last7days') function getDefaultGranularity(): PickerGranularity {
return props.includeAutoGranularity ? 'auto' : 'day'
}
const selectedPreset = ref(props.modelValue.preset || 'today')
const startDate = ref(props.modelValue.start_date || '') const startDate = ref(props.modelValue.start_date || '')
const endDate = ref(props.modelValue.end_date || '') const endDate = ref(props.modelValue.end_date || '')
const selectedGranularity = ref(props.modelValue.granularity || 'day') const selectedGranularity = ref<PickerGranularity>(props.modelValue.granularity || getDefaultGranularity())
const showGranularity = computed(() => props.showGranularity !== false) const showGranularity = computed(() => props.showGranularity !== false)
const allowHourly = computed(() => props.allowHourly === true) const allowHourly = computed(() => props.allowHourly === true)
const includeAutoGranularity = computed(() => props.includeAutoGranularity === true)
const compact = computed(() => props.compact === true)
const presetTriggerClass = computed(() =>
compact.value
? 'h-8 w-28 text-xs border-border/60'
: 'h-8 w-32 text-xs border-border/60',
)
const dateInputClass = computed(() =>
compact.value
? 'h-8 w-32 text-xs border-border/60'
: 'h-8 w-36 text-xs border-border/60',
)
const granularityTriggerClass = computed(() =>
compact.value
? 'h-8 w-20 text-xs border-border/60'
: 'h-8 w-24 text-xs border-border/60',
)
const canUseHourly = computed(() => { const canUseHourly = computed(() => {
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true if (selectedPreset.value === 'today') return true
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) { if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
return startDate.value === endDate.value return startDate.value === endDate.value
} }
@@ -165,7 +181,7 @@ watch(() => props.modelValue, (value) => {
if (value.preset) selectedPreset.value = value.preset if (value.preset) selectedPreset.value = value.preset
if (value.start_date !== undefined) startDate.value = value.start_date || '' if (value.start_date !== undefined) startDate.value = value.start_date || ''
if (value.end_date !== undefined) endDate.value = value.end_date || '' if (value.end_date !== undefined) endDate.value = value.end_date || ''
if (value.granularity) selectedGranularity.value = value.granularity selectedGranularity.value = value.granularity || getDefaultGranularity()
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit // 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
lastEmittedValue = getValueKey(value) lastEmittedValue = getValueKey(value)
}, { deep: true }) }, { deep: true })
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
watch([selectedPreset, startDate, endDate, selectedGranularity], () => { watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) { if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') { if (selectedGranularity.value === 'hour') {
selectedGranularity.value = 'day' selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
} }
} }
@@ -13,8 +13,11 @@
<p class="mt-0.5"> <p class="mt-0.5">
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }} {{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
</p> </p>
<p class="text-[11px] text-muted-foreground"> <p class="text-[11px] text-muted-foreground whitespace-nowrap">
成本 {{ formatCurrency(tooltip.day.total_cost) }} {{ valueLabel }} {{ formatCurrency(tooltip.day.total_cost) }}
<template v-if="actualValueLabel && tooltip.day.actual_total_cost !== undefined">
· {{ actualValueLabel }} {{ formatCurrency(tooltip.day.actual_total_cost) }}
</template>
</p> </p>
</div> </div>
</Teleport> </Teleport>
@@ -90,7 +93,7 @@
class="flex items-center invisible" class="flex items-center invisible"
>周六</span> >周六</span>
</div> </div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-0">
<div <div
ref="heatmapWrapper" ref="heatmapWrapper"
class="relative block w-full" class="relative block w-full"
@@ -100,12 +103,12 @@
:style="horizontalGapStyle" :style="horizontalGapStyle"
> >
<div <div
v-for="(week, weekIndex) in weekColumns" v-for="(week, weekIndex) in visibleWeekColumns"
:key="`month-${weekIndex}`" :key="`month-${weekIndex}`"
:style="monthCellStyle" :style="monthCellStyle"
class="text-center" class="text-center"
> >
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span> <span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
</div> </div>
</div> </div>
<div <div
@@ -113,7 +116,7 @@
:style="horizontalGapStyle" :style="horizontalGapStyle"
> >
<div <div
v-for="(week, weekIndex) in weekColumns" v-for="(week, weekIndex) in visibleWeekColumns"
:key="weekIndex" :key="weekIndex"
class="flex flex-col" class="flex flex-col"
:style="verticalGapStyle" :style="verticalGapStyle"
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
title?: string title?: string
subtitle?: string subtitle?: string
showHeader?: boolean showHeader?: boolean
valueLabel?: string
actualValueLabel?: string
}>(), { }>(), {
data: undefined, data: undefined,
title: undefined, title: undefined,
subtitle: undefined, subtitle: undefined,
showHeader: true showHeader: true,
valueLabel: '费用',
actualValueLabel: undefined,
}) })
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85] const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
if (month === lastMonth) { if (month === lastMonth) {
return return
} }
markers[index] = `${month + 1}月` markers[index] = String(month + 1)
lastMonth = month
})
return markers
})
const MIN_CELL_SIZE = 6
// 根据容器宽度计算最多能放多少周,然后从后面截取(保留最新数据)
const maxVisibleWeeks = computed(() => {
const total = weekColumns.value.length
if (!heatmapWidth.value || !total) return total
// 先尝试用理想尺寸放全部
const totalGapAll = Math.max(total - 1, 0) * cellGap.value
const rawSizeAll = (heatmapWidth.value - totalGapAll) / total
if (rawSizeAll >= MIN_CELL_SIZE) return total
// 放不下,算容器最多能容纳几周(用最小尺寸)
// n * MIN_CELL_SIZE + (n-1) * gap <= width
const maxN = Math.floor((heatmapWidth.value + cellGap.value) / (MIN_CELL_SIZE + cellGap.value))
return Math.max(1, Math.min(maxN, total))
})
const visibleWeekColumns = computed(() => {
const all = weekColumns.value
const n = maxVisibleWeeks.value
if (n >= all.length) return all
return all.slice(all.length - n)
})
const visibleMonthMarkers = computed(() => {
const markers: Record<number, string> = {}
const columns = visibleWeekColumns.value
let lastMonth: number | null = null
columns.forEach((week, index) => {
const firstValid = week.find((day): day is DayWithMeta => day !== null)
if (!firstValid) return
const month = firstValid.dateObj.getUTCMonth()
if (month === lastMonth) return
markers[index] = String(month + 1)
lastMonth = month lastMonth = month
}) })
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => { const recalcCellSize = () => {
const columnCount = weekColumns.value.length const columnCount = visibleWeekColumns.value.length
if (!columnCount || !heatmapWidth.value) { if (!columnCount || !heatmapWidth.value) {
return return
} }
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0) const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount const rawSize = availableSpace / columnCount
// 自适应尺寸,最小 6px // 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
cellSize.value = Math.max(6, rawSize) cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
} }
watch( watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value], [() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
() => { () => {
recalcCellSize() recalcCellSize()
}, },
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) { function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect() const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200 const tooltipWidth = 280
const tooltipHeight = 72 const tooltipHeight = 72
// Calculate horizontal position (centered on cell) // Calculate horizontal position (centered on cell)
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
function buildTooltip(day: ActivityHeatmapDay): string { function buildTooltip(day: ActivityHeatmapDay): string {
const dateLabel = day.date const dateLabel = day.date
const costLabel = formatCurrency(day.total_cost || 0) const costLabel = `${props.valueLabel} ${formatCurrency(day.total_cost || 0)}`
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel] const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
if (day.actual_total_cost !== undefined) { if (props.actualValueLabel && day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`) parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
} }
return parts.join(' · ') return parts.join(' · ')
} }
@@ -1,38 +0,0 @@
<template>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">
{{ label }}
</div>
<div class="text-lg font-semibold">
{{ value }}
</div>
<div
class="text-xs"
:class="changeClass"
>
<span v-if="changePercent !== null">{{ changePercent }}%</span>
<span v-else>--</span>
<span class="ml-1 text-muted-foreground">vs 对比期</span>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Card } from '@/components/ui'
interface Props {
label: string
value: string
changePercent: number | null
}
const props = defineProps<Props>()
const changeClass = computed(() => {
if (props.changePercent === null) return 'text-muted-foreground'
if (props.changePercent > 0) return 'text-emerald-500'
if (props.changePercent < 0) return 'text-rose-500'
return 'text-muted-foreground'
})
</script>
@@ -1,93 +0,0 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[280px]"
>
<LineChart
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
interface Props {
title: string
subtitle?: string
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
})
const labels = computed(() => [
...props.history.map(item => item.date),
...props.forecast.map(item => item.date)
])
const chartData = computed(() => {
const historyValues = props.history.map(item => item.total_cost)
const forecastValues = props.forecast.map(item => item.total_cost)
return {
labels: labels.value,
datasets: [
{
label: '实际成本',
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
tension: 0.25,
pointRadius: 2
},
{
label: '预测成本',
data: new Array(historyValues.length).fill(null).concat(forecastValues),
borderColor: 'rgb(234, 179, 8)',
backgroundColor: 'rgba(234, 179, 8, 0.15)',
borderDash: [6, 4],
tension: 0.25,
pointRadius: 2
}
]
}
})
const chartOptions = computed(() => ({
plugins: {
tooltip: {
callbacks: {
label: (context: { parsed?: { y?: number }; dataset: { label?: string } }) => {
const value = context.parsed?.y ?? 0
return `${context.dataset.label}: ${formatCurrency(value)}`
}
}
}
}
}))
</script>
@@ -1,14 +1,34 @@
<template> <template>
<div class="space-y-3"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<h3 class="text-sm font-semibold"> <div>
{{ title }} <h3 class="text-sm font-semibold">
</h3> {{ title }}
<span </h3>
v-if="subtitle" <p
class="text-xs text-muted-foreground" v-if="subtitle"
>{{ subtitle }}</span> class="mt-1 text-[11px] text-muted-foreground"
>
{{ subtitle }}
</p>
</div>
<div
v-if="!unavailable"
class="flex flex-wrap gap-2"
>
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
总错误 {{ totalErrors }}
</div>
<div
v-if="topCategory"
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
>
TOP {{ topCategory.label }} · {{ topCategory.count }}
</div>
</div>
</div> </div>
<div <div
v-if="loading" v-if="loading"
class="p-6" class="p-6"
@@ -16,57 +36,116 @@
<LoadingState /> <LoadingState />
</div> </div>
<div <div
v-else v-else-if="unavailable"
class="h-[260px]" class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
> >
<DoughnutChart {{ unavailableText }}
:data="chartData" </div>
:options="chartOptions" <div
/> v-else-if="!hasData"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
暂无错误分布数据
</div>
<div
v-else
class="rounded-2xl border border-border/60 bg-background p-3"
>
<div class="mx-auto w-full max-w-[320px]">
<DoughnutChart
:data="chartData"
:options="chartOptions"
:show-legend="true"
:height="248"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import DoughnutChart from '@/components/charts/DoughnutChart.vue' import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { LoadingState } from '@/components/common' import { LoadingState } from '@/components/common'
import type { ErrorDistributionItem } from '@/api/admin'
interface ErrorDistributionItem {
category: string
label: string
count: number
}
interface Props { interface Props {
title: string title: string
subtitle?: string subtitle?: string
distribution: ErrorDistributionItem[] distribution: ErrorDistributionItem[]
loading?: boolean loading?: boolean
unavailable?: boolean
unavailableText?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
subtitle: undefined, subtitle: undefined,
loading: false loading: false,
unavailable: false,
unavailableText: '数据暂不可用',
}) })
const chartData = computed(() => ({ const chartColors = [
labels: props.distribution.map(item => item.category), 'rgba(226, 85, 118, 0.88)',
'rgba(84, 135, 237, 0.86)',
'rgba(225, 165, 52, 0.84)',
'rgba(68, 187, 132, 0.82)',
'rgba(148, 163, 184, 0.8)',
'rgba(145, 118, 214, 0.82)',
]
const totalErrors = computed(() => props.distribution.reduce((sum, item) => sum + item.count, 0))
const hasData = computed(() => totalErrors.value > 0)
const distributionWithShare = computed(() => props.distribution.map(item => ({
...item,
label: item.label?.trim() || item.category,
share: totalErrors.value > 0 ? `${((item.count / totalErrors.value) * 100).toFixed(1)}%` : '0.0%',
})))
const topCategory = computed(() => (
distributionWithShare.value.slice().sort((left, right) => right.count - left.count)[0] ?? null
))
const chartData = computed<ChartData<'doughnut'>>(() => ({
labels: distributionWithShare.value.map(item => item.label),
datasets: [ datasets: [
{ {
data: props.distribution.map(item => item.count), data: distributionWithShare.value.map(item => item.count),
backgroundColor: [ backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
'rgba(239, 68, 68, 0.7)', borderColor: 'rgba(255,255,255,0.92)',
'rgba(59, 130, 246, 0.7)', borderWidth: 2,
'rgba(234, 179, 8, 0.7)', hoverOffset: 6,
'rgba(34, 197, 94, 0.7)', },
'rgba(148, 163, 184, 0.7)' ],
],
borderWidth: 0
}
]
})) }))
const chartOptions = computed(() => ({ const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
cutout: '68%',
plugins: { plugins: {
legend: { legend: {
position: 'bottom' as const position: 'bottom',
} align: 'center',
} labels: {
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 10,
padding: 14,
},
},
tooltip: {
callbacks: {
label: (context) => {
const value = Number(context.raw ?? 0)
const percentage = totalErrors.value > 0 ? ((value / totalErrors.value) * 100).toFixed(1) : '0.0'
return `${context.label ?? '错误'}: ${value} (${percentage}%)`
},
},
},
},
})) }))
</script> </script>
@@ -1,34 +1,30 @@
<template> <template>
<TableCard :title="title"> <TableCard :title="title">
<template #actions> <template #actions>
<Select <div
v-if="showMetricSelect" v-if="isRefreshing"
:model-value="metric" class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
@update:model-value="emitMetric"
> >
<SelectTrigger class="h-8 text-xs w-28"> 更新中
<SelectValue placeholder="指标" /> </div>
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
</template> </template>
<div <div
v-if="loading" v-if="showLoadingState"
class="p-6" class="p-6"
> >
<LoadingState /> <LoadingState />
</div> </div>
<div
v-else-if="showUnavailableState"
class="p-6"
>
<EmptyState
type="error"
:title="unavailableTitle"
:description="unavailableDescription"
/>
</div>
<div <div
v-else-if="items.length === 0" v-else-if="items.length === 0"
class="p-6" class="p-6"
@@ -38,20 +34,29 @@
description="当前时间范围内没有统计结果" description="当前时间范围内没有统计结果"
/> />
</div> </div>
<Table v-else> <Table
v-else
class="table-fixed"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-16"> <TableHead class="w-14 px-2.5">
排名 排名
</TableHead> </TableHead>
<TableHead>名称</TableHead> <TableHead class="w-[34%] px-2.5">
<TableHead class="text-right"> 名称
</TableHead>
<TableHead class="w-[15%] px-2.5 text-right">
请求数 请求数
</TableHead> </TableHead>
<TableHead class="text-right"> <TableHead class="w-[18%] px-2.5 text-right">
Tokens Tokens
</TableHead> </TableHead>
<TableHead class="text-right"> <TableHead class="w-[16%] px-2.5 text-right">
费用
</TableHead>
<TableHead class="w-[17%] px-2.5 text-right">
成本 成本
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -60,68 +65,118 @@
<TableRow <TableRow
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
class="cursor-pointer"
:class="item.id === selectedId
? 'bg-[linear-gradient(90deg,rgba(232,145,89,0.16),rgba(232,145,89,0.05),transparent)] shadow-[inset_4px_0_0_0_rgba(232,145,89,0.9)] hover:bg-[linear-gradient(90deg,rgba(232,145,89,0.2),rgba(232,145,89,0.06),transparent)]'
: 'hover:bg-muted/25'"
@click="emit('select', item.id)"
> >
<TableCell class="font-medium"> <TableCell class="px-2.5 py-2.5">
{{ item.rank }} <div
class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums transition-colors"
:class="item.id === selectedId
? 'border-amber-500/70 bg-amber-500/15 text-amber-700 dark:text-amber-300'
: 'border-border/70 bg-muted/35 text-muted-foreground'"
>
{{ item.rank }}
</div>
</TableCell> </TableCell>
<TableCell>{{ item.name }}</TableCell> <TableCell class="px-2.5 py-2.5">
<TableCell class="text-right"> <div class="min-w-0">
{{ item.requests }} <div
class="truncate text-sm font-medium"
:title="item.name"
>
{{ item.name }}
</div>
<div class="mt-0.5 truncate text-[10px] text-muted-foreground">
费用优先
</div>
</div>
</TableCell> </TableCell>
<TableCell class="text-right"> <TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
{{ item.requests.toLocaleString() }}
</TableCell>
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
{{ formatTokens(item.tokens) }} {{ formatTokens(item.tokens) }}
</TableCell> </TableCell>
<TableCell class="text-right"> <TableCell class="px-2.5 py-2.5 text-right text-[11px] font-medium tabular-nums text-amber-700 dark:text-amber-300">
{{ formatCurrency(item.cost) }} {{ formatCurrency(item.cost) }}
</TableCell> </TableCell>
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
{{ formatCurrency(item.actualCost) }}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<template v-if="showPagination" #pagination>
<Pagination
:current="currentPage"
:total="totalItems"
:page-size="pageSize"
:show-page-size-selector="false"
@update:current="emit('update:currentPage', $event)"
/>
</template>
</TableCard> </TableCard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { EmptyState, LoadingState } from '@/components/common' import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui' import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { formatCurrency, formatTokens } from '@/utils/format' import { formatCurrency, formatTokens } from '@/utils/format'
import type { LeaderboardItem } from '@/api/admin'
interface LeaderboardItem {
rank: number
id: string
name: string
requests: number
tokens: number
cost: number
actualCost: number
}
interface Props { interface Props {
title: string title: string
items: LeaderboardItem[] items: LeaderboardItem[]
metric: 'requests' | 'tokens' | 'cost' selectedId?: string
loading?: boolean loading?: boolean
showMetricSelect?: boolean hasLoaded?: boolean
currentPage?: number
totalItems?: number
pageSize?: number
showPagination?: boolean
unavailable?: boolean
unavailableTitle?: string
unavailableDescription?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
selectedId: undefined,
loading: false, loading: false,
showMetricSelect: true hasLoaded: false,
currentPage: 1,
totalItems: 0,
pageSize: 5,
showPagination: false,
unavailable: false,
unavailableTitle: '排行榜暂不可用',
unavailableDescription: '接口未返回结果,请稍后重试',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void (e: 'select', value: string): void
(e: 'update:currentPage', value: number): void
}>() }>()
const metric = computed(() => props.metric) const selectedId = computed(() => props.selectedId)
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
function emitMetric(value: string) { const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
if (value === 'requests' || value === 'tokens' || value === 'cost') { const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
emit('update:metric', value) const currentPage = computed(() => props.currentPage)
} const totalItems = computed(() => props.totalItems)
} const pageSize = computed(() => props.pageSize)
const showPagination = computed(() => props.showPagination && props.totalItems > props.pageSize)
</script> </script>
+193 -53
View File
@@ -1,27 +1,59 @@
<template> <template>
<div class="space-y-3"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<h3 class="text-sm font-semibold"> <div>
{{ title }} <h3 class="text-sm font-semibold">
</h3> {{ title }}
<span </h3>
v-if="subtitle" <p
class="text-xs text-muted-foreground" v-if="subtitle"
>{{ subtitle }}</span> class="mt-1 text-[11px] text-muted-foreground"
>
{{ subtitle }}
</p>
</div>
<div
v-if="summaryMetrics.length > 0 && !unavailable"
class="flex flex-wrap gap-2"
>
<div
v-for="metric in summaryMetrics"
:key="metric.label"
class="rounded-full border px-2.5 py-1 text-[10px] font-medium"
:class="metric.className"
>
{{ metric.label }} {{ metric.value }}
</div>
</div>
</div> </div>
<div <div
v-if="loading" v-if="loading"
class="p-6" class="p-6"
> >
<LoadingState /> <LoadingState />
</div> </div>
<div
v-else-if="unavailable"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
{{ unavailableText }}
</div>
<div
v-else-if="!hasData"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
暂无延迟数据
</div>
<div <div
v-else v-else
class="h-[260px]" class="rounded-2xl border border-border/60 bg-background p-3"
> >
<LineChart <LineChart
:data="chartData" :data="chartData"
:options="chartOptions" :options="chartOptions"
:height="248"
/> />
</div> </div>
</div> </div>
@@ -29,9 +61,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import LineChart from '@/components/charts/LineChart.vue' import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common' import { LoadingState } from '@/components/common'
import type { PercentileItem } from '@/api/admin'
interface PercentileItem {
date: string
p50_response_time_ms?: number | null
p90_response_time_ms?: number | null
p99_response_time_ms?: number | null
p50_first_byte_time_ms?: number | null
p90_first_byte_time_ms?: number | null
p99_first_byte_time_ms?: number | null
}
interface Props { interface Props {
title: string title: string
@@ -39,61 +81,159 @@ interface Props {
series: PercentileItem[] series: PercentileItem[]
mode: 'response' | 'ttfb' mode: 'response' | 'ttfb'
loading?: boolean loading?: boolean
unavailable?: boolean
unavailableText?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
subtitle: undefined, subtitle: undefined,
loading: false loading: false,
unavailable: false,
unavailableText: '数据暂不可用',
}) })
const labels = computed(() => props.series.map(item => item.date))
// 毫秒转秒
function msToSeconds(ms: number | null | undefined): number | null { function msToSeconds(ms: number | null | undefined): number | null {
if (ms == null) return null if (ms == null) return null
return ms / 1000 return Number((ms / 1000).toFixed(3))
} }
const chartData = computed(() => { function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms' if (props.mode === 'response') {
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms' if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms' if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
return msToSeconds(item.p99_response_time_ms)
return {
labels: labels.value,
datasets: [
{
label: 'P50',
data: props.series.map(item => msToSeconds(item[p50Key])),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P90',
data: props.series.map(item => msToSeconds(item[p90Key])),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P99',
data: props.series.map(item => msToSeconds(item[p99Key])),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
} }
})
const chartOptions = computed(() => ({ if (percentile === '50') return msToSeconds(item.p50_first_byte_time_ms)
if (percentile === '90') return msToSeconds(item.p90_first_byte_time_ms)
return msToSeconds(item.p99_first_byte_time_ms)
}
function getLatestMetricValue(percentile: '50' | '90' | '99') {
for (let index = props.series.length - 1; index >= 0; index -= 1) {
const value = getPercentileValue(props.series[index], percentile)
if (value != null) return value
}
return null
}
function formatSeconds(value: number | null) {
if (value == null) return '--'
if (value < 1) return `${Math.round(value * 1000)}ms`
return `${value.toFixed(2)}s`
}
const labels = computed(() => props.series.map(item => item.date?.slice(5) ?? ''))
const hasData = computed(() => props.series.some(item => (
getPercentileValue(item, '50') != null
|| getPercentileValue(item, '90') != null
|| getPercentileValue(item, '99') != null
)))
const summaryMetrics = computed(() => ([
{
label: 'P50',
value: formatSeconds(getLatestMetricValue('50')),
className: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
},
{
label: 'P90',
value: formatSeconds(getLatestMetricValue('90')),
className: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
},
{
label: 'P99',
value: formatSeconds(getLatestMetricValue('99')),
className: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
},
]))
const chartData = computed<ChartData<'line'>>(() => ({
labels: labels.value,
datasets: [
{
label: 'P50',
data: props.series.map(item => getPercentileValue(item, '50')),
borderColor: 'rgb(70, 136, 240)',
backgroundColor: 'rgba(70, 136, 240, 0.10)',
borderWidth: 2.4,
fill: false,
tension: 0.32,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
},
{
label: 'P90',
data: props.series.map(item => getPercentileValue(item, '90')),
borderColor: 'rgb(223, 166, 55)',
backgroundColor: 'rgba(223, 166, 55, 0.08)',
borderWidth: 2.1,
fill: false,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
},
{
label: 'P99',
data: props.series.map(item => getPercentileValue(item, '99')),
borderColor: 'rgb(214, 92, 111)',
backgroundColor: 'rgba(214, 92, 111, 0.08)',
borderWidth: 2,
borderDash: [6, 4],
fill: false,
tension: 0.28,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
},
],
}))
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 10,
padding: 14,
},
},
tooltip: {
callbacks: {
title: items => props.series[items[0]?.dataIndex ?? -1]?.date ?? '',
label: context => `${context.dataset.label}: ${formatSeconds(Number(context.raw ?? 0))}`,
},
},
},
scales: { scales: {
y: { x: {
grid: {
color: 'rgba(148, 163, 184, 0.06)',
},
ticks: { ticks: {
callback: (value: number) => `${value}s` color: 'rgb(100, 116, 139)',
} maxRotation: 0,
} },
} },
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.07)',
},
ticks: {
color: 'rgb(100, 116, 139)',
callback: value => formatSeconds(Number(value)),
},
},
},
})) }))
</script> </script>
@@ -1,81 +0,0 @@
<template>
<Card class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div
v-if="loading"
class="p-4"
>
<LoadingState />
</div>
<div
v-else-if="providers.length === 0"
class="p-4"
>
<EmptyState
title="暂无数据"
description="暂无月卡配额数据"
/>
</div>
<div
v-else
class="space-y-4"
>
<div
v-for="provider in providers"
:key="provider.id"
class="space-y-2"
>
<div class="flex items-center justify-between text-xs">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-muted-foreground">
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
</span>
</div>
<div class="h-2 rounded-full bg-muted">
<div
class="h-2 rounded-full bg-primary"
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
/>
</div>
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
<span v-if="provider.estimated_exhaust_at">
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
import type { QuotaUsageProvider } from '@/api/admin'
interface Props {
title: string
subtitle?: string
providers: QuotaUsageProvider[]
loading?: boolean
}
withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
})
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
</script>
-3
View File
@@ -1,7 +1,4 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.vue' export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
export { default as LeaderboardTable } from './LeaderboardTable.vue' export { default as LeaderboardTable } from './LeaderboardTable.vue'
export { default as CostForecastChart } from './CostForecastChart.vue'
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
export { default as PercentileChart } from './PercentileChart.vue' export { default as PercentileChart } from './PercentileChart.vue'
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue' export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
export { default as ComparisonCard } from './ComparisonCard.vue'
+11 -3
View File
@@ -59,7 +59,7 @@ const isHidden = computed(
) )
const itemClass = computed(() => const itemClass = computed(() =>
cn( cn(
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none', 'relative flex min-w-0 w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
'data-[highlighted]:bg-accent focus:bg-accent text-foreground', 'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
isHidden.value && 'hidden', isHidden.value && 'hidden',
@@ -101,8 +101,16 @@ onBeforeUnmount(() => {
<Check class="h-4 w-4" /> <Check class="h-4 w-4" />
</SelectItemIndicator> </SelectItemIndicator>
</span> </span>
<SelectItemText> <SelectItemText
<slot /> as="div"
class="min-w-0 flex-1 overflow-hidden"
>
<span
class="block w-full min-w-0 truncate"
:title="normalizedText"
>
<slot />
</span>
</SelectItemText> </SelectItemText>
</SelectItemPrimitive> </SelectItemPrimitive>
</template> </template>
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
:class="triggerClass" :class="triggerClass"
:disabled="disabled" :disabled="disabled"
> >
<span class="truncate"> <span class="min-w-0 flex-1 truncate">
<slot /> <slot />
</span> </span>
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" /> <ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
@@ -0,0 +1,3 @@
export { useLeaderboardData } from './useLeaderboardData'
export type { LeaderboardDimension } from './useLeaderboardData'
export { usePerformanceData } from './usePerformanceData'
@@ -0,0 +1,23 @@
import type { AnalyticsLeaderboardItem } from '@/api/analytics'
export interface LeaderboardTableRow {
rank: number
id: string
name: string
requests: number
tokens: number
cost: number
actualCost: number
}
export function mapAnalyticsLeaderboardItem(item: AnalyticsLeaderboardItem): LeaderboardTableRow {
return {
rank: item.rank,
id: item.id,
name: item.label,
requests: item.requests_total,
tokens: item.total_tokens,
cost: item.total_cost_usd,
actualCost: item.actual_total_cost_usd,
}
}
@@ -0,0 +1,272 @@
import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
import { analyticsApi, type AnalyticsFilterOption, type AnalyticsLeaderboardItem, type AnalyticsTimeseriesBucket } from '@/api/analytics'
import { buildTimeRangeParams, createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
import { isAnalyticsSingleDayRange, resolveAnalyticsAutoGranularity, type AnalyticsGranularityOption } from '@/utils/analyticsGranularity'
import { mapAnalyticsLeaderboardItem, type LeaderboardTableRow } from './mappers'
export type LeaderboardDimension = 'users' | 'api_keys'
export interface LeaderboardSummary {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost: number
avg_response_time: number
}
function dimensionToEntity(dimension: LeaderboardDimension): 'user' | 'api_key' {
return dimension === 'users' ? 'user' : 'api_key'
}
export function useLeaderboardData(dimension: Ref<LeaderboardDimension>) {
const filters = useInjectedAnalyticsFilters()
const leaderboardItems = ref<AnalyticsLeaderboardItem[]>([])
const latestUserOptions = ref<AnalyticsFilterOption[]>([])
const latestApiKeyOptions = ref<AnalyticsFilterOption[]>([])
const leaderboardLoading = ref(false)
const leaderboardError = ref(false)
const leaderboardHasLoaded = ref(false)
const selectedId = ref<string>('')
const selectedSummary = ref<LeaderboardSummary | null>(null)
const selectedTrendBuckets = ref<AnalyticsTimeseriesBucket[]>([])
const panelLoading = ref(false)
const panelError = ref(false)
const panelHasLoaded = ref(false)
const leaderboardGuard = createRequestGuard()
const filterOptionsGuard = createRequestGuard()
const panelGuard = createRequestGuard()
const entityOptions = computed(() => (
dimension.value === 'users' ? latestUserOptions.value : latestApiKeyOptions.value
))
const entityLabelMap = computed(() => (
new Map(entityOptions.value.map(option => [option.value, option.label]))
))
const leaderboard = computed<LeaderboardTableRow[]>(() => (
leaderboardItems.value.map(item => ({
...mapAnalyticsLeaderboardItem(item),
name: entityLabelMap.value.get(item.id) ?? item.label,
}))
))
const selectedItem = computed(() => (
leaderboard.value.find(item => item.id === selectedId.value) ?? null
))
const selectedLabel = computed(() => (
selectedItem.value?.name ?? entityLabelMap.value.get(selectedId.value) ?? null
))
const resolvedTrendGranularity = computed(() => resolveAnalyticsAutoGranularity(filters.timeRange.value))
const baseTimeRangePayload = computed(() => ({
start_date: filters.timeRange.value.start_date,
end_date: filters.timeRange.value.end_date,
preset: filters.timeRange.value.preset,
timezone: filters.timeRange.value.timezone,
tz_offset_minutes: filters.timeRange.value.tz_offset_minutes,
}))
const trendTimeRangePayload = computed(() => ({
...baseTimeRangePayload.value,
granularity: resolvedTrendGranularity.value,
}))
const currentTrendGranularitySelection = computed<AnalyticsGranularityOption>({
get: () => filters.timeRange.value.granularity || 'auto',
set: (value) => {
if (value === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
value = 'auto'
}
filters.timeRange.value = {
...filters.timeRange.value,
granularity: value,
}
},
})
function resetPanelData(invalidate = false) {
if (invalidate) panelGuard.invalidate()
panelLoading.value = false
panelError.value = false
selectedSummary.value = null
selectedTrendBuckets.value = []
}
async function loadFilterOptions() {
const requestId = filterOptionsGuard.next()
const response = await analyticsApi.getFilterOptions({
scope: { kind: 'global' },
time_range: baseTimeRangePayload.value,
filters: {},
}).catch(() => null)
if (filterOptionsGuard.isStale(requestId) || !response) return
latestUserOptions.value = response.users ?? []
latestApiKeyOptions.value = response.api_keys ?? []
}
async function loadLeaderboard() {
const requestId = leaderboardGuard.next()
leaderboardLoading.value = true
leaderboardError.value = false
try {
const response = await analyticsApi.getLeaderboard({
scope: { kind: 'global' },
time_range: baseTimeRangePayload.value,
filters: {
statuses: ['completed', 'failed', 'cancelled'],
},
entity: dimensionToEntity(dimension.value),
metric: 'total_cost_usd',
limit: 20,
})
if (leaderboardGuard.isStale(requestId)) return
leaderboardItems.value = response.items
const nextSelectedId = response.items.some(item => item.id === selectedId.value)
? selectedId.value
: (response.items[0]?.id ?? '')
if (selectedId.value !== nextSelectedId) {
selectedId.value = nextSelectedId
}
leaderboardHasLoaded.value = true
} catch {
if (leaderboardGuard.isStale(requestId)) return
leaderboardError.value = true
} finally {
if (leaderboardGuard.isCurrent(requestId)) {
leaderboardLoading.value = false
}
}
}
async function loadPanel() {
if (!selectedId.value) {
resetPanelData(true)
return
}
const requestId = panelGuard.next()
panelLoading.value = true
panelError.value = false
try {
const scope = dimension.value === 'users'
? { kind: 'user' as const, user_id: selectedId.value }
: { kind: 'api_key' as const, api_key_id: selectedId.value }
const basePayload = {
scope,
time_range: baseTimeRangePayload.value,
filters: {
statuses: ['completed', 'failed', 'cancelled'],
},
}
const [overview, timeseries] = await Promise.all([
analyticsApi.getOverview(basePayload),
analyticsApi.getTimeseries({
...basePayload,
time_range: trendTimeRangePayload.value,
}),
])
if (panelGuard.isStale(requestId)) return
selectedSummary.value = {
total_requests: overview.summary.requests_total,
total_tokens: overview.summary.total_tokens,
total_cost: overview.summary.total_cost_usd,
total_actual_cost: overview.summary.actual_total_cost_usd,
avg_response_time: overview.summary.avg_response_time_ms / 1000,
}
selectedTrendBuckets.value = fillMissingTimeseriesBuckets(
timeseries.buckets,
trendTimeRangePayload.value,
)
panelHasLoaded.value = true
} catch {
if (panelGuard.isStale(requestId)) return
panelError.value = true
} finally {
if (panelGuard.isCurrent(requestId)) {
panelLoading.value = false
}
}
}
const filterOptionsLoader = createLoader(loadFilterOptions)
const leaderboardLoader = createLoader(loadLeaderboard)
const panelLoader = createLoader(loadPanel)
watch(
() => [filters.timeRange.value.preset, filters.timeRange.value.start_date, filters.timeRange.value.end_date],
() => {
if (filters.timeRange.value.granularity === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
filters.timeRange.value = {
...filters.timeRange.value,
granularity: 'auto',
}
}
},
)
watch(
() => [baseTimeRangePayload.value, dimension.value],
() => {
filterOptionsLoader.schedule()
leaderboardLoader.schedule()
},
{ deep: true },
)
watch(
() => [
selectedId.value,
dimension.value,
baseTimeRangePayload.value,
trendTimeRangePayload.value,
],
() => panelLoader.schedule(),
{ deep: true },
)
onMounted(() => {
void (async () => {
await filterOptionsLoader.execute()
await leaderboardLoader.execute()
await panelLoader.execute()
})()
})
onUnmounted(() => {
filterOptionsLoader.cleanup()
leaderboardLoader.cleanup()
panelLoader.cleanup()
filterOptionsGuard.invalidate()
leaderboardGuard.invalidate()
panelGuard.invalidate()
})
return {
leaderboard,
leaderboardLoading,
leaderboardError,
leaderboardHasLoaded,
selectedId,
selectedItem,
selectedLabel,
selectedSummary,
selectedTrendBuckets,
panelLoading,
panelError,
panelHasLoaded,
resolvedTrendGranularity,
currentTrendGranularitySelection,
reloadLeaderboard: leaderboardLoader.schedule,
}
}
@@ -0,0 +1,98 @@
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { analyticsApi, type AnalyticsErrorCategory, type AnalyticsErrorTrendItem, type AnalyticsPercentilePoint, type AnalyticsPerformanceResponse, type AnalyticsProviderHealthItem } from '@/api/analytics'
import { auditApi, type MonitoringSystemStatus } from '@/api/audit'
import { getHealthSummary, type HealthSummary } from '@/api/endpoints'
import { createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
const EMPTY_LATENCY: AnalyticsPerformanceResponse['latency'] = {
response_time_ms: { avg: 0, p50: null, p90: null, p99: null },
first_byte_time_ms: { avg: 0, p50: null, p90: null, p99: null },
}
export function usePerformanceData() {
const filters = useInjectedAnalyticsFilters()
const latency = ref<AnalyticsPerformanceResponse['latency']>(EMPTY_LATENCY)
const percentiles = ref<AnalyticsPercentilePoint[]>([])
const errorDistribution = ref<AnalyticsErrorCategory[]>([])
const errorTrend = ref<AnalyticsErrorTrendItem[]>([])
const errorTotal = ref(0)
const errorRate = ref(0)
const providerStatus = ref<AnalyticsProviderHealthItem[]>([])
const healthSummary = ref<HealthSummary | null>(null)
const systemStatus = ref<MonitoringSystemStatus | null>(null)
const loading = ref(false)
const loadError = ref(false)
const hasLoaded = ref(false)
const guard = createRequestGuard()
async function load() {
const requestId = guard.next()
loading.value = true
loadError.value = false
try {
const [response, summaryResponse, systemResponse] = await Promise.all([
analyticsApi.getPerformance({
scope: { kind: 'global' },
time_range: filters.getTimeRangeParams(),
filters: {
statuses: ['completed', 'failed', 'cancelled'],
},
}),
getHealthSummary().catch(() => null),
auditApi.getSystemStatus().catch(() => null),
])
if (guard.isStale(requestId)) return
latency.value = response.latency
percentiles.value = response.percentiles
errorTotal.value = response.errors.total
errorRate.value = response.errors.rate
errorDistribution.value = response.errors.categories
errorTrend.value = response.errors.trend
providerStatus.value = response.provider_health
if (summaryResponse) {
healthSummary.value = summaryResponse
}
if (systemResponse) {
systemStatus.value = systemResponse
}
hasLoaded.value = true
} catch {
if (guard.isStale(requestId)) return
loadError.value = true
} finally {
if (guard.isCurrent(requestId)) {
loading.value = false
}
}
}
const loader = createLoader(load)
watch(
() => [
filters.getTimeRangeParams(),
],
() => loader.schedule(),
{ deep: true },
)
onMounted(() => { void loader.execute() })
onUnmounted(() => { loader.cleanup(); guard.invalidate() })
return {
latency,
percentiles,
errorDistribution,
errorTrend,
errorTotal,
errorRate,
providerStatus,
healthSummary,
systemStatus,
loading,
loadError,
hasLoaded,
}
}
@@ -0,0 +1,144 @@
import { inject, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
/**
* 构建时间范围 API 参数(从各页面提取的公共逻辑)
*/
export function buildTimeRangeParams(timeRange: DateRangeParams) {
return {
start_date: timeRange.start_date,
end_date: timeRange.end_date,
preset: timeRange.preset,
timezone: timeRange.timezone,
tz_offset_minutes: timeRange.tz_offset_minutes,
granularity: timeRange.granularity && timeRange.granularity !== 'auto'
? timeRange.granularity
: 'day',
}
}
/**
* 通用防抖加载器工厂
* 封装 120ms debounce + in-flight promise 去重 + requestId 过期检查
*/
export function createLoader(loadFn: () => Promise<void>) {
let loadPromise: Promise<void> | null = null
let hasPending = false
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function schedule() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void execute()
}, 120)
}
async function execute() {
if (loadPromise) {
hasPending = true
return loadPromise
}
loadPromise = loadFn()
.finally(() => {
loadPromise = null
if (hasPending) {
hasPending = false
void execute()
}
})
return loadPromise
}
function cleanup() {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
hasPending = false
loadPromise = null
}
return { schedule, execute, cleanup }
}
/**
* 请求 ID 守卫:用于确保异步回调中的数据不被过期请求覆盖
*/
export function createRequestGuard() {
let id = 0
function next() { return ++id }
function isStale(requestId: number) { return requestId !== id }
function isCurrent(requestId: number) { return requestId === id }
function invalidate() { id++ }
return { next, isStale, isCurrent, invalidate }
}
export interface AnalyticsFiltersOptions {
defaultPreset?: PeriodValue
syncToUrl?: boolean
}
/**
* 统一的分析页面筛选状态
* - timeRange: 时间范围
* - modelFilter / providerFilter / apiKeyFilter / userFilter: 多维度筛选
* - activeTab: 当前 Tab
* - URL query 双向同步
*/
export function useAnalyticsFilters(options: AnalyticsFiltersOptions = {}) {
const { defaultPreset = 'last30days', syncToUrl = true } = options
const route = useRoute()
const router = useRouter()
// 从 URL query 初始化,无则用默认值
const initialPreset = (syncToUrl && route.query.preset as PeriodValue) || defaultPreset
const initialTab = (syncToUrl && route.query.tab as string) || 'detail'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod(initialPreset))
const activeTab = ref(initialTab)
const modelFilter = ref<string[]>([])
const providerFilter = ref<string[]>([])
const apiKeyFilter = ref<string[]>([])
const userFilter = ref<string[]>([])
// URL 同步:仅同步 preset 和 tab(各类多选筛选太冗长不入 URL)
if (syncToUrl) {
watch([() => timeRange.value.preset, activeTab], ([preset, tab]) => {
const query: Record<string, string> = {}
if (preset && preset !== defaultPreset) query.preset = preset
if (tab && tab !== 'detail') query.tab = tab
// 保留其他 query 参数
const currentQuery = { ...route.query }
delete currentQuery.preset
delete currentQuery.tab
router.replace({ query: { ...currentQuery, ...query } })
}, { flush: 'post' })
}
function getTimeRangeParams() {
return buildTimeRangeParams(timeRange.value)
}
return {
timeRange,
activeTab,
modelFilter,
providerFilter,
apiKeyFilter,
userFilter,
getTimeRangeParams,
}
}
export type AnalyticsFilters = ReturnType<typeof useAnalyticsFilters>
export function useInjectedAnalyticsFilters(): AnalyticsFilters {
const filters = inject<AnalyticsFilters>('analyticsFilters', null)
if (!filters) {
throw new Error('analyticsFilters provider is missing')
}
return filters
}
+287
View File
@@ -0,0 +1,287 @@
import { computed, ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
import {
analyticsApi,
type AnalyticsBreakdownRow,
type AnalyticsFilterOption,
type AnalyticsScope,
type AnalyticsSummary,
type AnalyticsTimeseriesBucket,
} from '@/api/analytics'
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import { createLoader, createRequestGuard, buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
import { getAnalyticsRangeDaysInclusive, resolveAnalyticsAutoGranularity, type ResolvedAnalyticsGranularity } from '@/utils/analyticsGranularity'
import { type DailyUsageBreakdown } from '@/utils/usageBreakdown'
export interface ModelSummary {
model: string
requests: number
tokens: number
cost: number
inputTokens: number
outputTokens: number
cacheCreationTokens: number
cacheReadTokens: number
cacheHitRate: number
avg_response_time?: number
avg_first_byte_time?: number
}
export interface ReportBucketStat {
bucket_start: string
bucket_end: string
requests: number
tokens: number
cost: number
avg_response_time: number
avg_first_byte_time: number
models_used_count: number
}
function mapBucketToReportStat(bucket: AnalyticsTimeseriesBucket): ReportBucketStat {
return {
bucket_start: bucket.bucket_start,
bucket_end: bucket.bucket_end,
requests: bucket.requests_total,
tokens: bucket.total_tokens,
cost: bucket.total_cost_usd,
avg_response_time: bucket.avg_response_time_ms / 1000,
avg_first_byte_time: bucket.avg_first_byte_time_ms / 1000,
models_used_count: bucket.models_used_count,
}
}
function mapBucketToBreakdown(bucket: AnalyticsTimeseriesBucket): DailyUsageBreakdown {
return {
date: bucket.bucket_start,
inputTokens: bucket.input_tokens,
outputTokens: bucket.output_tokens,
cacheCreationTokens: bucket.cache_creation_input_tokens,
cacheReadTokens: bucket.cache_read_input_tokens,
cacheCreationCost: bucket.cache_creation_cost_usd,
cacheReadCost: bucket.cache_read_cost_usd,
cacheHitRate: bucket.cache_hit_rate,
totalCacheCost: bucket.cache_cost_usd,
totalCacheTokens: bucket.cache_creation_input_tokens + bucket.cache_read_input_tokens,
totalTrackedTokens: bucket.total_tokens,
baseTokens: bucket.input_output_total_tokens,
}
}
function mapBreakdownRowToModelSummary(row: AnalyticsBreakdownRow): ModelSummary {
const requests = row.requests_total
const tokens = row.total_tokens
const cost = row.total_cost_usd
return {
model: row.label,
requests,
tokens,
cost,
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
cacheCreationTokens: row.cache_creation_input_tokens,
cacheReadTokens: row.cache_read_input_tokens,
cacheHitRate: row.cache_hit_rate,
avg_response_time: row.avg_response_time_ms / 1000,
avg_first_byte_time: row.avg_first_byte_time_ms / 1000,
}
}
type ReportGranularity = ResolvedAnalyticsGranularity
function resolveReportGranularity(range: DateRangeParams): ReportGranularity {
return resolveAnalyticsAutoGranularity(range)
}
function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
export interface UseReportsDataOptions {
scope?: AnalyticsScope
defaultPreset?: PeriodValue
timeRange?: Ref<DateRangeParams>
userFilter?: Ref<string[]>
apiKeyFilter?: Ref<string[]>
loadApiKeyOptions?: boolean
}
export function useReportsData(options: UseReportsDataOptions = {}) {
const {
scope = { kind: 'me' as const },
defaultPreset = 'today',
timeRange: externalTimeRange,
userFilter: externalUserFilter,
apiKeyFilter: externalApiKeyFilter,
loadApiKeyOptions = scope.kind === 'me',
} = options
const timeRange = externalTimeRange ?? ref<DateRangeParams>({
...getDateRangeFromPeriod(defaultPreset),
granularity: 'auto',
})
const userFilter = externalUserFilter ?? ref<string[]>([])
const apiKeyFilter = externalApiKeyFilter ?? ref<string[]>([])
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
const summary = ref<AnalyticsSummary | null>(null)
const bucketStats = ref<ReportBucketStat[]>([])
const bucketBreakdowns = ref<Record<string, DailyUsageBreakdown>>({})
const modelSummary = ref<ModelSummary[]>([])
const loading = ref(false)
const loadError = ref(false)
const hasLoaded = ref(false)
const guard = createRequestGuard()
const apiKeysGuard = createRequestGuard()
let suppressNextApiKeyFilterLoad = false
const resolvedGranularity = computed(() => resolveReportGranularity(timeRange.value))
const timeseriesTimeRange = computed(() => ({
...buildTimeRangeParams(timeRange.value),
granularity: resolvedGranularity.value,
}))
watch(
() => [timeRange.value.preset, timeRange.value.start_date, timeRange.value.end_date],
() => {
if (timeRange.value.granularity === 'hour' && getAnalyticsRangeDaysInclusive(timeRange.value) !== 1) {
timeRange.value = {
...timeRange.value,
granularity: 'auto',
}
}
},
)
async function load() {
const requestId = guard.next()
loading.value = true
loadError.value = false
try {
const params = buildTimeRangeParams(timeRange.value)
const timeseriesParams = timeseriesTimeRange.value
const basePayload = {
scope,
time_range: params,
filters: {
user_ids: userFilter.value,
api_key_ids: apiKeyFilter.value,
},
}
const [overviewData, timeseriesData, modelBreakdownData] = await Promise.all([
analyticsApi.getOverview(basePayload),
analyticsApi.getTimeseries({
...basePayload,
time_range: timeseriesParams,
}),
analyticsApi.getBreakdown({
...basePayload,
dimension: 'model',
limit: 50,
}),
])
if (guard.isStale(requestId)) return
const filledBuckets = fillMissingTimeseriesBuckets(timeseriesData.buckets, timeseriesParams)
summary.value = overviewData.summary
bucketStats.value = filledBuckets.map(mapBucketToReportStat)
bucketBreakdowns.value = Object.fromEntries(
filledBuckets.map(bucket => {
const breakdown = mapBucketToBreakdown(bucket)
return [breakdown.date, breakdown]
}),
)
modelSummary.value = modelBreakdownData.rows.map(mapBreakdownRowToModelSummary)
hasLoaded.value = true
} catch {
if (guard.isStale(requestId)) return
loadError.value = true
} finally {
if (guard.isCurrent(requestId)) {
loading.value = false
}
}
}
async function loadApiKeys() {
if (!loadApiKeyOptions) return
const requestId = apiKeysGuard.next()
const filterOptions = await analyticsApi.getFilterOptions({
scope,
time_range: buildTimeRangeParams(timeRange.value),
filters: {},
}).catch(() => null)
if (apiKeysGuard.isStale(requestId)) return
const nextOptions = filterOptions?.api_keys ?? []
apiKeyOptions.value = nextOptions
const validOptionValues = new Set(nextOptions.map(option => option.value))
const nextFilter = apiKeyFilter.value.filter(value => validOptionValues.has(value))
if (!areStringArraysEqual(nextFilter, apiKeyFilter.value)) {
suppressNextApiKeyFilterLoad = true
apiKeyFilter.value = nextFilter
}
}
const loader = createLoader(load)
const apiKeysLoader = createLoader(loadApiKeys)
watch(
timeRange,
() => {
void (async () => {
if (loadApiKeyOptions) {
await apiKeysLoader.execute()
}
loader.schedule()
})()
},
{ deep: true },
)
watch(
[userFilter, apiKeyFilter],
() => {
if (suppressNextApiKeyFilterLoad) {
suppressNextApiKeyFilterLoad = false
return
}
loader.schedule()
},
{ deep: true },
)
onMounted(() => {
void (async () => {
if (loadApiKeyOptions) {
await apiKeysLoader.execute()
}
await loader.execute()
})()
})
onUnmounted(() => {
loader.cleanup()
apiKeysLoader.cleanup()
guard.invalidate()
apiKeysGuard.invalidate()
})
return {
timeRange,
apiKeyFilter,
apiKeyOptions,
summary,
bucketStats,
bucketBreakdowns,
modelSummary,
loading,
loadError,
hasLoaded,
resolvedGranularity,
}
}
@@ -282,8 +282,6 @@ export interface AliasGroup {
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
providerId: string providerId: string
/** @deprecated */
providerApiFormats?: string[]
models: Model[] models: Model[]
editingGroup?: AliasGroup | null editingGroup?: AliasGroup | null
preselectedModelId?: string | null preselectedModelId?: string | null
@@ -544,12 +544,12 @@
v-if="refreshingQuota" v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin" class="w-3 h-3 text-muted-foreground/70 animate-spin"
/> />
<span <span
v-if="key.upstream_metadata.codex?.updated_at" v-if="key.upstream_metadata.codex?.updated_at"
class="text-[9px] text-muted-foreground/70" class="text-[9px] text-muted-foreground/70"
> >
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }} {{ formatUpdatedAt(key.upstream_metadata.codex.updated_at) }}
</span> </span>
</div> </div>
</div> </div>
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 --> <!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
@@ -674,7 +674,7 @@
v-if="key.upstream_metadata.antigravity?.updated_at" v-if="key.upstream_metadata.antigravity?.updated_at"
class="text-[9px] text-muted-foreground/70" class="text-[9px] text-muted-foreground/70"
> >
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }} {{ formatUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
</span> </span>
</div> </div>
</div> </div>
@@ -2471,10 +2471,6 @@ function formatUpdatedAt(updatedAt: number): string {
return `${days}天前更新` return `${days}天前更新`
} }
// 兼容旧函数名
const formatCodexUpdatedAt = formatUpdatedAt
const formatAntigravityUpdatedAt = formatUpdatedAt
function secondsUntilReset(resetTime: string): number | null { function secondsUntilReset(resetTime: string): number | null {
if (!resetTime) return null if (!resetTime) return null
const ts = Date.parse(resetTime) const ts = Date.parse(resetTime)
@@ -165,7 +165,6 @@
<ModelMappingDialog <ModelMappingDialog
v-model:open="dialogOpen" v-model:open="dialogOpen"
:provider-id="provider.id" :provider-id="provider.id"
:provider-api-formats="providerApiFormats"
:models="models" :models="models"
:editing-group="editingGroup" :editing-group="editingGroup"
:preselected-model-id="preselectedModelId" :preselected-model-id="preselectedModelId"
@@ -195,7 +194,6 @@ import { useToast } from '@/composables/useToast'
import { import {
getProviderModels, getProviderModels,
testModel, testModel,
API_FORMAT_LABELS,
type Model, type Model,
type ProviderModelAlias type ProviderModelAlias
} from '@/api/endpoints' } from '@/api/endpoints'
@@ -227,16 +225,6 @@ const preselectedModelId = ref<string | null>(null)
// 列表展开状态 // 列表展开状态
const expandedAliasGroups = ref<Set<string>>(new Set()) const expandedAliasGroups = ref<Set<string>>(new Set())
// 获取 Provider 支持的 API 格式
const providerApiFormats = computed(() => {
const formats = props.provider?.api_formats
if (Array.isArray(formats) && formats.length > 0) {
const order = Object.keys(API_FORMAT_LABELS)
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
}
return []
})
// 生成作用域唯一键 // 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string { function getApiFormatsKey(formats: string[] | undefined): string {
if (!formats || formats.length === 0) return '' if (!formats || formats.length === 0) return ''
@@ -20,26 +20,31 @@
</div> </div>
<div <div
v-if="isLoading" v-if="isLoading"
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground" class="flex items-center justify-center text-sm text-muted-foreground"
:style="{ minHeight: `${minHeight}px` }"
> >
<Loader2 class="h-5 w-5 animate-spin mr-2" /> <Loader2 class="h-5 w-5 animate-spin mr-2" />
加载中... 加载中...
</div> </div>
<div <div
v-else-if="hasError" v-else-if="hasError"
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive" class="flex items-center justify-center text-sm text-destructive"
:style="{ minHeight: `${minHeight}px` }"
> >
<AlertCircle class="h-4 w-4 mr-1.5" /> <AlertCircle class="h-4 w-4 mr-1.5" />
加载失败 数据暂不可用
</div> </div>
<ActivityHeatmap <ActivityHeatmap
v-else-if="hasData" v-else-if="hasData"
:data="data" :data="data"
:show-header="false" :show-header="false"
:value-label="valueLabel"
:actual-value-label="actualValueLabel"
/> />
<div <div
v-else v-else
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground" class="flex items-center justify-center text-sm text-muted-foreground"
:style="{ minHeight: `${minHeight}px` }"
> >
暂无活跃数据 暂无活跃数据
</div> </div>
@@ -58,6 +63,9 @@ const props = defineProps<{
title: string title: string
isLoading?: boolean isLoading?: boolean
hasError?: boolean hasError?: boolean
minHeight?: number
valueLabel?: string
actualValueLabel?: string
}>() }>()
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85] const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
@@ -65,4 +73,6 @@ const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
const hasData = computed(() => const hasData = computed(() =>
props.data && props.data.days && props.data.days.length > 0 props.data && props.data.days && props.data.days.length > 0
) )
const minHeight = computed(() => props.minHeight ?? 160)
</script> </script>
@@ -780,11 +780,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
} }
merged.status = normalizeTimelineStatus(raw.status ?? merged.status) merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id if (!merged.provider_id && typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name if (!merged.provider_name && typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id if (!merged.endpoint_id && typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id if (!merged.key_id && typeof raw.key_id === 'string') merged.key_id = raw.key_id
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name if (!merged.key_name && typeof raw.key_name === 'string') merged.key_name = raw.key_name
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : '' const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : ''
@@ -1,325 +0,0 @@
<template>
<Card class="p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold">
{{ title }}
</p>
<div
v-if="displayLegendItems.length > 0"
class="flex items-center gap-2 flex-wrap justify-end text-[11px]"
>
<div
v-for="item in displayLegendItems"
:key="item.id"
class="flex items-center gap-1"
>
<div
class="w-2.5 h-2.5 rounded-full"
:style="{ backgroundColor: item.color }"
/>
<span class="text-muted-foreground">{{ item.name }}</span>
</div>
<span
v-if="hiddenLegendCount > 0"
class="text-muted-foreground"
>
+{{ hiddenLegendCount }} 更多
</span>
</div>
</div>
<div
v-if="loading"
class="h-[160px] flex items-center justify-center"
>
<div class="text-sm text-muted-foreground">
Loading...
</div>
</div>
<div
v-else-if="hasData"
class="h-[160px]"
>
<ScatterChart
:data="chartData"
:options="chartOptions"
/>
</div>
<div
v-else
class="h-[160px] flex items-center justify-center text-sm text-muted-foreground"
>
暂无请求间隔数据
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
import { meApi } from '@/api/me'
import type { ChartData, ChartOptions } from 'chart.js'
import { log } from '@/utils/logger'
const props = withDefaults(defineProps<{
title: string
isAdmin: boolean
hours?: number
}>(), {
hours: 24 // 默认当天
})
const loading = ref(false)
const timelineData = ref<IntervalTimelineResponse | null>(null)
const primaryColor = ref('201, 100, 66') // 默认主题色
let loadRequestId = 0
const ADMIN_TIMELINE_LIMIT = 1500
const USER_TIMELINE_LIMIT = 1200
// 获取主题色
function getPrimaryColor(): string {
if (typeof window === 'undefined') return '201, 100, 66'
// CSS 变量定义在 body 上,不是 documentElement
const body = document.body
const style = getComputedStyle(body)
const rgb = style.getPropertyValue('--color-primary-rgb').trim()
return rgb || '201, 100, 66'
}
onMounted(() => {
primaryColor.value = getPrimaryColor()
void loadData()
})
// 预定义的颜色列表(用于区分不同用户/模型)
const COLORS = [
'rgba(59, 130, 246, 0.7)', // blue
'rgba(236, 72, 153, 0.7)', // pink
'rgba(34, 197, 94, 0.7)', // green
'rgba(249, 115, 22, 0.7)', // orange
'rgba(168, 85, 247, 0.7)', // purple
'rgba(234, 179, 8, 0.7)', // yellow
'rgba(14, 165, 233, 0.7)', // sky
'rgba(239, 68, 68, 0.7)', // red
'rgba(20, 184, 166, 0.7)', // teal
'rgba(99, 102, 241, 0.7)', // indigo
]
const hasData = computed(() =>
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
)
// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组)
const hasMultipleGroups = computed(() => {
if (props.isAdmin) {
// 管理员视图:按用户分组
return timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
} else {
// 用户视图:按模型分组
return timelineData.value?.models && timelineData.value.models.length > 1
}
})
// 图例最多显示数量
const MAX_LEGEND_ITEMS = 6
// 图例项(管理员显示用户,普通用户显示模型)
const legendItems = computed(() => {
if (props.isAdmin && timelineData.value?.users) {
// 管理员视图:显示用户图例
const users = Object.entries(timelineData.value.users)
if (users.length <= 1) return []
return users.map(([userId, username], index) => ({
id: userId,
name: username || userId.slice(0, 8),
color: COLORS[index % COLORS.length]
}))
} else if (timelineData.value?.models && timelineData.value.models.length > 1) {
// 用户视图:显示模型图例
return timelineData.value.models.map((model, index) => ({
id: model,
name: formatModelName(model),
color: COLORS[index % COLORS.length]
}))
}
return []
})
// 显示的图例项(限制数量)
const displayLegendItems = computed(() => {
return legendItems.value.slice(0, MAX_LEGEND_ITEMS)
})
// 隐藏的图例数量
const hiddenLegendCount = computed(() => {
return Math.max(0, legendItems.value.length - MAX_LEGEND_ITEMS)
})
// 格式化模型名称,使其更简洁
function formatModelName(model: string): string {
// 常见的简化规则
if (model.includes('claude')) {
// claude-3-5-sonnet-20241022 -> Claude 3.5 Sonnet
const match = model.match(/claude-(\d+)-(\d+)?-?(\w+)?/i)
if (match) {
const major = match[1]
const minor = match[2]
const variant = match[3]
let name = `Claude ${major}`
if (minor) name += `.${minor}`
if (variant) name += ` ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
return name
}
}
// 其他模型保持原样但截断
return model.length > 20 ? `${model.slice(0, 17) }...` : model
}
// 构建图表数据
const chartData = computed<ChartData<'scatter'>>(() => {
if (!timelineData.value?.points) {
return { datasets: [] }
}
const points = timelineData.value.points
// 管理员视图且有多个用户:按用户分组
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
const userIds = Object.keys(timelineData.value.users)
const userColorMap: Record<string, string> = {}
userIds.forEach((userId, index) => {
userColorMap[userId] = COLORS[index % COLORS.length]
})
// 按用户分组数据
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
for (const point of points) {
const userId = point.user_id || 'unknown'
if (!groupedData[userId]) {
groupedData[userId] = []
}
groupedData[userId].push({ x: point.x, y: point.y })
}
// 创建每个用户的 dataset
const datasets = Object.entries(groupedData).map(([userId, data]) => ({
label: timelineData.value?.users?.[userId] || userId.slice(0, 8),
data,
backgroundColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.6)',
borderColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.8)',
pointRadius: 3,
pointHoverRadius: 5,
}))
return { datasets }
}
// 用户视图且有多个模型:按模型分组
if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) {
const models = timelineData.value.models
const modelColorMap: Record<string, string> = {}
models.forEach((model, index) => {
modelColorMap[model] = COLORS[index % COLORS.length]
})
// 按模型分组数据
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
for (const point of points) {
const model = point.model || 'unknown'
if (!groupedData[model]) {
groupedData[model] = []
}
groupedData[model].push({ x: point.x, y: point.y })
}
// 创建每个模型的 dataset
const datasets = Object.entries(groupedData).map(([model, data]) => ({
label: formatModelName(model),
data,
backgroundColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.6)',
borderColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.8)',
pointRadius: 3,
pointHoverRadius: 5,
}))
return { datasets }
}
// 单用户或单模型:使用主题色
return {
datasets: [{
label: '请求间隔',
data: points.map(p => ({ x: p.x, y: p.y })),
backgroundColor: `rgba(${primaryColor.value}, 0.6)`,
borderColor: `rgba(${primaryColor.value}, 0.8)`,
pointRadius: 3,
pointHoverRadius: 5,
}]
}
})
const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
plugins: {
legend: {
display: false // 使用自定义图例
},
tooltip: {
callbacks: {
label: (context) => {
const point = context.raw as { x: string; y: number; _originalY?: number }
const realY = point._originalY ?? point.y
const datasetLabel = context.dataset.label || ''
if (hasMultipleGroups.value) {
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
}
return `间隔: ${realY.toFixed(1)} 分钟`
}
}
}
}
}))
async function loadData() {
const requestId = ++loadRequestId
loading.value = true
try {
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
if (props.isAdmin) {
// 管理员:获取所有用户数据(按比例采样)
const data = await cacheAnalysisApi.getIntervalTimeline({
hours: props.hours,
include_user_info: true,
limit,
})
if (requestId !== loadRequestId) return
timelineData.value = data
} else {
// 普通用户:获取自己的数据
const data = await meApi.getIntervalTimeline({
hours: props.hours,
limit,
})
if (requestId !== loadRequestId) return
timelineData.value = data
}
} catch (error) {
if (requestId !== loadRequestId) return
log.error('加载请求间隔时间线失败:', error)
timelineData.value = null
} finally {
if (requestId === loadRequestId) {
loading.value = false
}
}
}
watch([() => props.hours, () => props.isAdmin], () => {
void loadData()
})
onBeforeUnmount(() => {
loadRequestId++
})
</script>
@@ -322,7 +322,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/dashboard' import { requestDetailsApi, type ReplayResponse, type RequestDetail } from '@/api/request-details'
import { getProvidersSummary } from '@/api/endpoints/providers' import { getProvidersSummary } from '@/api/endpoints/providers'
import { getProviderKeys } from '@/api/endpoints/keys' import { getProviderKeys } from '@/api/endpoints/keys'
import type { EndpointAPIKey } from '@/api/endpoints/types' import type { EndpointAPIKey } from '@/api/endpoints/types'
@@ -482,7 +482,7 @@ async function doReplay() {
if (selectedProviderId.value) params.provider_id = selectedProviderId.value if (selectedProviderId.value) params.provider_id = selectedProviderId.value
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
replayResult.value = await dashboardApi.replayRequest( replayResult.value = await requestDetailsApi.replayRequest(
props.requestId, props.requestId,
Object.keys(params).length > 0 ? params : undefined, Object.keys(params).length > 0 ? params : undefined,
) )
@@ -696,7 +696,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue' import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue' import TabsContent from '@/components/ui/tabs-content.vue'
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next' import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard' import { requestDetailsApi, type RequestDetail } from '@/api/request-details'
import { formatApiFormat } from '@/api/endpoints/types/api-format' import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -1319,7 +1319,7 @@ async function ensureBodyContentLoaded() {
const requestId = ++bodyLoadRequestId const requestId = ++bodyLoadRequestId
bodyLoading.value = true bodyLoading.value = true
try { try {
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true }) const response = await requestDetailsApi.getRequestDetail(props.requestId, { includeBodies: true })
if (requestId !== bodyLoadRequestId || !detail.value) return if (requestId !== bodyLoadRequestId || !detail.value) return
detail.value = { detail.value = {
...detail.value, ...detail.value,
@@ -1359,7 +1359,7 @@ async function loadDetail(id: string, silent = false) {
} }
error.value = null error.value = null
try { try {
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false }) const response = await requestDetailsApi.getRequestDetail(id, { includeBodies: false })
if (requestId !== loadDetailRequestId) return if (requestId !== loadDetailRequestId) return
const previousDetail = detail.value const previousDetail = detail.value
@@ -1737,7 +1737,7 @@ async function copyCurlCommand() {
if (!props.requestId || curlCopying.value) return if (!props.requestId || curlCopying.value) return
curlCopying.value = true curlCopying.value = true
try { try {
const data = await dashboardApi.getCurlData(props.requestId) const data = await requestDetailsApi.getCurlData(props.requestId)
if (data.curl) { if (data.curl) {
copyToClipboard(data.curl, false) copyToClipboard(data.curl, false)
curlCopied.value = true curlCopied.value = true
@@ -159,7 +159,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import JsonContent from './JsonContent.vue' import JsonContent from './JsonContent.vue'
import type { RequestDetail } from '@/api/dashboard' import type { RequestDetail } from '@/api/request-details'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
detail: RequestDetail detail: RequestDetail
@@ -1,100 +0,0 @@
<template>
<Card class="overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b flex-shrink-0">
<h3 class="text-sm font-medium">
按API格式分析
</h3>
</div>
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
API格式
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
<span>输入/输出</span>
<span class="text-muted-foreground font-normal">缓存</span>
</div>
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
>
暂无API格式统计数据
</TableCell>
</TableRow>
<TableRow
v-for="item in data"
:key="item.api_format"
>
<TableCell class="font-medium py-2 px-2">
{{ formatApiFormat(item.api_format) }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ item.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
<span>{{ formatTokens(item.total_input_context || 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
<span class="text-muted-foreground">{{ formatTokens((item.cache_read_tokens || 0) + (item.cache_creation_tokens || 0)) }}</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(item.total_cost) }}</span>
<span
v-if="isAdmin && item.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(item.actual_cost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatHitRate(item.cache_hit_rate) }}</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ item.avgResponseTime }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>
<script setup lang="ts">
import Card from '@/components/ui/card.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ApiFormatStatsItem } from '../types'
defineProps<{
data: ApiFormatStatsItem[]
isAdmin: boolean
}>()
</script>
@@ -1,99 +0,0 @@
<template>
<Card class="overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b flex-shrink-0">
<h3 class="text-sm font-medium">
按模型分析
</h3>
</div>
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
模型
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
<span>输入/输出</span>
<span class="text-muted-foreground font-normal">缓存</span>
</div>
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
</TableHead>
<TableHead class="h-8 px-2 text-right">
效率
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="6"
class="text-center py-6 text-muted-foreground px-2"
>
暂无模型统计数据
</TableCell>
</TableRow>
<TableRow
v-for="model in data"
:key="model.model"
>
<TableCell class="font-medium py-2 px-2">
{{ model.model.replace('claude-', '') }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ model.request_count }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
<span>{{ formatTokens(model.total_input_context || 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }}</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(model.total_cost) }}</span>
<span
v-if="isAdmin && model.actual_cost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(model.actual_cost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatHitRate(model.cache_hit_rate) }}</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ model.costPerToken }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>
<script setup lang="ts">
import Card from '@/components/ui/card.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
import type { EnhancedModelStatsItem } from '../types'
defineProps<{
data: EnhancedModelStatsItem[]
isAdmin: boolean
}>()
</script>
@@ -1,111 +0,0 @@
<template>
<Card class="overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b flex-shrink-0">
<h3 class="text-sm font-medium">
按提供商分析
</h3>
</div>
<div class="overflow-auto max-h-[320px]">
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="h-8 px-2">
提供商
</TableHead>
<TableHead class="h-8 px-2 text-right">
请求数
</TableHead>
<TableHead class="h-8 px-2 text-right">
<div class="flex flex-col text-xs gap-0.5 whitespace-nowrap">
<span>输入/输出</span>
<span class="text-muted-foreground font-normal">缓存</span>
</div>
</TableHead>
<TableHead class="h-8 px-2 text-right">
费用
</TableHead>
<TableHead class="h-8 px-2 text-right">
缓存命中率
</TableHead>
<TableHead class="h-8 px-2 text-right">
成功率
</TableHead>
<TableHead class="h-8 px-2 text-right">
平均响应
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.length === 0">
<TableCell
:colspan="7"
class="text-center py-6 text-muted-foreground px-2"
>
暂无提供商统计数据
</TableCell>
</TableRow>
<TableRow
v-for="provider in data"
:key="provider.provider"
>
<TableCell class="font-medium py-2 px-2">
{{ provider.provider }}
</TableCell>
<TableCell class="text-right py-2 px-2">
{{ provider.requests }}
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
<span>{{ formatTokens(provider.totalInputContext || 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
<span class="text-muted-foreground">{{ formatTokens((provider.cacheReadTokens || 0) + (provider.cacheCreationTokens || 0)) }}</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(provider.totalCost) }}</span>
<span
v-if="isAdmin && provider.actualCost !== undefined"
class="text-muted-foreground text-[10px]"
>
{{ formatCurrency(provider.actualCost) }}
</span>
</div>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span>{{ formatHitRate(provider.cacheHitRate) }}</span>
</TableCell>
<TableCell class="text-right py-2 px-2">
<span :class="getSuccessRateClass(provider.successRate)">{{ provider.successRate }}%</span>
</TableCell>
<TableCell class="text-right text-muted-foreground py-2 px-2">
{{ provider.avgResponseTime }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
</template>
<script setup lang="ts">
import Card from '@/components/ui/card.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency, formatHitRate } from '@/utils/format'
import type { ProviderStatsItem } from '../types'
defineProps<{
data: ProviderStatsItem[]
isAdmin: boolean
}>()
// 成功率样式 - 简化为两种状态
function getSuccessRateClass(rate: number): string {
if (rate < 90) return 'text-destructive'
return '' // 默认颜色
}
</script>
@@ -36,10 +36,33 @@
</SelectItem> </SelectItem>
<SelectItem <SelectItem
v-for="user in availableUsers" v-for="user in availableUsers"
:key="user.id" :key="user.value"
:value="user.id" :value="user.value"
> >
{{ user.username || user.email }} {{ user.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- Key 筛选 -->
<Select
v-if="availableApiKeys.length > 0"
:model-value="filterApiKey"
@update:model-value="$emit('update:filterApiKey', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="Key" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部 Key
</SelectItem>
<SelectItem
v-for="apiKey in availableApiKeys"
:key="apiKey.value"
:value="apiKey.value"
>
{{ apiKey.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -58,10 +81,10 @@
</SelectItem> </SelectItem>
<SelectItem <SelectItem
v-for="model in availableModels" v-for="model in availableModels"
:key="model" :key="model.value"
:value="model" :value="model.value"
> >
{{ model.replace('claude-', '') }} {{ model.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -81,10 +104,10 @@
</SelectItem> </SelectItem>
<SelectItem <SelectItem
v-for="provider in availableProviders" v-for="provider in availableProviders"
:key="provider" :key="provider.value"
:value="provider" :value="provider.value"
> >
{{ provider }} {{ provider.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -106,7 +129,7 @@
:key="format.value" :key="format.value"
:value="format.value" :value="format.value"
> >
{{ format.label }} {{ getApiFormatOptionLabel(format) }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -123,26 +146,12 @@
<SelectItem value="__all__"> <SelectItem value="__all__">
全部状态 全部状态
</SelectItem> </SelectItem>
<SelectItem value="stream"> <SelectItem
流式 v-for="status in availableStatuses"
</SelectItem> :key="status.value"
<SelectItem value="standard"> :value="status.value"
标准 >
</SelectItem> {{ getStatusOptionLabel(status) }}
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -350,18 +359,18 @@
<TableCell <TableCell
v-if="isAdmin" v-if="isAdmin"
class="py-4 w-[100px] truncate" class="py-4 w-[100px] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')" :title="getUserDisplayName(record)"
> >
<div class="flex flex-col text-xs gap-0.5"> <div class="flex flex-col text-xs gap-0.5">
<span class="truncate"> <span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }} {{ getUserDisplayName(record) }}
</span> </span>
<span <span
v-if="record.api_key?.name" v-if="getUserApiKeyLabel(record)"
class="text-muted-foreground truncate" class="text-muted-foreground truncate"
:title="record.api_key.name" :title="getUserApiKeyLabel(record) || undefined"
> >
{{ record.api_key.name }} {{ getUserApiKeyLabel(record) }}
</span> </span>
</div> </div>
</TableCell> </TableCell>
@@ -369,10 +378,10 @@
<TableCell <TableCell
v-if="!isAdmin" v-if="!isAdmin"
class="py-4 w-[100px]" class="py-4 w-[100px]"
:title="record.api_key?.name || '-'" :title="getUserApiKeyLabel(record) || '-'"
> >
<div class="flex flex-col text-xs gap-0.5"> <div class="flex flex-col text-xs gap-0.5">
<span class="truncate">{{ record.api_key?.name || '-' }}</span> <span class="truncate">{{ getUserApiKeyLabel(record) || '-' }}</span>
<span <span
v-if="record.api_key?.display" v-if="record.api_key?.display"
class="text-muted-foreground truncate" class="text-muted-foreground truncate"
@@ -417,7 +426,7 @@
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="flex flex-col text-xs gap-0.5"> <div class="flex flex-col text-xs gap-0.5">
<span>{{ record.provider }}</span> <span>{{ formatProviderLabel(record.provider) }}</span>
<span <span
v-if="record.api_key_name" v-if="record.api_key_name"
class="text-muted-foreground truncate" class="text-muted-foreground truncate"
@@ -672,16 +681,11 @@ import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables' import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick' import { useRowClick } from '@/composables/useRowClick'
import { formatApiFormat } from '@/api/endpoints/types/api-format' import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { AnalyticsFilterOption } from '@/api/analytics'
import type { DateRangeParams, UsageRecord } from '../types' import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common' import { TimeRangePicker } from '@/components/common'
import ElapsedTimeText from './ElapsedTimeText.vue' import ElapsedTimeText from './ElapsedTimeText.vue'
export interface UserOption {
id: string
username: string
email: string
}
const props = defineProps<{ const props = defineProps<{
records: UsageRecord[] records: UsageRecord[]
isAdmin: boolean isAdmin: boolean
@@ -692,13 +696,17 @@ const props = defineProps<{
// 筛选 // 筛选
filterSearch: string filterSearch: string
filterUser: string filterUser: string
filterApiKey: string
filterModel: string filterModel: string
filterProvider: string filterProvider: string
filterApiFormat: string filterApiFormat: string
filterStatus: string filterStatus: string
availableUsers: UserOption[] availableUsers: AnalyticsFilterOption[]
availableModels: string[] availableApiKeys: AnalyticsFilterOption[]
availableProviders: string[] availableModels: AnalyticsFilterOption[]
availableApiFormats: AnalyticsFilterOption[]
availableProviders: AnalyticsFilterOption[]
availableStatuses: AnalyticsFilterOption[]
// 分页 // 分页
currentPage: number currentPage: number
pageSize: number pageSize: number
@@ -712,6 +720,7 @@ const emit = defineEmits<{
'update:timeRange': [value: DateRangeParams] 'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string] 'update:filterSearch': [value: string]
'update:filterUser': [value: string] 'update:filterUser': [value: string]
'update:filterApiKey': [value: string]
'update:filterModel': [value: string] 'update:filterModel': [value: string]
'update:filterProvider': [value: string] 'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string] 'update:filterApiFormat': [value: string]
@@ -723,22 +732,6 @@ const emit = defineEmits<{
'showDetail': [id: string] 'showDetail': [id: string]
}>() }>()
// 静态常量(放在 defineProps/defineEmits 之后)
const AVAILABLE_API_FORMATS = [
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:cli', label: 'OpenAI CLI' },
{ value: 'openai:compact', label: 'OpenAI Compact' },
{ value: 'openai:video', label: 'OpenAI Video' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
{ value: 'gemini:video', label: 'Gemini Video' },
] as const
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
const timeRangeModel = computed({ const timeRangeModel = computed({
get: () => props.timeRange, get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value) set: (value: DateRangeParams) => emit('update:timeRange', value)
@@ -760,6 +753,44 @@ watch(localSearch, (value) => {
emitSearchDebounced(value) emitSearchDebounced(value)
}) })
function getApiFormatOptionLabel(option: AnalyticsFilterOption): string {
return formatApiFormat(option.label || option.value)
}
function getStatusOptionLabel(option: AnalyticsFilterOption): string {
const statusLabelMap: Record<string, string> = {
pending: '等待',
streaming: '传输中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
active: '活跃',
stream: '流式',
standard: '标准',
has_retry: '发生重试',
has_fallback: '发生转移',
}
return statusLabelMap[option.value] || option.label || option.value
}
function formatProviderLabel(value: string | null | undefined): string {
if (!value) return '-'
if (value === 'pending') return '待分配提供商'
if (value === 'unknown') return '未识别提供商'
return value
}
function getUserDisplayName(record: UsageRecord): string {
return record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')
}
function getUserApiKeyLabel(record: UsageRecord): string | null {
const name = record.api_key?.name?.trim()
if (name) return name
return record.api_key?.id ? null : '已删除Key'
}
// 使用复用的行点击逻辑 // 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick() const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
@@ -1,8 +1,3 @@
export { default as UsageModelTable } from './UsageModelTable.vue'
export { default as UsageProviderTable } from './UsageProviderTable.vue'
export { default as UsageApiFormatTable } from './UsageApiFormatTable.vue'
export { default as UsageRecordsTable } from './UsageRecordsTable.vue' export { default as UsageRecordsTable } from './UsageRecordsTable.vue'
export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue' export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue'
export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue' export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue'
export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue'
export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue'
@@ -28,18 +28,17 @@ export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
case 'today': case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()) startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
break break
case 'yesterday':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
break
case 'last7days': case 'last7days':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break break
case 'last30days': case 'last30days':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break break
case 'last90days': case 'last180days':
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) startDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
break
case 'last1year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
break break
default: default:
return {} // 返回空对象表示不过滤时间 return {} // 返回空对象表示不过滤时间
@@ -1,16 +1,10 @@
import { ref, computed, type Ref } from 'vue' import { ref, type Ref } from 'vue'
import { usageApi } from '@/api/usage' import { analyticsApi, type AnalyticsBaseRequest, type AnalyticsFilterOption, type AnalyticsRecord, type AnalyticsScope } from '@/api/analytics'
import { meApi } from '@/api/me' import { buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
import type { import type {
UsageStatsState,
ModelStatsItem,
ProviderStatsItem,
ApiFormatStatsItem,
UsageRecord, UsageRecord,
DateRangeParams, DateRangeParams,
EnhancedModelStatsItem
} from '../types' } from '../types'
import { createDefaultStats } from '../types'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error' import { getErrorStatus } from '@/types/api-error'
@@ -26,27 +20,21 @@ export interface PaginationParams {
export interface FilterParams { export interface FilterParams {
search?: string search?: string
user_id?: string user_id?: string
api_key_id?: string
model?: string model?: string
provider?: string provider?: string
api_format?: string api_format?: string
status?: string status?: string
} }
type FilterOptionDimension = 'user' | 'api_key' | 'model' | 'provider' | 'api_format' | 'status'
export function useUsageData(options: UseUsageDataOptions) { export function useUsageData(options: UseUsageDataOptions) {
const { isAdminPage } = options const { isAdminPage } = options
// 加载状态
const isLoadingStats = ref(true)
const isLoadingRecords = ref(false) const isLoadingRecords = ref(false)
const loading = computed(() => isLoadingStats.value || isLoadingRecords.value)
// 统计数据 // 记录数据:前后端统一按当前页维护
const stats = ref<UsageStatsState>(createDefaultStats())
const modelStats = ref<ModelStatsItem[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const apiFormatStats = ref<ApiFormatStatsItem[]>([])
// 记录数据 - 只存储当前页
const currentRecords = ref<UsageRecord[]>([]) const currentRecords = ref<UsageRecord[]>([])
const totalRecords = ref(0) const totalRecords = ref(0)
@@ -56,195 +44,93 @@ export function useUsageData(options: UseUsageDataOptions) {
let loadRecordsRequestId = 0 let loadRecordsRequestId = 0
// 可用的筛选选项(从统计数据获取,而不是从记录中) // 可用的筛选选项(从统计数据获取,而不是从记录中)
const availableModels = ref<string[]>([]) const availableUsers = ref<AnalyticsFilterOption[]>([])
const availableProviders = ref<string[]>([]) const availableModels = ref<AnalyticsFilterOption[]>([])
const availableProviders = ref<AnalyticsFilterOption[]>([])
const availableApiKeys = ref<AnalyticsFilterOption[]>([])
const availableApiFormats = ref<AnalyticsFilterOption[]>([])
const availableStatuses = ref<AnalyticsFilterOption[]>([])
// 增强的模型统计(包含效率分析) function mapAnalyticsRecord(record: AnalyticsRecord): UsageRecord {
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => { return {
return modelStats.value.map(model => ({ id: record.id,
...model, user_id: record.user_id || undefined,
costPerToken: model.total_tokens > 0 username: record.username || undefined,
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M` api_key: {
: '-' id: record.api_key_id,
})) name: record.api_key_name,
}) display: null,
},
provider: record.provider_name,
api_key_name: record.provider_api_key_name || undefined,
rate_multiplier: record.rate_multiplier,
model: record.model,
target_model: record.target_model,
api_format: record.api_format || undefined,
endpoint_api_format: undefined,
has_format_conversion: record.has_format_conversion ?? undefined,
input_tokens: record.input_tokens,
output_tokens: record.output_tokens,
cache_creation_input_tokens: record.cache_creation_input_tokens,
cache_read_input_tokens: record.cache_read_input_tokens,
total_tokens: record.total_tokens,
cost: record.total_cost_usd,
actual_cost: record.actual_total_cost_usd,
response_time_ms: record.response_time_ms ?? undefined,
first_byte_time_ms: record.first_byte_time_ms ?? undefined,
is_stream: record.is_stream,
status_code: record.status_code ?? undefined,
error_message: record.error_message ?? undefined,
status: record.status as UsageRecord['status'],
created_at: record.created_at || '',
has_fallback: record.has_fallback ?? undefined,
has_retry: record.has_retry ?? undefined,
}
}
function buildScope(): AnalyticsScope {
if (isAdminPage.value) {
return { kind: 'global' }
}
return { kind: 'me' }
}
function buildAnalyticsFilters(
filters?: FilterParams,
excludeDimension?: FilterOptionDimension,
): NonNullable<AnalyticsBaseRequest['filters']> {
return {
user_ids: filters?.user_id && excludeDimension !== 'user' ? [filters.user_id] : [],
models: filters?.model && excludeDimension !== 'model' ? [filters.model] : [],
provider_names: filters?.provider && excludeDimension !== 'provider' ? [filters.provider] : [],
api_key_ids: filters?.api_key_id && excludeDimension !== 'api_key' ? [filters.api_key_id] : [],
api_formats: filters?.api_format && excludeDimension !== 'api_format' ? [filters.api_format] : [],
statuses: filters?.status && excludeDimension !== 'status' ? [filters.status] : [],
}
}
// 加载统计数据(不加载记录) // 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams) { async function loadStats(dateRange?: DateRangeParams, filters?: FilterParams) {
const requestId = ++loadStatsRequestId const requestId = ++loadStatsRequestId
isLoadingStats.value = true
currentDateRange.value = dateRange currentDateRange.value = dateRange
try { try {
if (isAdminPage.value) { const response = await analyticsApi.getFilterOptions({
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。 scope: buildScope(),
const statsData = await usageApi.getUsageStats(dateRange) time_range: buildTimeRangeParams(dateRange || currentDateRange.value || {}),
filters: buildAnalyticsFilters(filters),
})
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {
return return
}
// statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown>
stats.value = {
total_requests: statsData.total_requests || 0,
total_tokens: statsData.total_tokens || 0,
total_cost: statsData.total_cost || 0,
total_actual_cost: statsData.total_actual_cost,
avg_response_time: statsData.avg_response_time || 0,
error_count: typeof statsRaw.error_count === 'number' ? statsRaw.error_count : undefined,
error_rate: typeof statsRaw.error_rate === 'number' ? statsRaw.error_rate : undefined,
cache_stats: statsRaw.cache_stats as UsageStatsState['cache_stats'],
period_start: '',
period_end: '',
}
const modelData = await usageApi.getUsageByModel(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
modelStats.value = modelData.map(item => {
const raw = item as Record<string, unknown>
return {
model: item.model,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
total_input_context: typeof raw.total_input_context === 'number' ? raw.total_input_context : 0,
output_tokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : 0,
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
cache_creation_tokens: typeof raw.cache_creation_tokens === 'number' ? raw.cache_creation_tokens : 0,
cache_hit_rate: typeof raw.cache_hit_rate === 'number' ? raw.cache_hit_rate : 0,
total_cost: item.total_cost || 0,
actual_cost: typeof raw.actual_cost === 'number' ? raw.actual_cost : undefined
}
})
const providerData = await usageApi.getUsageByProvider(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
providerStats.value = providerData.map(item => ({
provider: item.provider,
requests: item.request_count,
totalTokens: item.total_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost,
actualCost: item.actual_cost,
successRate: item.success_rate,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
apiFormatStats.value = apiFormatData.map(item => ({
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
total_input_context: item.total_input_context || 0,
output_tokens: item.output_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_creation_tokens: item.cache_creation_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost || 0,
actual_cost: item.actual_cost,
avgResponseTime: item.avg_response_time_ms > 0
? `${(item.avg_response_time_ms / 1000).toFixed(2)}s`
: '-'
}))
// 从统计数据中提取可用的筛选选项
availableModels.value = modelData.map(item => item.model).filter(Boolean).sort()
availableProviders.value = providerData.map(item => item.provider).filter(Boolean).sort()
} else {
// 用户页面
const userData = await meApi.getUsage(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
stats.value = {
total_requests: userData.total_requests || 0,
total_tokens: userData.total_tokens || 0,
total_cost: userData.total_cost || 0,
total_actual_cost: userData.total_actual_cost,
avg_response_time: userData.avg_response_time || 0,
period_start: '',
period_end: '',
}
modelStats.value = (userData.summary_by_model || []).map((item) => ({
model: item.model,
request_count: item.requests || 0,
total_tokens: item.total_tokens || 0,
total_input_context: item.total_input_context || 0,
output_tokens: item.output_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_creation_tokens: item.cache_creation_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost_usd || 0,
actual_cost: item.actual_total_cost_usd
}))
providerStats.value = (userData.summary_by_provider || []).map((item) => ({
provider: item.provider,
requests: item.requests || 0,
totalTokens: item.total_tokens || 0,
totalInputContext: item.total_input_context || 0,
outputTokens: item.output_tokens || 0,
cacheReadTokens: item.cache_read_tokens || 0,
cacheCreationTokens: item.cache_creation_tokens || 0,
cacheHitRate: item.cache_hit_rate || 0,
totalCost: item.total_cost_usd || 0,
successRate: item.success_rate || 0,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
// 用户页面:记录直接从 userData 获取(数量较少)
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total ?? currentRecords.value.length
// 从记录中提取筛选选项
const models = new Set<string>()
const providers = new Set<string>()
currentRecords.value.forEach(record => {
if (record.model) models.add(record.model)
if (record.provider) providers.add(record.provider)
})
availableModels.value = Array.from(models).sort()
availableProviders.value = Array.from(providers).sort()
// API 格式统计直接使用后端聚合数据
apiFormatStats.value = (userData.summary_by_api_format || []).map(item => ({
api_format: item.api_format,
request_count: item.request_count || 0,
total_tokens: item.total_tokens || 0,
total_input_context: item.total_input_context || 0,
output_tokens: item.output_tokens || 0,
cache_read_tokens: item.cache_read_tokens || 0,
cache_creation_tokens: item.cache_creation_tokens || 0,
cache_hit_rate: item.cache_hit_rate || 0,
total_cost: item.total_cost_usd || 0,
avgResponseTime: (item.avg_response_time_ms ?? 0) > 0
? `${((item.avg_response_time_ms ?? 0) / 1000).toFixed(2)}s`
: '-'
}))
} }
availableUsers.value = response.users ?? []
availableModels.value = response.models ?? []
availableProviders.value = response.providers ?? []
availableApiKeys.value = response.api_keys ?? []
availableApiFormats.value = response.api_formats ?? []
availableStatuses.value = response.statuses ?? []
} catch (error: unknown) { } catch (error: unknown) {
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {
return return
@@ -252,17 +138,17 @@ export function useUsageData(options: UseUsageDataOptions) {
if (getErrorStatus(error) !== 403) { if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error) log.error('加载统计数据失败:', error)
} }
stats.value = createDefaultStats() availableUsers.value = []
modelStats.value = [] availableModels.value = []
availableProviders.value = []
currentRecords.value = [] currentRecords.value = []
} finally { availableApiKeys.value = []
if (requestId === loadStatsRequestId) { availableApiFormats.value = []
isLoadingStats.value = false availableStatuses.value = []
}
} }
} }
// 加载记录(真正的后端分页) // 加载记录:前后端统一由后端分页和筛选
async function loadRecords( async function loadRecords(
pagination: PaginationParams, pagination: PaginationParams,
filters?: FilterParams, filters?: FilterParams,
@@ -278,53 +164,24 @@ export function useUsageData(options: UseUsageDataOptions) {
currentDateRange.value = dateRange currentDateRange.value = dateRange
} }
// 构建请求参数 const response = await analyticsApi.getRecords({
const params: Record<string, unknown> = { scope: buildScope(),
limit: pagination.pageSize, time_range: buildTimeRangeParams(currentDateRange.value || {}),
offset, filters: buildAnalyticsFilters(filters),
...effectiveDateRange search: {
} text: filters?.search || null,
},
// 添加筛选条件 pagination: {
if (filters?.search?.trim()) { limit: pagination.pageSize,
params.search = filters.search.trim() offset,
} },
})
if (isAdminPage.value) { if (requestId !== loadRecordsRequestId) {
// 管理员页面:使用管理员 API return
if (filters?.user_id) {
params.user_id = filters.user_id
}
if (filters?.model) {
params.model = filters.model
}
if (filters?.provider) {
params.provider = filters.provider
}
if (filters?.api_format) {
params.api_format = filters.api_format
}
if (filters?.status) {
params.status = filters.status
}
const response = await usageApi.getAllUsageRecords(params)
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (response.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = response.total || 0
} else {
// 用户页面:使用用户 API
const userData = await meApi.getUsage(params)
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total || currentRecords.value.length
} }
const nextRecords = (response.records || []).map(mapAnalyticsRecord)
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = response.total || 0
} catch (error) { } catch (error) {
if (requestId !== loadRecordsRequestId) { if (requestId !== loadRecordsRequestId) {
return return
@@ -412,33 +269,19 @@ export function useUsageData(options: UseUsageDataOptions) {
}) })
} }
// 刷新所有数据
async function refreshData(dateRange?: DateRangeParams) {
await loadStats(dateRange)
}
return { return {
// 状态
loading,
isLoadingStats,
isLoadingRecords, isLoadingRecords,
stats,
modelStats,
providerStats,
apiFormatStats,
currentRecords, currentRecords,
totalRecords, totalRecords,
// 筛选选项 availableUsers,
availableModels, availableModels,
availableProviders, availableProviders,
availableApiKeys,
availableApiFormats,
availableStatuses,
// 计算属性
enhancedModelStats,
// 方法
loadStats, loadStats,
loadRecords, loadRecords,
refreshData
} }
} }
+5 -92
View File
@@ -1,74 +1,3 @@
// 统计数据状态
export interface UsageStatsState {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost?: number // 倍率消耗(仅管理员可见)
avg_response_time: number
error_count?: number
error_rate?: number
cache_stats?: {
cache_creation_tokens: number
cache_read_tokens: number
cache_creation_cost: number
cache_read_cost: number
}
period_start: string
period_end: string
}
// 模型统计
export interface ModelStatsItem {
model: string
request_count: number
total_tokens: number
total_input_context?: number
output_tokens?: number
cache_read_tokens?: number
cache_creation_tokens?: number
cache_hit_rate?: number
total_cost: number
actual_cost?: number // 倍率消耗
}
// 增强的模型统计(包含效率分析)
export interface EnhancedModelStatsItem extends ModelStatsItem {
costPerToken: string
}
// 提供商统计
export interface ProviderStatsItem {
provider: string
requests: number
totalTokens: number
totalInputContext?: number
outputTokens?: number
cacheReadTokens?: number
cacheCreationTokens?: number
cacheHitRate?: number
totalCost: number
actualCost?: number
successRate: number
avgResponseTime: string
}
// API格式统计
export interface ApiFormatStatsItem {
api_format: string
request_count: number
total_tokens: number
total_input_context?: number
output_tokens?: number
cache_read_tokens?: number
cache_creation_tokens?: number
cache_hit_rate?: number
total_cost: number
actual_cost?: number
avgResponseTime: string
}
// 请求记录
// 请求状态类型
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
export interface UsageRecord { export interface UsageRecord {
@@ -82,7 +11,7 @@ export interface UsageRecord {
display: string | null display: string | null
} | null } | null
provider?: string // 仅管理员可见 provider?: string // 仅管理员可见
api_key_name?: string api_key_name?: string // 提供商 Key 名称(管理员列使用)
rate_multiplier?: number rate_multiplier?: number
model: string model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空) target_model?: string | null // 映射后的目标模型名(若无映射则为空)
@@ -113,29 +42,13 @@ export interface DateRangeParams {
start_date?: string start_date?: string
end_date?: string end_date?: string
preset?: string preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month' granularity?: 'auto' | 'hour' | 'day' | 'week' | 'month'
timezone?: string timezone?: string
tz_offset_minutes?: number tz_offset_minutes?: number
} }
// 时间段选项 // 时间段选项
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days' export type PeriodValue = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
// 筛选状态(简化为常用维度) // 筛选状态由后端 filter-options 决定
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled' export type FilterStatusValue = string
// 默认统计状态
export function createDefaultStats(): UsageStatsState {
return {
total_requests: 0,
total_tokens: 0,
total_cost: 0,
total_actual_cost: undefined,
avg_response_time: 0,
error_count: undefined,
error_rate: undefined,
cache_stats: undefined,
period_start: '',
period_end: ''
}
}
+3 -4
View File
@@ -516,6 +516,7 @@ const navigation = computed(() => {
items: [ items: [
{ name: '仪表盘', href: '/dashboard', icon: Home }, { name: '仪表盘', href: '/dashboard', icon: Home },
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity }, { name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
{ name: '综合报表', href: '/dashboard/reports', icon: BarChart3 },
] ]
}, },
{ {
@@ -529,7 +530,7 @@ const navigation = computed(() => {
title: '账户', title: '账户',
items: [ items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet }, { name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 }, { name: '使用记录', href: '/dashboard/usage', icon: Activity },
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap }, { name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
] ]
} }
@@ -573,10 +574,8 @@ const navigation = computed(() => {
title: '概览', title: '概览',
items: [ items: [
{ name: '仪表盘', href: '/admin/dashboard', icon: Home }, { name: '仪表盘', href: '/admin/dashboard', icon: Home },
{ name: '数据分析', href: '/admin/analytics', icon: BarChart3 },
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity }, { name: '健康监控', href: '/admin/health-monitor', icon: Activity },
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
] ]
}, },
{ {
+1 -210
View File
@@ -4,10 +4,9 @@
*/ */
import type { User, LoginResponse } from '@/api/auth' import type { User, LoginResponse } from '@/api/auth'
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
import type { User as AdminUser } from '@/api/users' import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin' import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me' import type { Profile } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types' import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
// ========== 用户数据 ========== // ========== 用户数据 ==========
@@ -128,163 +127,6 @@ export const MOCK_USER_PROFILE: Profile = {
} }
} }
// ========== Dashboard 数据 ==========
export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
stats: [
{
name: '今日请求',
value: '1,234',
subValue: '成功率 99.2%',
change: '+12.5%',
changeType: 'increase',
icon: 'Activity'
},
{
name: '今日 Token',
value: '2.5M',
subValue: '输入 1.8M / 输出 0.7M',
change: '+8.3%',
changeType: 'increase',
icon: 'Zap'
},
{
name: '今日费用',
value: '$45.67',
subValue: '节省 $12.34 (21%)',
change: '-5.2%',
changeType: 'decrease',
icon: 'DollarSign'
},
{
name: '活跃用户',
value: '28',
subValue: '总用户 156',
change: '+3',
changeType: 'increase',
icon: 'Users'
}
],
today: {
requests: 1234,
tokens: 2500000,
cost: 45.67,
actual_cost: 33.33,
cache_creation_tokens: 50000,
cache_read_tokens: 200000
},
api_keys: {
total: 45,
active: 38
},
tokens: {
month: 75000000
},
system_health: {
avg_response_time: 1.23,
error_rate: 0.8,
error_requests: 10,
fallback_count: 5,
total_requests: 1234
},
cost_stats: {
total_cost: 45.67,
total_actual_cost: 33.33,
cost_savings: 12.34
},
cache_stats: {
cache_creation_tokens: 50000,
cache_read_tokens: 200000,
cache_creation_cost: 0.25,
cache_read_cost: 0.10,
cache_hit_rate: 0.35,
total_cache_tokens: 250000
},
users: {
total: 156,
active: 28
},
token_breakdown: {
input: 1800000,
output: 700000,
cache_creation: 50000,
cache_read: 200000
},
// 普通用户专用字段
monthly_cost: 45.67
}
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
{ name: 'Anthropic Official', status: 'active', requests: 456 },
{ name: 'OpenAI Official', status: 'active', requests: 389 },
{ name: 'Google AI', status: 'active', requests: 234 },
{ name: 'AWS Bedrock', status: 'active', requests: 89 },
{ name: 'Azure OpenAI', status: 'inactive', requests: 0 },
{ name: 'Vertex AI', status: 'active', requests: 66 }
]
// 生成过去7天的每日统计数据
function generateDailyStats(): DailyStatsResponse {
const dailyStats = []
const now = new Date()
for (let i = 6; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0]
const baseRequests = 800 + Math.floor(Math.random() * 600)
const baseTokens = 1500000 + Math.floor(Math.random() * 1500000)
const baseCost = 30 + Math.random() * 30
dailyStats.push({
date: dateStr,
requests: baseRequests,
tokens: baseTokens,
cost: Number(baseCost.toFixed(2)),
avg_response_time: 0.8 + Math.random() * 0.8,
unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3),
model_breakdown: [
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
]
})
}
return {
daily_stats: dailyStats,
model_summary: [
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
],
period: {
start_date: dailyStats[0].date,
end_date: dailyStats[dailyStats.length - 1].date,
days: 7
}
}
}
export const MOCK_DAILY_STATS = generateDailyStats()
// ========== 用户管理数据 ========== // ========== 用户管理数据 ==========
export const MOCK_ALL_USERS: AdminUser[] = [ export const MOCK_ALL_USERS: AdminUser[] = [
@@ -808,57 +650,6 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
} }
] ]
// ========== Usage 数据 ==========
export const MOCK_USAGE_RESPONSE: UsageResponse = {
total_requests: 1234,
total_input_tokens: 1800000,
total_output_tokens: 700000,
total_tokens: 2500000,
total_cost: 45.67,
total_actual_cost: 33.33,
avg_response_time: 1.23,
billing: MOCK_USER_BILLING,
summary_by_model: [
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
],
records: [
{
id: 'usage-001',
provider: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
input_tokens: 1500,
output_tokens: 800,
total_tokens: 2300,
cost: 0.0165,
response_time_ms: 1234,
is_stream: true,
created_at: new Date().toISOString(),
status_code: 200,
input_price_per_1m: 3,
output_price_per_1m: 15
},
{
id: 'usage-002',
provider: 'openai',
model: 'gpt-5.1',
input_tokens: 2000,
output_tokens: 500,
total_tokens: 2500,
cost: 0.01,
response_time_ms: 890,
is_stream: false,
created_at: new Date(Date.now() - 300000).toISOString(),
status_code: 200,
input_price_per_1m: 2.5,
output_price_per_1m: 10
}
]
}
// ========== 系统配置 ========== // ========== 系统配置 ==========
export const MOCK_SYSTEM_CONFIGS = [ export const MOCK_SYSTEM_CONFIGS = [
File diff suppressed because it is too large Load Diff
+8 -18
View File
@@ -112,6 +112,11 @@ const routes: RouteRecordRaw[] = [
name: 'MyUsage', name: 'MyUsage',
component: () => importWithRetry(() => import('@/views/shared/Usage.vue')) component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
}, },
{
path: 'reports',
name: 'Reports',
component: () => importWithRetry(() => import('@/views/user/Reports.vue'))
},
{ {
path: 'endpoint-status', path: 'endpoint-status',
name: 'EndpointStatus', name: 'EndpointStatus',
@@ -196,19 +201,9 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/shared/Usage.vue')) component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
}, },
{ {
path: 'user-stats', path: 'analytics',
name: 'UserStats', name: 'Analytics',
component: () => importWithRetry(() => import('@/views/admin/UserStats.vue')) component: () => importWithRetry(() => import('@/views/admin/Analytics.vue'))
},
{
path: 'cost-analysis',
name: 'CostAnalysis',
component: () => importWithRetry(() => import('@/views/admin/CostAnalysis.vue'))
},
{
path: 'performance-analysis',
name: 'PerformanceAnalysis',
component: () => importWithRetry(() => import('@/views/admin/PerformanceAnalysis.vue'))
}, },
{ {
path: 'system', path: 'system',
@@ -272,11 +267,6 @@ const routes: RouteRecordRaw[] = [
path: 'gemini-files', path: 'gemini-files',
name: 'GeminiFilesManagement', name: 'GeminiFilesManagement',
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue')) component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
},
// 保留旧路由兼容性
{
path: 'video-tasks',
redirect: '/admin/async-tasks'
} }
] ]
} }
+137
View File
@@ -0,0 +1,137 @@
import type { DateRangeParams } from '@/features/usage/types'
export type AnalyticsGranularityOption = NonNullable<DateRangeParams['granularity']>
export type ResolvedAnalyticsGranularity = Exclude<AnalyticsGranularityOption, 'auto'>
export const analyticsGranularityTabs: Array<{ value: AnalyticsGranularityOption; label: string }> = [
{ value: 'auto', label: '自动' },
{ value: 'hour', label: '小时' },
{ value: 'day', label: '天' },
{ value: 'week', label: '周' },
{ value: 'month', label: '月' },
]
export function parseAnalyticsDateKey(dateKey?: string): Date | null {
if (!dateKey) return null
return new Date(`${dateKey}T00:00:00`)
}
export function resolveAnalyticsRangeBounds(range: DateRangeParams): { startDate: Date | null; endDate: Date | null } {
const today = new Date()
switch (range.preset) {
case 'today':
return {
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
}
case 'last7days':
return {
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
}
case 'last30days':
return {
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
}
case 'last180days':
return {
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 179),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
}
case 'last1year':
return {
startDate: new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()),
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
}
default:
return {
startDate: parseAnalyticsDateKey(range.start_date),
endDate: parseAnalyticsDateKey(range.end_date),
}
}
}
export function getAnalyticsRangeDaysInclusive(range: DateRangeParams): number | null {
const { startDate, endDate } = resolveAnalyticsRangeBounds(range)
if (!startDate || !endDate) return null
const diff = endDate.getTime() - startDate.getTime()
if (diff < 0) return null
return Math.floor(diff / (24 * 60 * 60 * 1000)) + 1
}
export function isAnalyticsSingleDayRange(range: DateRangeParams): boolean {
return getAnalyticsRangeDaysInclusive(range) === 1
}
export function resolveAnalyticsAutoGranularity(range: DateRangeParams): ResolvedAnalyticsGranularity {
if (range.granularity && range.granularity !== 'auto') {
if (range.granularity === 'hour') {
return isAnalyticsSingleDayRange(range) ? 'hour' : 'day'
}
return range.granularity
}
const rangeDays = getAnalyticsRangeDaysInclusive(range)
if (rangeDays == null) return 'day'
if (rangeDays === 1) return 'hour'
if (rangeDays > 180) return 'month'
if (rangeDays > 31) return 'week'
return 'day'
}
export function formatAnalyticsTrendDescription(granularity: ResolvedAnalyticsGranularity, metricsLabel: string): string {
if (granularity === 'hour') {
return `按小时观察今天截至当前时刻的${metricsLabel}`
}
if (granularity === 'week') {
return `按周观察${metricsLabel}`
}
if (granularity === 'month') {
return `按月观察${metricsLabel}`
}
return `按日观察${metricsLabel}`
}
export function formatAnalyticsTrendBucketLabel(
bucketStart: string,
granularity: ResolvedAnalyticsGranularity,
): string {
if (granularity === 'hour') {
return `${bucketStart.slice(11, 13)}:00`
}
if (granularity === 'month') {
return bucketStart.slice(0, 7)
}
return bucketStart.slice(5, 10)
}
function formatAnalyticsDateRangeLabel(start: Date, end: Date): string {
const startLabel = `${String(start.getMonth() + 1).padStart(2, '0')}/${String(start.getDate()).padStart(2, '0')}`
const endLabel = `${String(end.getMonth() + 1).padStart(2, '0')}/${String(end.getDate()).padStart(2, '0')}`
return `${startLabel} - ${endLabel}`
}
export function formatAnalyticsTrendTooltipTitle(
bucketStart: string,
granularity: ResolvedAnalyticsGranularity,
): string {
if (granularity === 'hour') {
return `${bucketStart.slice(0, 10)} ${bucketStart.slice(11, 13)}:00`
}
if (granularity === 'month') {
return bucketStart.slice(0, 7)
}
if (granularity === 'week') {
const start = parseAnalyticsDateKey(bucketStart.slice(0, 10))
if (!start) return bucketStart.slice(0, 10)
const end = new Date(start)
end.setDate(end.getDate() + 6)
return formatAnalyticsDateRangeLabel(start, end)
}
return bucketStart.slice(0, 10)
}
+369
View File
@@ -0,0 +1,369 @@
import type { AnalyticsGranularity, AnalyticsSummary, AnalyticsTimeRange, AnalyticsTimeseriesBucket } from '@/api/analytics'
type DailyPreset = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
const ZERO_ANALYTICS_SUMMARY: AnalyticsSummary = {
requests_total: 0,
requests_success: 0,
requests_error: 0,
requests_stream: 0,
success_rate: 0,
input_tokens: 0,
output_tokens: 0,
input_output_total_tokens: 0,
cache_creation_input_tokens: 0,
cache_creation_input_tokens_5m: 0,
cache_creation_input_tokens_1h: 0,
cache_read_input_tokens: 0,
input_context_tokens: 0,
total_tokens: 0,
cache_hit_rate: 0,
input_cost_usd: 0,
output_cost_usd: 0,
cache_creation_cost_usd: 0,
cache_creation_cost_usd_5m: 0,
cache_creation_cost_usd_1h: 0,
cache_read_cost_usd: 0,
cache_cost_usd: 0,
request_cost_usd: 0,
total_cost_usd: 0,
actual_total_cost_usd: 0,
actual_cache_cost_usd: 0,
avg_response_time_ms: 0,
avg_first_byte_time_ms: 0,
format_conversion_count: 0,
models_used_count: 0,
}
function parseDateKey(dateKey: string | undefined): Date | null {
if (!dateKey) return null
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey.trim())
if (!match) return null
const [, year, month, day] = match
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
}
function formatDateKey(date: Date): string {
return date.toISOString().slice(0, 10)
}
function formatLocalDateKey(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatHourKey(dateKey: string, hour: number): string {
return `${dateKey}T${String(hour).padStart(2, '0')}`
}
function getStartOfWeekDateKey(date: Date): string {
const weekday = date.getUTCDay()
const diff = weekday === 0 ? -6 : 1 - weekday
const result = new Date(date)
result.setUTCDate(result.getUTCDate() + diff)
return formatDateKey(result)
}
function getStartOfMonthDateKey(date: Date): string {
const result = new Date(date)
result.setUTCDate(1)
return formatDateKey(result)
}
function getNextDateKey(dateKey: string): string {
const date = parseDateKey(dateKey)
if (!date) return dateKey
date.setUTCDate(date.getUTCDate() + 1)
return formatDateKey(date)
}
function getNextWeekKey(dateKey: string): string {
const date = parseDateKey(dateKey)
if (!date) return dateKey
date.setUTCDate(date.getUTCDate() + 7)
return formatDateKey(date)
}
function getNextMonthKey(dateKey: string): string {
const date = parseDateKey(dateKey)
if (!date) return dateKey
date.setUTCMonth(date.getUTCMonth() + 1, 1)
return formatDateKey(date)
}
function resolvePresetDateRange(preset?: string): { start_date: string; end_date: string } | null {
const now = new Date()
let startDate: Date
const endDate = new Date(now)
switch (preset as DailyPreset | undefined) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
break
case 'last7days':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)
break
case 'last30days':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29)
break
case 'last180days':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 179)
break
case 'last1year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
break
default:
return null
}
return {
start_date: formatLocalDateKey(startDate),
end_date: formatLocalDateKey(endDate),
}
}
function resolveDateBounds(
timeRange: AnalyticsTimeRange,
fallbackBuckets: AnalyticsTimeseriesBucket[],
): { startDate: Date | null; endDate: Date | null } {
const sortedBuckets = sortBuckets(fallbackBuckets)
const fallbackStart = sortedBuckets[0]?.bucket_start.slice(0, 10)
const fallbackEnd = sortedBuckets[sortedBuckets.length - 1]?.bucket_start.slice(0, 10)
const presetRange = resolvePresetDateRange(timeRange.preset)
const startDate = parseDateKey(timeRange.start_date ?? presetRange?.start_date ?? fallbackStart)
const endDate = parseDateKey(timeRange.end_date ?? presetRange?.end_date ?? fallbackEnd)
return { startDate, endDate }
}
function sortBuckets(buckets: AnalyticsTimeseriesBucket[]): AnalyticsTimeseriesBucket[] {
return buckets.slice().sort((left, right) => left.bucket_start.localeCompare(right.bucket_start))
}
function normalizeGranularity(granularity?: string): AnalyticsGranularity {
if (granularity === 'hour' || granularity === 'week' || granularity === 'month') {
return granularity
}
return 'day'
}
function resolveTodayDateKey(): string {
return formatLocalDateKey(new Date())
}
export function getDateKeysInRange(
timeRange: AnalyticsTimeRange,
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
): string[] {
const sortedBuckets = sortBuckets(fallbackBuckets)
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
}
const dates: string[] = []
const cursor = new Date(startDate)
while (cursor.getTime() <= endDate.getTime()) {
dates.push(formatDateKey(cursor))
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return dates
}
export function getHourKeysInRange(
timeRange: AnalyticsTimeRange,
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
): string[] {
const sortedBuckets = sortBuckets(fallbackBuckets)
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
if (!startDate || !endDate || startDate.getTime() !== endDate.getTime()) {
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 13))
}
const dateKey = formatDateKey(startDate)
const isToday = dateKey === resolveTodayDateKey()
const maxHour = isToday ? new Date().getHours() : 23
return Array.from({ length: maxHour + 1 }, (_, hour) => formatHourKey(dateKey, hour))
}
export function getWeekKeysInRange(
timeRange: AnalyticsTimeRange,
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
): string[] {
const sortedBuckets = sortBuckets(fallbackBuckets)
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
}
const keys: string[] = []
let cursorKey = getStartOfWeekDateKey(startDate)
const endKey = getStartOfWeekDateKey(endDate)
while (cursorKey <= endKey) {
keys.push(cursorKey)
cursorKey = getNextWeekKey(cursorKey)
}
return keys
}
export function getMonthKeysInRange(
timeRange: AnalyticsTimeRange,
fallbackBuckets: AnalyticsTimeseriesBucket[] = [],
): string[] {
const sortedBuckets = sortBuckets(fallbackBuckets)
const { startDate, endDate } = resolveDateBounds(timeRange, sortedBuckets)
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) {
return sortedBuckets.map(bucket => bucket.bucket_start.slice(0, 10))
}
const keys: string[] = []
let cursorKey = getStartOfMonthDateKey(startDate)
const endKey = getStartOfMonthDateKey(endDate)
while (cursorKey <= endKey) {
keys.push(cursorKey)
cursorKey = getNextMonthKey(cursorKey)
}
return keys
}
export function createZeroDailyBucket(dateKey: string): AnalyticsTimeseriesBucket {
return {
...ZERO_ANALYTICS_SUMMARY,
bucket_start: `${dateKey}T00:00:00`,
bucket_end: `${getNextDateKey(dateKey)}T00:00:00`,
}
}
export function createZeroHourlyBucket(hourKey: string): AnalyticsTimeseriesBucket {
const dateKey = hourKey.slice(0, 10)
const hour = Number(hourKey.slice(11, 13))
const nextDate = new Date(Date.UTC(
Number(dateKey.slice(0, 4)),
Number(dateKey.slice(5, 7)) - 1,
Number(dateKey.slice(8, 10)),
hour,
))
nextDate.setUTCHours(nextDate.getUTCHours() + 1)
const nextDateKey = formatDateKey(nextDate)
const nextHour = String(nextDate.getUTCHours()).padStart(2, '0')
return {
...ZERO_ANALYTICS_SUMMARY,
bucket_start: `${hourKey}:00:00`,
bucket_end: `${nextDateKey}T${nextHour}:00:00`,
}
}
export function createZeroWeeklyBucket(dateKey: string): AnalyticsTimeseriesBucket {
return {
...ZERO_ANALYTICS_SUMMARY,
bucket_start: `${dateKey}T00:00:00`,
bucket_end: `${getNextWeekKey(dateKey)}T00:00:00`,
}
}
export function createZeroMonthlyBucket(dateKey: string): AnalyticsTimeseriesBucket {
return {
...ZERO_ANALYTICS_SUMMARY,
bucket_start: `${dateKey}T00:00:00`,
bucket_end: `${getNextMonthKey(dateKey)}T00:00:00`,
}
}
export function fillMissingDailyTimeseriesBuckets(
buckets: AnalyticsTimeseriesBucket[],
timeRange: AnalyticsTimeRange,
): AnalyticsTimeseriesBucket[] {
const sortedBuckets = sortBuckets(buckets)
const dateKeys = getDateKeysInRange(timeRange, sortedBuckets)
if (!dateKeys.length) {
return sortedBuckets
}
const bucketByDate = new Map(
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
)
return dateKeys.map(dateKey => bucketByDate.get(dateKey) ?? createZeroDailyBucket(dateKey))
}
export function fillMissingWeeklyTimeseriesBuckets(
buckets: AnalyticsTimeseriesBucket[],
timeRange: AnalyticsTimeRange,
): AnalyticsTimeseriesBucket[] {
const sortedBuckets = sortBuckets(buckets)
const weekKeys = getWeekKeysInRange(timeRange, sortedBuckets)
if (!weekKeys.length) {
return sortedBuckets
}
const bucketByWeek = new Map(
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
)
return weekKeys.map(weekKey => bucketByWeek.get(weekKey) ?? createZeroWeeklyBucket(weekKey))
}
export function fillMissingMonthlyTimeseriesBuckets(
buckets: AnalyticsTimeseriesBucket[],
timeRange: AnalyticsTimeRange,
): AnalyticsTimeseriesBucket[] {
const sortedBuckets = sortBuckets(buckets)
const monthKeys = getMonthKeysInRange(timeRange, sortedBuckets)
if (!monthKeys.length) {
return sortedBuckets
}
const bucketByMonth = new Map(
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 10), bucket]),
)
return monthKeys.map(monthKey => bucketByMonth.get(monthKey) ?? createZeroMonthlyBucket(monthKey))
}
export function fillMissingTimeseriesBuckets(
buckets: AnalyticsTimeseriesBucket[],
timeRange: AnalyticsTimeRange,
): AnalyticsTimeseriesBucket[] {
const granularity = normalizeGranularity(timeRange.granularity)
const sortedBuckets = sortBuckets(buckets)
if (granularity === 'week') {
return fillMissingWeeklyTimeseriesBuckets(sortedBuckets, timeRange)
}
if (granularity === 'month') {
return fillMissingMonthlyTimeseriesBuckets(sortedBuckets, timeRange)
}
if (granularity !== 'hour') {
return fillMissingDailyTimeseriesBuckets(sortedBuckets, timeRange)
}
const hourKeys = getHourKeysInRange(timeRange, sortedBuckets)
if (!hourKeys.length) {
return sortedBuckets
}
const bucketByHour = new Map(
sortedBuckets.map(bucket => [bucket.bucket_start.slice(0, 13), bucket]),
)
return hourKeys.map(hourKey => bucketByHour.get(hourKey) ?? createZeroHourlyBucket(hourKey))
}
+135
View File
@@ -0,0 +1,135 @@
const COLOR_KEYS = new Set([
'backgroundColor',
'borderColor',
'color',
'pointBackgroundColor',
'pointBorderColor',
'pointHoverBackgroundColor',
'pointHoverBorderColor',
'hoverBackgroundColor',
'hoverBorderColor',
'tickColor',
])
const CSS_COLOR_PATTERN = /(var\(|oklch\(|hsl\(|hsla\(|rgb\(|rgba\()/i
const HSL_VAR_PATTERN = /^hsl\(var\((--[^)]+)\)(?:\s*\/\s*([^)]+))?\)$/i
let resolverElement: HTMLSpanElement | null = null
function getResolverElement(): HTMLSpanElement | null {
if (typeof document === 'undefined') return null
if (resolverElement && resolverElement.isConnected) return resolverElement
const element = document.createElement('span')
element.setAttribute('aria-hidden', 'true')
element.style.position = 'fixed'
element.style.pointerEvents = 'none'
element.style.opacity = '0'
element.style.visibility = 'hidden'
element.style.inset = '-9999px'
document.body.appendChild(element)
resolverElement = element
return resolverElement
}
export function resolveCssColor(value: string): string {
if (!CSS_COLOR_PATTERN.test(value)) return value
const hslVarMatch = value.trim().match(HSL_VAR_PATTERN)
if (hslVarMatch) {
const [, variableName, alphaValue] = hslVarMatch
const baseColor = resolveCssColor(`var(${variableName})`)
if (!alphaValue) return baseColor
return applyAlpha(baseColor, alphaValue)
}
const element = getResolverElement()
if (!element) return value
element.style.color = ''
element.style.color = value
if (!element.style.color) {
return value
}
const resolved = window.getComputedStyle(element).color.trim()
return resolved || value
}
function applyAlpha(color: string, alphaValue: string): string {
const alpha = parseAlpha(alphaValue)
if (alpha == null) return color
const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/i)
if (!rgbMatch) return color
const parts = rgbMatch[1].split(',').map(part => part.trim())
if (parts.length < 3) return color
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`
}
function parseAlpha(value: string): number | null {
const trimmed = value.trim()
if (trimmed.endsWith('%')) {
const percentage = Number.parseFloat(trimmed.slice(0, -1))
return Number.isFinite(percentage) ? percentage / 100 : null
}
const numeric = Number.parseFloat(trimmed)
return Number.isFinite(numeric) ? numeric : null
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isColorKey(key: string | undefined): boolean {
if (!key) return false
return COLOR_KEYS.has(key) || /color$/i.test(key)
}
function resolveValue(value: unknown, key?: string): unknown {
if (typeof value === 'string') {
return isColorKey(key) ? resolveCssColor(value) : value
}
if (Array.isArray(value)) {
return value.map(item => resolveValue(item, key))
}
if (!isObject(value)) {
return value
}
const output: Record<string, unknown> = {}
for (const [childKey, childValue] of Object.entries(value)) {
output[childKey] = resolveValue(childValue, childKey)
}
return output
}
export function resolveChartTheme<T>(value: T): T {
return resolveValue(value) as T
}
export function observeChartThemeChanges(callback: () => void): () => void {
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') {
return () => {}
}
const observer = new MutationObserver(() => callback())
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style'],
})
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'style', 'theme-mode'],
})
}
return () => observer.disconnect()
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 客户端 CSV 导出工具
* 将数据转为 CSV 并触发浏览器下载
*/
function escapeCsvField(value: string | number | null | undefined): string {
if (value == null) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
/**
* 生成 CSV 内容并触发下载
* @param filename 文件名(不含扩展名)
* @param headers 列标题
* @param rows 数据行
*/
export function downloadCsv(
filename: string,
headers: string[],
rows: (string | number | null | undefined)[][],
): void {
const bom = '\uFEFF' // UTF-8 BOM,确保 Excel 正确识别中文
const headerLine = headers.map(escapeCsvField).join(',')
const dataLines = rows.map(row => row.map(escapeCsvField).join(','))
const csv = bom + [headerLine, ...dataLines].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
/**
* 从对象数组生成 CSV 下载
* @param filename 文件名
* @param data 对象数组
* @param columns 列定义 [{ key, label }]
*/
export function downloadCsvFromObjects<T extends Record<string, unknown>>(
filename: string,
data: T[],
columns: { key: string; label: string }[],
): void {
const headers = columns.map(c => c.label)
const rows = data.map(item =>
columns.map(c => item[c.key] as string | number | null),
)
downloadCsv(filename, headers, rows)
}
+40 -21
View File
@@ -1,17 +1,34 @@
function toFiniteNumber(value: number | string | undefined | null): number | null {
if (value === undefined || value === null) return null
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
const normalized = value
.trim()
.replace(/[$,\s]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
// Token formatting - intelligent display based on value size // Token formatting - intelligent display based on value size
export function formatTokens(num: number | undefined | null): string { export function formatTokens(num: number | string | undefined | null): string {
if (num === undefined || num === null || num === 0) { const normalized = toFiniteNumber(num)
if (normalized === null || normalized === 0) {
return '0' return '0'
} }
// For very small values (< 1000), show as is without unit // For very small values (< 1000), show as is without unit
if (num < 1000) { if (normalized < 1000) {
return num.toString() return normalized.toString()
} }
// For values 1K-999K, show in thousands // For values 1K-999K, show in thousands
if (num < 1000000) { if (normalized < 1000000) {
const thousands = num / 1000 const thousands = normalized / 1000
if (thousands >= 100) { if (thousands >= 100) {
return `${Math.round(thousands) }K` return `${Math.round(thousands) }K`
} else if (thousands >= 10) { } else if (thousands >= 10) {
@@ -22,7 +39,7 @@ export function formatTokens(num: number | undefined | null): string {
} }
// For values >= 1M, show in millions // For values >= 1M, show in millions
const millions = num / 1000000 const millions = normalized / 1000000
if (millions >= 100) { if (millions >= 100) {
return `${Math.round(millions) }M` return `${Math.round(millions) }M`
} else if (millions >= 10) { } else if (millions >= 10) {
@@ -33,53 +50,54 @@ export function formatTokens(num: number | undefined | null): string {
} }
// Currency formatting with high precision for small values // Currency formatting with high precision for small values
export function formatCurrency(amount: number | undefined | null): string { export function formatCurrency(amount: number | string | undefined | null): string {
if (amount === undefined || amount === null || amount === 0) { const normalized = toFiniteNumber(amount)
if (normalized === null || normalized === 0) {
return '$0.00' return '$0.00'
} }
// For very small amounts (< $0.00001), show up to 8 decimal places // For very small amounts (< $0.00001), show up to 8 decimal places
if (amount > 0 && amount < 0.00001) { if (normalized > 0 && normalized < 0.00001) {
const formatted = amount.toFixed(8) const formatted = normalized.toFixed(8)
// Remove trailing zeros but keep at least 2 decimal places // Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1') const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}` return `$${ trimmed}`
} }
// For small amounts (< $0.0001), show up to 6 decimal places // For small amounts (< $0.0001), show up to 6 decimal places
if (amount < 0.0001) { if (normalized < 0.0001) {
const formatted = amount.toFixed(6) const formatted = normalized.toFixed(6)
// Remove trailing zeros but keep at least 2 decimal places // Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1') const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}` return `$${ trimmed}`
} }
// For small amounts (< $0.01), show up to 5 decimal places // For small amounts (< $0.01), show up to 5 decimal places
if (amount < 0.01) { if (normalized < 0.01) {
const formatted = amount.toFixed(5) const formatted = normalized.toFixed(5)
// Remove trailing zeros but keep at least 2 decimal places // Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1') const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}` return `$${ trimmed}`
} }
// For amounts less than $1, show 4 decimal places // For amounts less than $1, show 4 decimal places
if (amount < 1) { if (normalized < 1) {
const formatted = amount.toFixed(4) const formatted = normalized.toFixed(4)
// Remove trailing zeros but keep at least 2 decimal places // Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1') const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}` return `$${ trimmed}`
} }
// For amounts $1-$100, show 2-3 decimal places // For amounts $1-$100, show 2-3 decimal places
if (amount < 100) { if (normalized < 100) {
const formatted = amount.toFixed(3) const formatted = normalized.toFixed(3)
// Remove trailing zeros but keep at least 2 decimal places // Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1') const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}` return `$${ trimmed}`
} }
// For larger amounts, show 2 decimal places // For larger amounts, show 2 decimal places
return `$${ amount.toFixed(2)}` return `$${ normalized.toFixed(2)}`
} }
// Number formatting with locale support // Number formatting with locale support
@@ -157,7 +175,8 @@ export function formatRemainingTime(expireAt: number | undefined, currentTime: n
// Cache hit rate formatting // Cache hit rate formatting
export function formatHitRate(rate: number | undefined): string { export function formatHitRate(rate: number | undefined): string {
if (typeof rate !== 'number' || Number.isNaN(rate)) return '-' if (typeof rate !== 'number' || Number.isNaN(rate)) return '-'
return `${rate.toFixed(2)}%` const normalized = rate <= 1 ? rate * 100 : rate
return `${normalized.toFixed(2)}%`
} }
// Rate limit formatting (supports "inherit" semantics: null = inherit system default) // Rate limit formatting (supports "inherit" semantics: null = inherit system default)
+14
View File
@@ -0,0 +1,14 @@
export interface DailyUsageBreakdown {
date: string
inputTokens: number
outputTokens: number
cacheCreationTokens: number
cacheReadTokens: number
cacheCreationCost: number
cacheReadCost: number
cacheHitRate: number | null
totalCacheCost: number
totalCacheTokens: number
totalTrackedTokens: number
baseTokens: number
}
+246
View File
@@ -0,0 +1,246 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="text-lg font-semibold">
数据分析
</h1>
<p class="text-xs text-muted-foreground">
用量报表、模型报表与全局性能洞察
</p>
</div>
</div>
<!-- Tabs -->
<Tabs v-model="filters.activeTab.value">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<TabsList class="tabs-button-list">
<TabsTrigger value="detail">
用量报表
</TabsTrigger>
<TabsTrigger value="models">
模型报表
</TabsTrigger>
<TabsTrigger value="leaderboard">
排行榜
</TabsTrigger>
<TabsTrigger value="performance">
性能
</TabsTrigger>
</TabsList>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<TimeRangePicker
v-model="filters.timeRange.value"
:show-granularity="false"
:include-auto-granularity="true"
:compact="true"
/>
<template v-if="filters.activeTab.value === 'leaderboard'">
<Select v-model="leaderboardDimension">
<SelectTrigger class="h-8 w-full text-xs sm:w-32">
<SelectValue placeholder="维度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="users">
用户
</SelectItem>
<SelectItem value="api_keys">
API Key
</SelectItem>
</SelectContent>
</Select>
</template>
<template v-else-if="showEntityFilters">
<Select v-model="selectedUserValue">
<SelectTrigger class="h-8 w-full text-xs sm:w-36">
<SelectValue placeholder="全部用户">
<span
class="block min-w-0 truncate"
:title="selectedUserLabel"
>
{{ selectedUserLabel }}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent class="w-[min(18rem,var(--radix-select-trigger-width))]">
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="option in userOptions"
:key="option.value"
:value="option.value"
:text-value="option.label"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="selectedApiKeyValue">
<SelectTrigger class="h-8 w-full text-xs sm:w-36">
<SelectValue placeholder="全部 Key">
<span
class="block min-w-0 truncate"
:title="selectedApiKeyLabel"
>
{{ selectedApiKeyLabel }}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent class="w-[min(18rem,var(--radix-select-trigger-width))]">
<SelectItem value="__all__">
全部 Key
</SelectItem>
<SelectItem
v-for="option in apiKeyOptions"
:key="option.value"
:value="option.value"
:text-value="option.label"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</template>
</div>
</div>
<div class="mt-4">
<ReportsTab
v-if="isReportsTab"
:mode="reportMode"
:selected-user-label="selectedUserExportLabel"
:selected-api-key-label="selectedApiKeyExportLabel"
/>
<LeaderboardTab
v-else-if="filters.activeTab.value === 'leaderboard'"
:dimension="leaderboardDimension"
/>
<PerformanceTab v-else-if="filters.activeTab.value === 'performance'" />
</div>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { computed, provide, ref, watch, onMounted } from 'vue'
import { Tabs, TabsList, TabsTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { TimeRangePicker } from '@/components/common'
import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
import { useAnalyticsFilters } from '@/composables/useAnalyticsFilters'
import type { LeaderboardDimension } from '@/composables/analytics/useLeaderboardData'
import ReportsTab from './components/analytics/ReportsTab.vue'
import LeaderboardTab from './components/analytics/LeaderboardTab.vue'
import PerformanceTab from './components/analytics/PerformanceTab.vue'
const filters = useAnalyticsFilters()
provide('analyticsFilters', filters)
const userOptions = ref<AnalyticsFilterOption[]>([])
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
const leaderboardDimension = ref<LeaderboardDimension>('users')
const supportedTabs = new Set(['detail', 'models', 'leaderboard', 'performance'])
const isReportsTab = computed(() => filters.activeTab.value === 'detail' || filters.activeTab.value === 'models')
const showEntityFilters = computed(() => isReportsTab.value)
const reportMode = computed<'detail' | 'models'>(() => (
filters.activeTab.value === 'models' ? 'models' : 'detail'
))
let suppressNextUserFilterLoad = false
let suppressNextApiKeyFilterLoad = false
const selectedUserValue = computed({
get: () => filters.userFilter.value[0] ?? '__all__',
set: (value: string) => {
filters.userFilter.value = value && value !== '__all__' ? [value] : []
},
})
const selectedUserLabel = computed(() => (
selectedUserValue.value === '__all__'
? '全部用户'
: userOptions.value.find(option => option.value === selectedUserValue.value)?.label || '全部用户'
))
const selectedApiKeyValue = computed({
get: () => filters.apiKeyFilter.value[0] ?? '__all__',
set: (value: string) => {
filters.apiKeyFilter.value = value && value !== '__all__' ? [value] : []
},
})
const selectedApiKeyLabel = computed(() => (
selectedApiKeyValue.value === '__all__'
? '全部 Key'
: apiKeyOptions.value.find(option => option.value === selectedApiKeyValue.value)?.label || '全部 Key'
))
const selectedUserExportLabel = computed(() => (
selectedUserValue.value === '__all__' ? '' : selectedUserLabel.value
))
const selectedApiKeyExportLabel = computed(() => (
selectedApiKeyValue.value === '__all__' ? '' : selectedApiKeyLabel.value
))
function hasOption(options: AnalyticsFilterOption[], value: string): boolean {
return options.some(option => option.value === value)
}
watch(
() => filters.activeTab.value,
(tab) => {
if (!supportedTabs.has(tab)) {
filters.activeTab.value = 'detail'
}
},
{ immediate: true },
)
async function loadFilterOptions() {
if (!showEntityFilters.value) return
const response = await analyticsApi.getFilterOptions({
scope: { kind: 'global' },
time_range: filters.getTimeRangeParams(),
filters: {
user_ids: filters.userFilter.value,
api_key_ids: filters.apiKeyFilter.value,
},
}).catch(() => null)
if (!response) return
userOptions.value = response.users ?? []
apiKeyOptions.value = response.api_keys ?? []
if (filters.userFilter.value.length > 0 && !hasOption(userOptions.value, filters.userFilter.value[0])) {
suppressNextUserFilterLoad = true
filters.userFilter.value = []
}
if (filters.apiKeyFilter.value.length > 0 && !hasOption(apiKeyOptions.value, filters.apiKeyFilter.value[0])) {
suppressNextApiKeyFilterLoad = true
filters.apiKeyFilter.value = []
}
}
watch(
() => [
showEntityFilters.value,
filters.getTimeRangeParams(),
filters.userFilter.value.slice(),
filters.apiKeyFilter.value.slice(),
],
([shouldShow]) => {
if (!shouldShow) return
if (suppressNextUserFilterLoad) {
suppressNextUserFilterLoad = false
return
}
if (suppressNextApiKeyFilterLoad) {
suppressNextApiKeyFilterLoad = false
return
}
void loadFilterOptions()
},
{ deep: true },
)
onMounted(() => {
if (showEntityFilters.value) {
void loadFilterOptions()
}
})
</script>
-207
View File
@@ -1,207 +0,0 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">
成本分析
</h1>
<p class="text-xs text-muted-foreground">
成本趋势、预测与节省统计
</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">
缓存节省
</div>
<div class="text-lg font-semibold">
{{ formatCurrency(costSavings?.cache_savings ?? 0) }}
</div>
<div class="text-xs text-muted-foreground">
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">
缓存读取 Tokens
</div>
<div class="text-lg font-semibold">
{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}
</div>
<div class="text-xs text-muted-foreground">
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">
缓存创建成本
</div>
<div class="text-lg font-semibold">
{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}
</div>
<div class="text-xs text-muted-foreground">
基于当前时间范围
</div>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<CostForecastChart
title="成本趋势预测"
:history="forecastHistory"
:forecast="forecastFuture"
:loading="forecastLoading"
/>
</Card>
<QuotaProgressCard
title="月卡消耗进度"
:providers="quotaProviders"
:loading="quotaLoading"
/>
</div>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import type { ProviderStatsItem } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const forecastLoading = ref(false)
const quotaLoading = ref(false)
let forecastRequestId = 0
let savingsRequestId = 0
let quotaRequestId = 0
let providerStatsRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || [])
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadForecast() {
const requestId = ++forecastRequestId
forecastLoading.value = true
try {
const data = await adminApi.getCostForecast(buildTimeRangeParams())
if (requestId !== forecastRequestId) return
forecast.value = data
} finally {
if (requestId === forecastRequestId) {
forecastLoading.value = false
}
}
}
async function loadSavings() {
const requestId = ++savingsRequestId
const data = await adminApi.getCostSavings(buildTimeRangeParams())
if (requestId !== savingsRequestId) return
costSavings.value = data
}
async function loadQuotaUsage() {
const requestId = ++quotaRequestId
quotaLoading.value = true
try {
const response = await adminApi.getQuotaUsage()
if (requestId !== quotaRequestId) return
quotaProviders.value = response.providers
} finally {
if (requestId === quotaRequestId) {
quotaLoading.value = false
}
}
}
async function loadProviderStats() {
const requestId = ++providerStatsRequestId
const stats = await usageApi.getUsageByProvider({
...buildTimeRangeParams(),
limit: 8
})
if (requestId !== providerStatsRequestId) return
providerStats.value = stats
}
async function loadAll() {
if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
if (hasPendingLoadAll) {
hasPendingLoadAll = false
void loadAll()
}
})
return loadAllPromise
}
function scheduleLoadAll() {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
}
loadAllDebounceTimer = setTimeout(() => {
loadAllDebounceTimer = null
void loadAll()
}, 120)
}
watch(timeRange, scheduleLoadAll, { deep: true })
onMounted(() => {
void loadAll()
})
onUnmounted(() => {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
forecastRequestId += 1
savingsRequestId += 1
quotaRequestId += 1
providerStatsRequestId += 1
})
</script>
@@ -1,232 +0,0 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">
性能分析
</h1>
<p class="text-xs text-muted-foreground">
延迟分布与错误统计
</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<PercentileChart
title="响应延迟百分位"
:series="percentiles"
mode="response"
:loading="percentileLoading"
/>
</Card>
<Card class="p-4">
<PercentileChart
title="首字节延迟百分位"
:series="percentiles"
mode="ttfb"
:loading="percentileLoading"
/>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<ErrorDistributionChart
title="错误分布"
:distribution="errorDistribution"
:loading="errorLoading"
/>
</Card>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">
错误趋势
</h3>
<div
v-if="errorLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
>
<LineChart :data="errorTrendChartData" />
</div>
</Card>
</div>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">
提供商健康度
</h3>
<div
v-if="providerLoading"
class="p-4"
>
<LoadingState />
</div>
<div
v-else
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm"
>
<div
v-for="provider in providerStatus"
:key="provider.name"
class="p-3 border rounded-lg"
>
<div class="flex items-center justify-between">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
</div>
<div class="text-xs text-muted-foreground mt-1">
状态: {{ provider.status }}
</div>
</div>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
import LineChart from '@/components/charts/LineChart.vue'
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const percentiles = ref<PercentileItem[]>([])
const percentileLoading = ref(false)
const errorDistribution = ref<ErrorDistributionResponse['distribution']>([])
const errorTrend = ref<ErrorDistributionResponse['trend']>([])
const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false)
let percentilesRequestId = 0
let errorsRequestId = 0
let providersRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadPercentiles() {
const requestId = ++percentilesRequestId
percentileLoading.value = true
try {
const data = await adminApi.getPercentiles(buildTimeRangeParams())
if (requestId !== percentilesRequestId) return
percentiles.value = data
} finally {
if (requestId === percentilesRequestId) {
percentileLoading.value = false
}
}
}
async function loadErrors() {
const requestId = ++errorsRequestId
errorLoading.value = true
try {
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
if (requestId !== errorsRequestId) return
errorDistribution.value = response.distribution
errorTrend.value = response.trend
} finally {
if (requestId === errorsRequestId) {
errorLoading.value = false
}
}
}
async function loadProviders() {
const requestId = ++providersRequestId
providerLoading.value = true
try {
const data = await dashboardApi.getProviderStatus()
if (requestId !== providersRequestId) return
providerStatus.value = data
} finally {
if (requestId === providersRequestId) {
providerLoading.value = false
}
}
}
const errorTrendChartData = computed(() => ({
labels: errorTrend.value.map(item => item.date),
datasets: [
{
label: '错误数',
data: errorTrend.value.map(item => item.total),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}))
async function loadAll() {
if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
if (hasPendingLoadAll) {
hasPendingLoadAll = false
void loadAll()
}
})
return loadAllPromise
}
function scheduleLoadAll() {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
}
loadAllDebounceTimer = setTimeout(() => {
loadAllDebounceTimer = null
void loadAll()
}, 120)
}
watch(timeRange, scheduleLoadAll, { deep: true })
onMounted(() => {
void loadAll()
})
onUnmounted(() => {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
percentilesRequestId += 1
errorsRequestId += 1
providersRequestId += 1
})
</script>
@@ -73,6 +73,17 @@
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event" @update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
/> />
<MonitoringCapacitySection
id="section-monitoring-capacity"
:redis-memory-total-g-b="redisMemoryTotalGB"
:postgres-storage-total-g-b="postgresStorageTotalGB"
:loading="monitoringConfigLoading"
:has-changes="hasMonitoringConfigChanges"
@save="saveMonitoringConfig"
@update:redis-memory-total-g-b="redisMemoryTotalGB = $event"
@update:postgres-storage-total-g-b="postgresStorageTotalGB = $event"
/>
<!-- 请求记录配置 --> <!-- 请求记录配置 -->
<RequestLogSection <RequestLogSection
id="section-request-log" id="section-request-log"
@@ -211,6 +222,7 @@ import UserDataSection from './system-settings/UserDataSection.vue'
import DataManagementSection from './system-settings/DataManagementSection.vue' import DataManagementSection from './system-settings/DataManagementSection.vue'
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue' import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
import BasicConfigSection from './system-settings/BasicConfigSection.vue' import BasicConfigSection from './system-settings/BasicConfigSection.vue'
import MonitoringCapacitySection from './system-settings/MonitoringCapacitySection.vue'
import RequestLogSection from './system-settings/RequestLogSection.vue' import RequestLogSection from './system-settings/RequestLogSection.vue'
import CleanupPolicySection from './system-settings/CleanupPolicySection.vue' import CleanupPolicySection from './system-settings/CleanupPolicySection.vue'
import ScheduledTasksSection from './system-settings/ScheduledTasksSection.vue' import ScheduledTasksSection from './system-settings/ScheduledTasksSection.vue'
@@ -230,6 +242,7 @@ const tocItems = [
{ id: 'section-data-mgmt', label: '数据管理' }, { id: 'section-data-mgmt', label: '数据管理' },
{ id: 'section-proxy', label: '网络代理' }, { id: 'section-proxy', label: '网络代理' },
{ id: 'section-basic', label: '基础配置' }, { id: 'section-basic', label: '基础配置' },
{ id: 'section-monitoring-capacity', label: '监控容量' },
{ id: 'section-request-log', label: '请求记录' }, { id: 'section-request-log', label: '请求记录' },
{ id: 'section-cleanup', label: '记录清理策略' }, { id: 'section-cleanup', label: '记录清理策略' },
{ id: 'section-scheduled', label: '定时任务' }, { id: 'section-scheduled', label: '定时任务' },
@@ -290,21 +303,26 @@ const {
siteInfoLoading, siteInfoLoading,
proxyConfigLoading, proxyConfigLoading,
basicConfigLoading, basicConfigLoading,
monitoringConfigLoading,
logConfigLoading, logConfigLoading,
cleanupConfigLoading, cleanupConfigLoading,
hasSiteInfoChanges, hasSiteInfoChanges,
hasProxyConfigChanges, hasProxyConfigChanges,
hasBasicConfigChanges, hasBasicConfigChanges,
hasMonitoringConfigChanges,
hasLogConfigChanges, hasLogConfigChanges,
hasCleanupConfigChanges, hasCleanupConfigChanges,
maxRequestBodySizeKB, maxRequestBodySizeKB,
maxResponseBodySizeKB, maxResponseBodySizeKB,
redisMemoryTotalGB,
postgresStorageTotalGB,
sensitiveHeadersStr, sensitiveHeadersStr,
loadSystemConfig, loadSystemConfig,
loadSystemVersion, loadSystemVersion,
saveSiteInfo, saveSiteInfo,
saveProxyConfig, saveProxyConfig,
saveBasicConfig, saveBasicConfig,
saveMonitoringConfig,
saveLogConfig, saveLogConfig,
saveCleanupConfig, saveCleanupConfig,
handleAutoCleanupToggle, handleAutoCleanupToggle,
-394
View File
@@ -1,394 +0,0 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">
用户统计
</h1>
<p class="text-xs text-muted-foreground">
查看用户排行榜与使用趋势
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
/>
<Select
v-model="selectedUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="选择用户" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="compareUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="对比用户(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
不对比
</SelectItem>
<SelectItem
v-for="user in users"
:key="`compare-${user.id}`"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LeaderboardTable
title="用户排行榜"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
@update:metric="metric = $event"
/>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">
用户摘要
</h3>
<div
v-if="summaryLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="grid grid-cols-2 gap-3 text-sm"
>
<div>
<div class="text-xs text-muted-foreground">
请求数
</div>
<div class="font-semibold">
{{ userSummary?.total_requests ?? 0 }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
Tokens
</div>
<div class="font-semibold">
{{ formatTokens(userSummary?.total_tokens ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
成本
</div>
<div class="font-semibold">
{{ formatCurrency(userSummary?.total_cost ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
错误率
</div>
<div class="font-semibold">
{{ userSummary?.error_rate ?? 0 }}%
</div>
</div>
</div>
</Card>
</div>
<Card class="p-4 space-y-4">
<h3 class="text-sm font-semibold">
用户使用趋势
</h3>
<div
v-if="seriesLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[280px]"
>
<LineChart :data="seriesChartData" />
</div>
</Card>
<Card
v-if="comparisonSeries.length > 0"
class="p-4 space-y-4"
>
<h3 class="text-sm font-semibold">
用户对比趋势
</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const users = ref<User[]>([])
const selectedUserId = ref<string | null>(null)
const compareUserId = ref<string>('__none__')
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)
interface UsageSummary {
total_requests: number
total_tokens: number
total_cost: number
error_rate: number
}
interface TimeSeriesItem {
date: string
total_cost: number
}
const userSummary = ref<UsageSummary | null>(null)
const summaryLoading = ref(false)
const series = ref<TimeSeriesItem[]>([])
const comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false)
let leaderboardRequestId = 0
let summaryRequestId = 0
let seriesRequestId = 0
let leaderboardLoadPromise: Promise<void> | null = null
let hasPendingLeaderboardLoad = false
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let userPanelsLoadPromise: Promise<void> | null = null
let hasPendingUserPanelsLoad = false
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
}
}
async function loadUsers() {
users.value = await usersApi.getAllUsers()
if (!selectedUserId.value && users.value.length > 0) {
selectedUserId.value = users.value[0].id
}
}
async function loadLeaderboard() {
if (leaderboardLoadPromise) {
hasPendingLeaderboardLoad = true
return leaderboardLoadPromise
}
leaderboardLoadPromise = (async () => {
const requestId = ++leaderboardRequestId
leaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
limit: 10
})
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items
} finally {
if (requestId === leaderboardRequestId) {
leaderboardLoading.value = false
}
}
})().finally(() => {
leaderboardLoadPromise = null
if (hasPendingLeaderboardLoad) {
hasPendingLeaderboardLoad = false
void loadLeaderboard()
}
})
return leaderboardLoadPromise
}
async function loadSummary() {
if (!selectedUserId.value) return
const requestId = ++summaryRequestId
summaryLoading.value = true
try {
const summary = await usageApi.getUsageStats({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
if (requestId !== summaryRequestId) return
userSummary.value = summary
} finally {
if (requestId === summaryRequestId) {
summaryLoading.value = false
}
}
}
async function loadSeries() {
if (!selectedUserId.value) return
const requestId = ++seriesRequestId
seriesLoading.value = true
try {
const baseParams = {
...buildTimeRangeParams(),
user_id: selectedUserId.value
}
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
? adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: compareUserId.value
})
: Promise.resolve([])
const [primarySeries, compareSeries] = await Promise.all([
adminApi.getTimeSeries(baseParams),
comparePromise
])
if (requestId !== seriesRequestId) return
series.value = primarySeries
comparisonSeries.value = compareSeries
} finally {
if (requestId === seriesRequestId) {
seriesLoading.value = false
}
}
}
async function loadUserPanels() {
if (userPanelsLoadPromise) {
hasPendingUserPanelsLoad = true
return userPanelsLoadPromise
}
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
.then(() => undefined)
.finally(() => {
userPanelsLoadPromise = null
if (hasPendingUserPanelsLoad) {
hasPendingUserPanelsLoad = false
void loadUserPanels()
}
})
return userPanelsLoadPromise
}
const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '成本',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}
]
}))
const comparisonChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '当前用户',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: '对比用户',
data: comparisonSeries.value.map(item => item.total_cost),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
}
]
}))
function scheduleLeaderboardLoad() {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
}
leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
void loadLeaderboard()
}, 120)
}
function scheduleUserPanelsLoad() {
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
}
userPanelsDebounceTimer = setTimeout(() => {
userPanelsDebounceTimer = null
void loadUserPanels()
}, 120)
}
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
onMounted(async () => {
await loadUsers()
await Promise.all([
loadLeaderboard(),
loadUserPanels()
])
})
onUnmounted(() => {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = null
}
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
userPanelsDebounceTimer = null
}
hasPendingLeaderboardLoad = false
hasPendingUserPanelsLoad = false
leaderboardLoadPromise = null
userPanelsLoadPromise = null
leaderboardRequestId += 1
summaryRequestId += 1
seriesRequestId += 1
})
</script>
+28 -5
View File
@@ -1019,7 +1019,7 @@ import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage' import { analyticsApi } from '@/api/analytics'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay' import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
@@ -1102,7 +1102,14 @@ const userApiKeyForm = ref({
}) })
// 用户统计 // 用户统计
const userStats = ref<Record<string, UsageByUser>>({}) interface UserUsageSummary {
user_id: string
request_count: number
total_tokens: number
total_cost: number
}
const userStats = ref<Record<string, UserUsageSummary>>({})
const loadingStats = ref(false) const loadingStats = ref(false)
let userStatsRequestId = 0 let userStatsRequestId = 0
const userWalletMap = ref<Record<string, AdminWallet>>({}) const userWalletMap = ref<Record<string, AdminWallet>>({})
@@ -1181,10 +1188,26 @@ async function loadUserStats() {
const requestId = ++userStatsRequestId const requestId = ++userStatsRequestId
loadingStats.value = true loadingStats.value = true
try { try {
const data = await usageApi.getUsageByUser() const response = await analyticsApi.getBreakdown({
scope: { kind: 'global' },
time_range: {
preset: 'last30days',
granularity: 'day',
timezone: 'UTC',
tz_offset_minutes: 0,
},
dimension: 'user',
metric: 'requests_total',
limit: 200,
})
if (requestId !== userStatsRequestId) return if (requestId !== userStatsRequestId) return
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => { userStats.value = response.rows.reduce((acc: Record<string, UserUsageSummary>, row) => {
acc[stat.user_id] = stat acc[row.key] = {
user_id: row.key,
request_count: row.requests_total,
total_tokens: row.total_tokens,
total_cost: row.total_cost_usd,
}
return acc return acc
}, {}) }, {})
} catch (err) { } catch (err) {
@@ -0,0 +1,496 @@
<template>
<div class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-muted-foreground">
固定按费用排序,点击{{ dimensionLabel }}名称即可在右侧查看同时间范围下的趋势。
</p>
<button
class="inline-flex h-8 items-center rounded-lg border border-border/70 bg-background px-3 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
@click="exportLeaderboard"
>
导出 CSV
</button>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,390px)_minmax(0,1fr)] 2xl:grid-cols-[minmax(0,420px)_minmax(0,1.18fr)]">
<LeaderboardTable
:title="leaderboardTitle"
:items="paginatedLeaderboard"
:selected-id="selectedId"
:loading="leaderboardLoading"
:unavailable="leaderboardUnavailable"
:has-loaded="leaderboardHasLoaded"
:current-page="leaderboardCurrentPage"
:total-items="leaderboard.length"
:page-size="LEADERBOARD_PAGE_SIZE"
:show-pagination="true"
@select="selectedId = $event"
@update:current-page="leaderboardCurrentPage = $event"
/>
<Card class="overflow-hidden">
<div class="border-b border-border/60 px-4 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
class="border-amber-500/35 bg-amber-500/8 text-[10px] text-amber-700 dark:text-amber-300"
>
{{ selectedItem ? `#${selectedItem.rank}` : '未选择' }}
</Badge>
<h3 class="truncate text-sm font-semibold">
{{ selectedLabel || `${dimensionLabel}趋势` }}
</h3>
</div>
<p class="mt-1 text-[11px] text-muted-foreground">
{{ trendLeadText }}
</p>
</div>
<Tabs
v-model="currentTrendGranularitySelection"
class="self-start"
>
<div class="flex flex-col items-start gap-2">
<div
v-if="isPanelRefreshing"
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
>
更新中
</div>
<TabsList class="tabs-button-list flex-wrap justify-start gap-1">
<TabsTrigger
v-for="option in trendGranularityTabStates"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
class="min-w-[38px] px-2 py-0.5 text-[10px]"
:class="option.disabled ? 'cursor-not-allowed opacity-40' : ''"
>
{{ option.label }}
</TabsTrigger>
</TabsList>
</div>
</Tabs>
</div>
<div
v-if="selectedSummary"
class="mt-4 grid grid-cols-2 gap-2 transition-opacity sm:grid-cols-4"
:class="{ 'opacity-60': isPanelRefreshing }"
>
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
请求数
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ selectedSummary.total_requests.toLocaleString() }}
</div>
</div>
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
总计 Tokens
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ formatTokens(selectedSummary.total_tokens) }}
</div>
</div>
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
费用
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ formatCurrency(selectedSummary.total_cost) }}
</div>
</div>
<div class="rounded-xl border border-border/60 bg-muted/15 px-3 py-2.5">
<div class="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
成本
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ formatCurrency(selectedSummary.total_actual_cost) }}
</div>
</div>
</div>
</div>
<LineChart
v-if="selectedItem && trendChartData"
:class="{ 'opacity-60 transition-opacity': isPanelRefreshing }"
:data="trendChartData"
:options="trendChartOptions"
:height="320"
/>
<div
v-else
class="flex h-[320px] items-center justify-center px-6 text-center text-xs text-muted-foreground"
>
{{ trendEmptyText }}
</div>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, toRef, watch } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import { Badge, Card, Tabs, TabsList, TabsTrigger } from '@/components/ui'
import { LeaderboardTable } from '@/components/stats'
import LineChart from '@/components/charts/LineChart.vue'
import { useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
import { useLeaderboardData, type LeaderboardDimension } from '@/composables/analytics'
import {
analyticsGranularityTabs,
formatAnalyticsTrendBucketLabel,
formatAnalyticsTrendDescription,
formatAnalyticsTrendTooltipTitle,
isAnalyticsSingleDayRange,
} from '@/utils/analyticsGranularity'
import { downloadCsvFromObjects } from '@/utils/csvExport'
import { formatCurrency, formatTokens } from '@/utils/format'
const props = defineProps<{
dimension: LeaderboardDimension
}>()
const dimension = toRef(props, 'dimension')
const filters = useInjectedAnalyticsFilters()
const {
leaderboard,
leaderboardLoading,
leaderboardError,
leaderboardHasLoaded,
selectedId,
selectedItem,
selectedLabel,
selectedSummary,
selectedTrendBuckets,
panelLoading,
panelError,
panelHasLoaded,
resolvedTrendGranularity,
currentTrendGranularitySelection,
} = useLeaderboardData(dimension)
const trendAxisTickCount = 6
const LEADERBOARD_PAGE_SIZE = 5
const leaderboardCurrentPage = ref(1)
const dimensionLabel = computed(() => (
props.dimension === 'users' ? '用户' : 'API Key'
))
const leaderboardTitle = computed(() => `${dimensionLabel.value}费用排行榜`)
const leaderboardUnavailable = computed(() => (
leaderboardError.value && !leaderboardHasLoaded.value
))
const leaderboardTotalPages = computed(() => (
Math.max(1, Math.ceil(leaderboard.value.length / LEADERBOARD_PAGE_SIZE))
))
const paginatedLeaderboard = computed(() => {
const start = (leaderboardCurrentPage.value - 1) * LEADERBOARD_PAGE_SIZE
return leaderboard.value.slice(start, start + LEADERBOARD_PAGE_SIZE)
})
const trendGranularityTabStates = computed(() => (
analyticsGranularityTabs.map(option => ({
...option,
disabled: option.value === 'hour' ? !isAnalyticsSingleDayRange(filters.timeRange.value) : false,
}))
))
const trendChartDescription = computed(() => (
formatAnalyticsTrendDescription(
resolvedTrendGranularity.value,
'请求量、费用、成本与总计 Tokens',
)
))
const trendLeadText = computed(() => {
if (leaderboardUnavailable.value) {
return `${dimensionLabel.value}排行榜接口未返回结果,请稍后重试。`
}
if (!selectedItem.value) {
return `从左侧选择${dimensionLabel.value}后,这里会显示与首页仪表盘一致的趋势视图。`
}
return `${selectedLabel.value} · ${trendChartDescription.value}`
})
const trendEmptyText = computed(() => {
if (leaderboardUnavailable.value) {
return `${dimensionLabel.value}排行榜暂不可用`
}
if (!selectedItem.value) {
return `请选择一个${dimensionLabel.value}查看趋势`
}
if (panelError.value && !panelHasLoaded.value) {
return `${dimensionLabel.value}趋势暂不可用`
}
return isInitialPanelLoading.value ? '加载中...' : '暂无趋势数据'
})
const isInitialPanelLoading = computed(() => (
panelLoading.value && !panelHasLoaded.value && selectedTrendBuckets.value.length === 0
))
const isPanelRefreshing = computed(() => (
panelLoading.value && panelHasLoaded.value && selectedTrendBuckets.value.length > 0
))
watch(
() => [
props.dimension,
filters.timeRange.value.preset,
filters.timeRange.value.start_date,
filters.timeRange.value.end_date,
],
() => {
leaderboardCurrentPage.value = 1
},
)
watch(
leaderboardTotalPages,
(totalPages) => {
if (leaderboardCurrentPage.value > totalPages) {
leaderboardCurrentPage.value = totalPages
}
},
)
watch(
[paginatedLeaderboard, selectedId],
([items, currentSelectedId]) => {
if (!items.length) return
if (!items.some(item => item.id === currentSelectedId)) {
selectedId.value = items[0].id
}
},
{ immediate: true },
)
function computeNiceAxisMax(maxValue: number, intervals: number): number {
if (!Number.isFinite(maxValue) || maxValue <= 0) return 1
const rawStep = maxValue / intervals
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
const normalized = rawStep / magnitude
const niceNormalized = normalized <= 1
? 1
: normalized <= 2
? 2
: normalized <= 2.5
? 2.5
: normalized <= 5
? 5
: 10
return niceNormalized * magnitude * intervals
}
const trendScaleMaxima = computed(() => {
const intervals = Math.max(trendAxisTickCount - 1, 1)
const requestMax = Math.max(0, ...selectedTrendBuckets.value.map(bucket => bucket.requests_total))
const tokenMax = Math.max(0, ...selectedTrendBuckets.value.map(bucket => bucket.total_tokens))
const costMax = Math.max(
0,
...selectedTrendBuckets.value.map(bucket => Math.max(bucket.total_cost_usd, bucket.actual_total_cost_usd)),
)
return {
requests: computeNiceAxisMax(requestMax, intervals),
tokens: computeNiceAxisMax(tokenMax, intervals),
cost: computeNiceAxisMax(costMax, intervals),
}
})
const trendChartData = computed<ChartData<'line'> | null>(() => {
if (!selectedTrendBuckets.value.length) return null
return {
labels: selectedTrendBuckets.value.map(bucket => (
formatAnalyticsTrendBucketLabel(bucket.bucket_start, resolvedTrendGranularity.value)
)),
datasets: [
{
label: '费用',
data: selectedTrendBuckets.value.map(bucket => bucket.total_cost_usd),
borderColor: 'rgb(232, 145, 89)',
backgroundColor: 'rgba(232, 145, 89, 0.12)',
borderWidth: 2.2,
fill: false,
tension: 0.28,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
yAxisID: 'y2',
},
{
label: '成本',
data: selectedTrendBuckets.value.map(bucket => bucket.actual_total_cost_usd),
borderColor: 'rgb(196, 92, 124)',
backgroundColor: 'rgba(196, 92, 124, 0.12)',
borderWidth: 2,
borderDash: [7, 5],
fill: false,
tension: 0.28,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
yAxisID: 'y2',
},
{
label: '总计 Tokens',
data: selectedTrendBuckets.value.map(bucket => bucket.total_tokens),
borderColor: 'rgb(103, 149, 230)',
backgroundColor: 'rgba(103, 149, 230, 0.16)',
borderWidth: 2.6,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
yAxisID: 'y1',
},
{
label: '请求数',
data: selectedTrendBuckets.value.map(bucket => bucket.requests_total),
borderColor: 'rgb(146, 126, 214)',
backgroundColor: 'rgba(146, 126, 214, 0.08)',
borderWidth: 2.2,
borderDash: [7, 5],
fill: false,
tension: 0.28,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
yAxisID: 'y',
},
],
}
})
const trendChartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
grid: {
color: 'rgba(156, 163, 175, 0.05)',
},
ticks: {
color: 'rgb(92, 98, 109)',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: resolvedTrendGranularity.value === 'hour' ? 12 : undefined,
},
},
y: {
type: 'linear',
display: true,
position: 'left',
min: 0,
max: trendScaleMaxima.value.requests,
beginAtZero: true,
alignToPixels: true,
bounds: 'ticks',
grid: {
color: 'rgba(156, 163, 175, 0.05)',
},
ticks: {
color: 'rgb(92, 98, 109)',
count: trendAxisTickCount,
},
},
y1: {
type: 'linear',
display: true,
position: 'right',
min: 0,
max: trendScaleMaxima.value.tokens,
beginAtZero: true,
alignToPixels: true,
bounds: 'ticks',
grid: {
drawOnChartArea: false,
},
ticks: {
color: 'rgb(92, 98, 109)',
count: trendAxisTickCount,
callback: value => {
const tokenValue = Number(value)
const tokenMax = trendScaleMaxima.value.tokens
const costMax = trendScaleMaxima.value.cost
const ratio = tokenMax > 0 ? tokenValue / tokenMax : 0
const costValue = ratio * costMax
return [formatTokens(tokenValue), formatCurrency(costValue)]
},
},
},
y2: {
type: 'linear',
display: false,
position: 'right',
min: 0,
max: trendScaleMaxima.value.cost,
beginAtZero: true,
alignToPixels: true,
bounds: 'ticks',
grid: {
drawOnChartArea: false,
},
ticks: {
color: 'rgb(92, 98, 109)',
count: trendAxisTickCount,
callback: value => formatCurrency(Number(value)),
},
},
},
plugins: {
legend: {
labels: {
usePointStyle: true,
},
},
tooltip: {
callbacks: {
title: items => {
const bucket = selectedTrendBuckets.value[items[0]?.dataIndex ?? -1]
if (!bucket) return ''
return formatAnalyticsTrendTooltipTitle(bucket.bucket_start, resolvedTrendGranularity.value)
},
label: context => {
const value = Number(context.raw ?? 0)
if (context.dataset.label === '总计 Tokens') {
return `${context.dataset.label}: ${formatTokens(value)}`
}
if (context.dataset.label === '费用' || context.dataset.label === '成本') {
return `${context.dataset.label}: ${formatCurrency(value)}`
}
return `${context.dataset.label}: ${value.toLocaleString()}`
},
},
},
},
}))
function exportLeaderboard() {
if (!leaderboard.value.length) return
downloadCsvFromObjects(
`leaderboard-${props.dimension}-${new Date().toISOString().slice(0, 10)}`,
leaderboard.value as unknown as Record<string, unknown>[],
[
{ key: 'rank', label: '排名' },
{ key: 'name', label: '名称' },
{ key: 'requests', label: '请求数' },
{ key: 'tokens', label: 'Tokens' },
{ key: 'cost', label: '费用' },
{ key: 'actualCost', label: '成本' },
],
)
}
</script>
@@ -0,0 +1,921 @@
<template>
<div class="space-y-5">
<div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-medium">
系统资源监控
</h3>
<p class="mt-1 text-[11px] text-muted-foreground">
6 项核心资源的实时压力指数,越满表示越紧张
</p>
</div>
<div
v-if="systemStatusTimestamp"
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
>
{{ systemStatusTimestamp }}
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Card
v-for="item in systemMetricCards"
:key="item.key"
class="min-h-[148px] border-border/60 bg-card p-3.5"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<div class="flex items-start justify-between gap-2.5">
<div class="min-w-0">
<div class="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/85">
{{ item.label }}
</div>
<div class="mt-1.5 text-xl font-semibold tracking-tight text-foreground">
{{ item.value }}
</div>
<div class="mt-1 line-clamp-2 text-[10px] text-muted-foreground">
{{ item.detail }}
</div>
</div>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border"
:class="item.iconClass"
>
<component :is="item.icon" class="h-3.5 w-3.5" />
</div>
</div>
<div class="mt-2.5 space-y-2">
<div class="flex items-center justify-between gap-2">
<span class="min-w-0 truncate text-[10px] text-muted-foreground">
{{ item.meta }}
</span>
<span
class="inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium"
:class="item.badgeClass"
>
{{ item.badge }}
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full transition-all"
:class="item.progressClass"
:style="{ width: item.progressWidth }"
/>
</div>
<div class="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span>{{ item.caption }}</span>
<span class="tabular-nums">{{ item.captionValue }}</span>
</div>
</div>
</Card>
</div>
</div>
<div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-medium">
运行概览
</h3>
<p class="mt-1 text-[11px] text-muted-foreground">
汇总健康状态、今日负载与当前风险信号
</p>
</div>
<div
v-if="isRefreshing"
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
>
更新中
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Card
v-for="item in statusCards"
:key="item.key"
class="min-h-[138px] border-border/60 bg-card p-4"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/85">
{{ item.label }}
</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-foreground">
{{ item.value }}
</div>
<div class="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">
{{ item.detail }}
</div>
</div>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border"
:class="item.iconClass"
>
<component :is="item.icon" class="h-4 w-4" />
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/50 pt-3">
<span class="min-w-0 truncate text-[11px] text-muted-foreground">
{{ item.caption }}
</span>
<span
class="inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium"
:class="item.badgeClass"
>
{{ item.badge }}
</span>
</div>
</Card>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card class="overflow-hidden border-border/60 bg-card p-4">
<div :class="{ 'opacity-60 transition-opacity': isRefreshing }">
<PercentileChart
title="响应延迟百分位"
subtitle="观察 P50 / P90 / P99 响应耗时的分布变化"
:series="percentiles"
mode="response"
:loading="isInitialLoading"
:unavailable="performanceUnavailable"
unavailable-text="性能数据暂不可用"
/>
</div>
</Card>
<Card class="overflow-hidden border-border/60 bg-card p-4">
<div :class="{ 'opacity-60 transition-opacity': isRefreshing }">
<PercentileChart
title="首字节延迟百分位"
subtitle="聚焦首包返回速度,判断链路是否出现抖动"
:series="percentiles"
mode="ttfb"
:loading="isInitialLoading"
:unavailable="performanceUnavailable"
unavailable-text="性能数据暂不可用"
/>
</div>
</Card>
<Card class="overflow-hidden border-border/60 bg-card p-4">
<div :class="{ 'opacity-60 transition-opacity': isRefreshing }">
<ErrorDistributionChart
title="错误分布"
subtitle="按错误类别观察主要失败来源"
:distribution="errorDistribution"
:loading="isInitialLoading"
:unavailable="performanceUnavailable"
unavailable-text="性能数据暂不可用"
/>
</div>
</Card>
<Card class="overflow-hidden border-border/60 bg-card p-4">
<div class="space-y-4" :class="{ 'opacity-60 transition-opacity': isRefreshing }">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
错误趋势
</h3>
<p class="mt-1 text-[11px] text-muted-foreground">
观察错误量在当前时间范围内的波动与峰值
</p>
</div>
<div
v-if="!performanceUnavailable"
class="flex flex-wrap gap-2"
>
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
总错误 {{ errorTotalCount }}
</div>
<div
v-if="errorPeakLabel"
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
>
峰值 {{ errorPeakLabel }}
</div>
</div>
</div>
<div
v-if="isInitialLoading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else-if="performanceUnavailable"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
性能数据暂不可用
</div>
<div
v-else-if="errorTrendChartData"
class="rounded-2xl border border-border/60 bg-background p-3"
>
<LineChart
:data="errorTrendChartData"
:options="errorTrendChartOptions"
:height="248"
/>
</div>
<div
v-else
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
暂无错误趋势数据
</div>
</div>
</Card>
</div>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-medium">
提供商健康度
</h3>
<div
v-if="isRefreshing"
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
>
更新中
</div>
</div>
<div
v-if="performanceUnavailable"
class="text-xs text-muted-foreground"
>
性能数据暂不可用
</div>
<div
v-else-if="providerStatus.length === 0 && !loading"
class="text-xs text-muted-foreground"
>
暂无提供商状态数据
</div>
<div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-5">
<Card
v-for="provider in providerStatus"
:key="provider.provider_name"
class="space-y-2 border-border/60 bg-card p-3"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="truncate text-xs font-semibold" :title="provider.provider_name">
{{ provider.provider_name }}
</div>
<div class="mt-1 text-[10px] text-muted-foreground tabular-nums">
{{ provider.requests_total?.toLocaleString() ?? 0 }} 请求
</div>
</div>
<Badge
:variant="provider.error_rate > 5 ? 'destructive' : provider.success_rate >= 98 ? 'success' : 'warning'"
class="h-5 px-1.5 text-[9px]"
>
{{ provider.error_rate > 5 ? '异常' : provider.success_rate >= 98 ? '稳定' : '波动' }}
</Badge>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-[10px] text-muted-foreground">
<div>
成功率
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
{{ provider.success_rate.toFixed(1) }}%
</div>
</div>
<div>
错误率
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
{{ provider.error_rate.toFixed(1) }}%
</div>
</div>
<div>
响应
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
{{ formatLatency(provider.avg_response_time_ms) }}
</div>
</div>
<div>
TTFB
<div class="mt-0.5 font-semibold text-foreground tabular-nums">
{{ formatLatency(provider.avg_first_byte_time_ms) }}
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import { Activity, AlertTriangle, Cpu, Database, Gauge, HardDrive, Key } from 'lucide-vue-next'
import type { MonitoringMetricStatus } from '@/api/audit'
import { Badge, Card } from '@/components/ui'
import { LoadingState } from '@/components/common'
import { PercentileChart, ErrorDistributionChart } from '@/components/stats'
import LineChart from '@/components/charts/LineChart.vue'
import { usePerformanceData } from '@/composables/analytics'
import { formatCurrency, formatTokens } from '@/utils/format'
const {
latency,
percentiles,
errorDistribution,
errorTrend,
errorTotal: errorTotalCount,
errorRate,
providerStatus,
healthSummary,
systemStatus,
loading,
loadError,
hasLoaded,
} = usePerformanceData()
const isInitialLoading = computed(() => loading.value && !hasLoaded.value)
const isRefreshing = computed(() => loading.value && hasLoaded.value)
const performanceUnavailable = computed(() => loadError.value && !hasLoaded.value)
const errorPeakPoint = computed(() => (
errorTrend.value.slice().sort((left, right) => right.total - left.total)[0] ?? null
))
const errorPeakLabel = computed(() => (
errorPeakPoint.value && errorPeakPoint.value.total > 0
? `${errorPeakPoint.value.date.slice(5)} · ${errorPeakPoint.value.total}`
: null
))
function formatLatency(value: number) {
if (!value || value <= 0) return '-'
if (value < 1000) return `${Math.round(value)}ms`
return `${(value / 1000).toFixed(2)}s`
}
function formatCount(value: number | null | undefined, ready: boolean) {
return ready && value != null ? value.toLocaleString() : '--'
}
function formatRatio(active: number | null | undefined, total: number | null | undefined, ready: boolean) {
return ready && active != null && total != null ? `${active}/${total}` : '--'
}
type StatusTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
interface StatusCardItem {
key: string
label: string
value: string
detail: string
caption: string
badge: string
badgeClass: string
iconClass: string
icon: Component
}
interface SystemMetricCardItem {
key: string
label: string
value: string
detail: string
meta: string
caption: string
captionValue: string
badge: string
badgeClass: string
iconClass: string
progressClass: string
progressWidth: string
icon: Component
}
function toneStyle(tone: StatusTone) {
switch (tone) {
case 'success':
return {
badgeClass: 'border-emerald-500/20 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300',
iconClass: 'border-emerald-500/20 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300',
}
case 'warning':
return {
badgeClass: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
iconClass: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
}
case 'danger':
return {
badgeClass: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
iconClass: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
}
case 'info':
return {
badgeClass: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
iconClass: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
}
default:
return {
badgeClass: 'border-border/70 bg-muted/20 text-muted-foreground',
iconClass: 'border-border/70 bg-muted/20 text-muted-foreground',
}
}
}
function toneProgressClass(tone: StatusTone) {
switch (tone) {
case 'success':
return 'bg-emerald-500/80'
case 'warning':
return 'bg-amber-500/80'
case 'danger':
return 'bg-rose-500/80'
case 'info':
return 'bg-sky-500/80'
default:
return 'bg-muted-foreground/40'
}
}
function healthTone(unhealthy: number, total: number): StatusTone {
if (total <= 0) return 'neutral'
if (unhealthy <= 0) return 'success'
if (unhealthy / total < 0.25) return 'warning'
return 'danger'
}
function availabilityTone(active: number, total: number): StatusTone {
if (total <= 0) return 'neutral'
if (active === total) return 'success'
if (active > 0) return 'warning'
return 'danger'
}
function latencyTone(avgMs: number): StatusTone {
if (!avgMs || avgMs <= 0) return 'neutral'
if (avgMs < 1500) return 'success'
if (avgMs < 3000) return 'warning'
return 'danger'
}
function monitoringTone(status: MonitoringMetricStatus | undefined): StatusTone {
switch (status) {
case 'ok':
return 'success'
case 'warning':
case 'degraded':
return 'warning'
case 'danger':
case 'error':
return 'danger'
default:
return 'neutral'
}
}
function formatPercent(value: number | null | undefined) {
return value == null ? '--' : `${value.toFixed(1)}%`
}
function formatCompactCount(value: number | null | undefined) {
return value == null ? '--' : value.toLocaleString()
}
function formatBytes(value: number | null | undefined) {
if (value == null) return '--'
if (value === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let current = value
let unitIndex = 0
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024
unitIndex += 1
}
const digits = current >= 100 ? 0 : current >= 10 ? 1 : 2
return `${current.toFixed(digits)} ${units[unitIndex]}`
}
function formatTimeLabel(timestamp: string | null | undefined) {
if (!timestamp) return null
return `更新 ${new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}`
}
function clampPercent(value: number | null | undefined) {
if (value == null) return null
return Math.max(0, Math.min(100, value))
}
function progressWidth(value: number | null | undefined) {
const safeValue = clampPercent(value)
const width = safeValue == null ? 12 : Math.max(6, safeValue)
return `${width}%`
}
function remainingToPressurePercent(value: number | null | undefined) {
const safeValue = clampPercent(value)
return safeValue == null ? null : Math.max(0, 100 - safeValue)
}
function formatPressureValue(value: number | null | undefined) {
return formatPercent(clampPercent(value))
}
function latencyPressureValue(latencyMs: number | null | undefined, status: MonitoringMetricStatus | undefined) {
if (latencyMs == null) {
return statusPressureValue(status)
}
if (latencyMs >= 200) return 100
if (latencyMs >= 120) return 88
if (latencyMs >= 80) return 72
if (latencyMs >= 40) return 34
if (latencyMs >= 20) return 24
return 16
}
function statusPressureValue(status: MonitoringMetricStatus | undefined) {
switch (status) {
case 'ok':
return 16
case 'warning':
return 72
case 'degraded':
return 82
case 'danger':
return 92
case 'error':
return 100
default:
return null
}
}
const PRESSURE_CAPTION = '压力指数'
function pressureMetric(value: number | null | undefined) {
return clampPercent(value)
}
function storagePressureMetric(value: number | null | undefined) {
return remainingToPressurePercent(value)
}
function fallbackPressureWidth() {
return progressWidth(null)
}
function fallbackPressureCaptionValue() {
return '--'
}
const statusCards = computed<StatusCardItem[]>(() => {
if (performanceUnavailable.value) {
return [
{ key: 'endpoint-health', label: 'Endpoint 健康', icon: Database },
{ key: 'key-health', label: 'Key 健康', icon: Key },
{ key: 'provider-status', label: 'Provider 在线', icon: Cpu },
{ key: 'today-traffic', label: '今日流量', icon: Activity },
{ key: 'recent-alerts', label: '近 1h 告警', icon: AlertTriangle },
{ key: 'latency', label: '平均延迟', icon: Gauge },
].map(item => ({
...item,
value: '不可用',
detail: '性能接口未返回结果',
caption: '请稍后重试',
badge: '不可用',
...toneStyle('neutral'),
}))
}
const healthReady = healthSummary.value !== null
const systemReady = systemStatus.value !== null
const endpointTotal = healthSummary.value?.endpoints.total ?? 0
const endpointActive = healthSummary.value?.endpoints.active ?? 0
const endpointUnhealthy = healthSummary.value?.endpoints.unhealthy ?? 0
const endpointTone = toneStyle(healthTone(endpointUnhealthy, endpointTotal))
const keyTotal = healthSummary.value?.keys.total ?? systemStatus.value?.api_keys.total ?? 0
const keyActive = healthSummary.value?.keys.active ?? systemStatus.value?.api_keys.active ?? 0
const keyUnhealthy = healthSummary.value?.keys.unhealthy ?? Math.max(keyTotal - keyActive, 0)
const keyCircuitOpen = healthSummary.value?.keys.circuit_open ?? 0
const keyTone = toneStyle(healthTone(keyUnhealthy, keyTotal))
const providerTotal = systemStatus.value?.providers.total ?? 0
const providerActive = systemStatus.value?.providers.active ?? 0
const userActive = systemStatus.value?.users.active ?? 0
const userTotal = systemStatus.value?.users.total ?? 0
const providerTone = toneStyle(availabilityTone(providerActive, providerTotal))
const todayRequests = systemStatus.value?.today_stats.requests ?? 0
const todayTokens = systemStatus.value?.today_stats.tokens ?? 0
const todayCost = systemStatus.value?.today_stats.cost_usd ?? 0
const throughputTone = toneStyle(todayRequests > 0 ? 'info' : 'neutral')
const recentAlerts = systemStatus.value?.recent_errors ?? 0
const alertsTone = toneStyle(recentAlerts <= 0 && errorRate.value < 2 ? 'success' : recentAlerts < 5 && errorRate.value < 5 ? 'warning' : 'danger')
const responseAvg = latency.value.response_time_ms.avg ?? 0
const ttfbAvg = latency.value.first_byte_time_ms.avg ?? 0
const latencyStyle = toneStyle(latencyTone(responseAvg))
return [
{
key: 'endpoint-health',
label: 'Endpoint 健康',
value: formatRatio(endpointActive, endpointTotal, healthReady),
detail: healthReady ? `异常端点 ${endpointUnhealthy}` : '健康摘要载入中',
caption: healthReady ? '当前可用端点覆盖' : '等待健康摘要返回',
badge: healthReady ? (endpointUnhealthy > 0 ? '关注中' : '稳定') : '载入中',
...endpointTone,
icon: Database,
},
{
key: 'key-health',
label: 'Key 健康',
value: formatRatio(keyActive, keyTotal, healthReady || systemReady),
detail: healthReady ? `异常 Key ${keyUnhealthy} · 熔断 ${keyCircuitOpen}` : '健康摘要载入中',
caption: healthReady ? '密钥池可用状态' : '等待健康摘要返回',
badge: healthReady ? (keyUnhealthy > 0 || keyCircuitOpen > 0 ? '波动' : '健康') : '载入中',
...keyTone,
icon: Key,
},
{
key: 'provider-status',
label: 'Provider 在线',
value: formatRatio(providerActive, providerTotal, systemReady),
detail: systemReady ? `活跃用户 ${userActive}/${userTotal}` : '系统状态载入中',
caption: systemReady ? '提供商接入状态' : '等待系统状态返回',
badge: systemReady ? (providerActive === providerTotal ? '在线' : '需关注') : '载入中',
...providerTone,
icon: Cpu,
},
{
key: 'today-traffic',
label: '今日流量',
value: formatCount(todayRequests, systemReady),
detail: systemReady ? `Tokens ${formatTokens(todayTokens)}` : '系统状态载入中',
caption: systemReady ? `成本 ${formatCurrency(todayCost)}` : '等待系统状态返回',
badge: systemReady ? (todayRequests > 0 ? '活跃' : '空闲') : '载入中',
...throughputTone,
icon: Activity,
},
{
key: 'recent-alerts',
label: '近 1h 告警',
value: formatCount(recentAlerts, systemReady),
detail: `当前范围失败 ${errorTotalCount.value.toLocaleString()}`,
caption: `错误率 ${errorRate.value.toFixed(1)}%`,
badge: systemReady
? recentAlerts <= 0 && errorRate.value < 2 ? '低风险' : recentAlerts < 5 && errorRate.value < 5 ? '需关注' : '偏高'
: '载入中',
...alertsTone,
icon: AlertTriangle,
},
{
key: 'latency-baseline',
label: '延迟基线',
value: formatLatency(responseAvg),
detail: `TTFB ${formatLatency(ttfbAvg)}`,
caption: `P50 ${formatLatency(latency.value.response_time_ms.p50 ?? 0)}`,
badge: responseAvg > 0 ? (responseAvg < 1500 ? '顺畅' : responseAvg < 3000 ? '一般' : '偏慢') : '无数据',
...latencyStyle,
icon: Gauge,
},
]
})
const systemStatusTimestamp = computed(() => formatTimeLabel(systemStatus.value?.timestamp))
const systemMetricCards = computed<SystemMetricCardItem[]>(() => {
const metrics = systemStatus.value?.system_metrics
if (!metrics) {
const detail = systemStatus.value ? 'system_metrics 未返回' : '系统状态载入中'
const tone = toneStyle('neutral')
const progressClass = toneProgressClass('neutral')
return [
{ key: 'cpu', label: 'CPU', icon: Cpu },
{ key: 'memory', label: '内存', icon: HardDrive },
{ key: 'redis-latency', label: 'Redis', icon: Activity },
{ key: 'redis-memory', label: 'Redis 内存', icon: HardDrive },
{ key: 'postgres-pool', label: 'PostgreSQL', icon: Database },
{ key: 'postgres-storage', label: 'PostgreSQL 空间', icon: HardDrive },
].map(item => ({
...item,
value: '--',
detail,
meta: '等待监控数据',
caption: PRESSURE_CAPTION,
captionValue: fallbackPressureCaptionValue(),
badge: '未知',
progressWidth: fallbackPressureWidth(),
progressClass,
...tone,
}))
}
const cpuTone = monitoringTone(metrics.cpu.status)
const memoryTone = monitoringTone(metrics.memory.status)
const redisTone = monitoringTone(metrics.redis.status)
const redisMemoryTone = monitoringTone(metrics.redis.memory_status ?? metrics.redis.status)
const postgresTone = monitoringTone(metrics.postgres.status)
const postgresStorageTone = monitoringTone(metrics.postgres.storage_status ?? metrics.postgres.status)
const redisMemoryCeiling = metrics.redis.memory_ceiling_bytes ?? metrics.redis.maxmemory_bytes ?? null
const redisMemorySource = metrics.redis.memory_source ?? 'unknown'
const redisMemorySourceLabel = redisMemorySource === 'configured'
? '手动容量'
: redisMemorySource === 'maxmemory'
? '上限'
: redisMemorySource === 'system'
? '估算上限'
: '未配置容量'
const cpuPressure = pressureMetric(metrics.cpu.usage_percent ?? metrics.cpu.load_percent)
const memoryPressure = pressureMetric(metrics.memory.used_percent)
const redisHealthPressure = latencyPressureValue(metrics.redis.latency_ms, metrics.redis.status)
const redisMemoryPressure = pressureMetric(metrics.redis.memory_percent ?? statusPressureValue(metrics.redis.memory_status))
const postgresPoolPressure = pressureMetric(
metrics.postgres.usage_percent ?? metrics.postgres.pool_usage_percent ?? metrics.postgres.server_usage_percent,
)
const postgresStoragePressure = storagePressureMetric(metrics.postgres.storage_free_percent)
return [
{
key: 'cpu',
label: 'CPU',
value: formatPercent(metrics.cpu.usage_percent ?? metrics.cpu.load_percent),
detail: metrics.cpu.usage_percent != null
? `实时占用 · Load ${formatPercent(metrics.cpu.load_percent)}`
: (metrics.cpu.message ?? 'CPU 监控数据不可用'),
meta: metrics.cpu.core_count > 0 ? `${metrics.cpu.core_count} 核` : '核心数未知',
caption: PRESSURE_CAPTION,
captionValue: formatPressureValue(cpuPressure),
badge: metrics.cpu.label,
progressWidth: progressWidth(cpuPressure),
progressClass: toneProgressClass(cpuTone),
...toneStyle(cpuTone),
icon: Cpu,
},
{
key: 'memory',
label: '内存',
value: formatPercent(metrics.memory.used_percent),
detail: metrics.memory.total_bytes != null
? `${formatBytes(metrics.memory.used_bytes)} / ${formatBytes(metrics.memory.total_bytes)}`
: (metrics.memory.message ?? '内存监控数据不可用'),
meta: metrics.memory.available_bytes != null ? `可用 ${formatBytes(metrics.memory.available_bytes)}` : '等待系统指标',
caption: PRESSURE_CAPTION,
captionValue: formatPressureValue(memoryPressure),
badge: metrics.memory.label,
progressWidth: progressWidth(memoryPressure),
progressClass: toneProgressClass(memoryTone),
...toneStyle(memoryTone),
icon: HardDrive,
},
{
key: 'redis-latency',
label: 'Redis',
value: metrics.redis.latency_ms != null ? formatLatency(metrics.redis.latency_ms) : metrics.redis.label,
detail: metrics.redis.message ?? '缓存链路实时探测',
meta: metrics.redis.latency_ms != null ? 'PING 延迟' : '连接状态',
caption: PRESSURE_CAPTION,
captionValue: formatPressureValue(redisHealthPressure),
badge: metrics.redis.label,
progressWidth: progressWidth(redisHealthPressure),
progressClass: toneProgressClass(redisTone),
...toneStyle(redisTone),
icon: Activity,
},
{
key: 'redis-memory',
label: 'Redis 内存',
value: formatBytes(metrics.redis.used_memory_bytes),
detail: metrics.redis.peak_memory_bytes != null
? `峰值 ${formatBytes(metrics.redis.peak_memory_bytes)}`
: (redisMemorySource === 'unknown' ? '可在系统设置中手动填写 Redis 总内存' : '等待 Redis 内存指标'),
meta: redisMemoryCeiling != null
? `${redisMemorySourceLabel} ${formatBytes(redisMemoryCeiling)}`
: '未提供内存上限',
caption: PRESSURE_CAPTION,
captionValue: formatPressureValue(redisMemoryPressure),
badge: metrics.redis.memory_label ?? metrics.redis.label,
progressWidth: progressWidth(redisMemoryPressure),
progressClass: toneProgressClass(redisMemoryTone),
...toneStyle(redisMemoryTone),
icon: HardDrive,
},
{
key: 'postgres-pool',
label: 'PostgreSQL',
value: formatPercent(metrics.postgres.usage_percent),
detail: metrics.postgres.message
?? (metrics.postgres.server_connections != null && metrics.postgres.server_max_connections != null
? `服务端 ${formatCompactCount(metrics.postgres.server_connections)} / ${formatCompactCount(metrics.postgres.server_max_connections)} 连接`
: `${formatCompactCount(metrics.postgres.checked_out)} / ${formatCompactCount(metrics.postgres.max_capacity)} 连接占用`),
meta: metrics.postgres.server_connections != null && metrics.postgres.server_max_connections != null
? `应用池 ${formatCompactCount(metrics.postgres.checked_out)} / ${formatCompactCount(metrics.postgres.max_capacity)}`
: `池 ${formatCompactCount(metrics.postgres.pool_size)} · 溢出 ${formatCompactCount(metrics.postgres.overflow)}`,
caption: PRESSURE_CAPTION,
captionValue: formatPressureValue(postgresPoolPressure),
badge: metrics.postgres.label,
progressWidth: progressWidth(postgresPoolPressure),
progressClass: toneProgressClass(postgresTone),
...toneStyle(postgresTone),
icon: Database,
},
{
key: 'postgres-storage',
label: 'PostgreSQL 空间',
value: formatBytes(metrics.postgres.storage_free_bytes),
detail: metrics.postgres.database_size_bytes != null
? `库体积 ${formatBytes(metrics.postgres.database_size_bytes)}`
: (metrics.postgres.storage_message ?? '等待 PostgreSQL 空间指标'),
meta: metrics.postgres.storage_total_bytes != null
? `总容量 ${formatBytes(metrics.postgres.storage_total_bytes)}`
: (metrics.postgres.storage_message ?? '数据目录总量未知'),
caption: PRESSURE_CAPTION,
captionValue: formatPressureValue(postgresStoragePressure),
badge: metrics.postgres.storage_label ?? metrics.postgres.label,
progressWidth: progressWidth(postgresStoragePressure),
progressClass: toneProgressClass(postgresStorageTone),
...toneStyle(postgresStorageTone),
icon: HardDrive,
},
]
})
const errorTrendChartData = computed<ChartData<'line'> | null>(() => {
if (!errorTrend.value.length) return null
return {
labels: errorTrend.value.map(d => d.date?.slice(5) ?? ''),
datasets: [{
label: '错误数',
data: errorTrend.value.map(d => d.total),
borderColor: 'rgb(226, 85, 118)',
backgroundColor: 'rgba(226, 85, 118, 0.16)',
fill: true,
tension: 0.34,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
borderWidth: 2.4,
}],
}
})
const errorTrendChartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
title: items => errorTrend.value[items[0]?.dataIndex ?? -1]?.date ?? '',
label: context => `错误数: ${Number(context.raw ?? 0).toLocaleString()}`,
},
},
},
scales: {
x: {
grid: {
color: 'rgba(148, 163, 184, 0.06)',
},
ticks: {
color: 'rgb(100, 116, 139)',
maxRotation: 0,
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.07)',
},
ticks: {
color: 'rgb(100, 116, 139)',
precision: 0,
},
},
},
}))
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
<template>
<CardSection
title="监控容量"
description="为远程或托管 Redis / PostgreSQL 手动填写总容量,便于监控面板计算剩余空间"
>
<template #actions>
<Button
size="sm"
:disabled="loading || !hasChanges"
@click="$emit('save')"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="redis-memory-total-gb"
class="block text-sm font-medium"
>
Redis 总内存 (GB)
</Label>
<Input
id="redis-memory-total-gb"
:model-value="redisMemoryTotalGB"
type="number"
step="0.1"
min="0"
placeholder="0"
class="mt-1"
@update:model-value="$emit('update:redisMemoryTotalGB', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
用于远程 Redis 未上报完整容量时计算剩余内存,0 表示继续自动探测
</p>
</div>
<div>
<Label
for="postgres-storage-total-gb"
class="block text-sm font-medium"
>
PostgreSQL 总空间 (GB)
</Label>
<Input
id="postgres-storage-total-gb"
:model-value="postgresStorageTotalGB"
type="number"
step="0.1"
min="0"
placeholder="0"
class="mt-1"
@update:model-value="$emit('update:postgresStorageTotalGB', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
监控面板会用“总空间 - 当前数据库体积”估算剩余空间,0 表示未设置
</p>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { CardSection } from '@/components/layout'
defineProps<{
redisMemoryTotalGB: number
postgresStorageTotalGB: number
loading: boolean
hasChanges: boolean
}>()
defineEmits<{
save: []
'update:redisMemoryTotalGB': [value: number]
'update:postgresStorageTotalGB': [value: number]
}>()
</script>
@@ -19,6 +19,9 @@ export interface SystemConfig {
auto_delete_expired_keys: boolean auto_delete_expired_keys: boolean
// 格式转换 // 格式转换
enable_format_conversion: boolean enable_format_conversion: boolean
// 监控容量
redis_memory_total_bytes: number
postgres_storage_total_bytes: number
// 请求记录 // 请求记录
request_record_level: string request_record_level: string
max_request_body_size: number max_request_body_size: number
@@ -55,6 +58,9 @@ const CONFIG_KEYS = [
'auto_delete_expired_keys', 'auto_delete_expired_keys',
// 格式转换 // 格式转换
'enable_format_conversion', 'enable_format_conversion',
// 监控容量
'redis_memory_total_bytes',
'postgres_storage_total_bytes',
// 请求记录 // 请求记录
'request_record_level', 'request_record_level',
'max_request_body_size', 'max_request_body_size',
@@ -92,6 +98,9 @@ function createDefaultConfig(): SystemConfig {
auto_delete_expired_keys: false, auto_delete_expired_keys: false,
// 格式转换 // 格式转换
enable_format_conversion: false, enable_format_conversion: false,
// 监控容量
redis_memory_total_bytes: 0,
postgres_storage_total_bytes: 0,
// 请求记录 // 请求记录
request_record_level: 'basic', request_record_level: 'basic',
max_request_body_size: 1048576, max_request_body_size: 1048576,
@@ -114,6 +123,18 @@ function createDefaultConfig(): SystemConfig {
} }
} }
const BYTES_PER_GB = 1024 * 1024 * 1024
function toBytesFromGB(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.round(value * BYTES_PER_GB)
}
function toGBFromBytes(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.round((value / BYTES_PER_GB) * 100) / 100
}
export function useSystemConfig() { export function useSystemConfig() {
const { success, error } = useToast() const { success, error } = useToast()
const { refreshSiteInfo } = useSiteInfo() const { refreshSiteInfo } = useSiteInfo()
@@ -126,6 +147,7 @@ export function useSystemConfig() {
const siteInfoLoading = ref(false) const siteInfoLoading = ref(false)
const proxyConfigLoading = ref(false) const proxyConfigLoading = ref(false)
const basicConfigLoading = ref(false) const basicConfigLoading = ref(false)
const monitoringConfigLoading = ref(false)
const logConfigLoading = ref(false) const logConfigLoading = ref(false)
const cleanupConfigLoading = ref(false) const cleanupConfigLoading = ref(false)
@@ -166,6 +188,14 @@ export function useSystemConfig() {
) )
}) })
const hasMonitoringConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.redis_memory_total_bytes !== originalConfig.value.redis_memory_total_bytes ||
systemConfig.value.postgres_storage_total_bytes !== originalConfig.value.postgres_storage_total_bytes
)
})
const hasCleanupConfigChanges = computed(() => { const hasCleanupConfigChanges = computed(() => {
if (!originalConfig.value) return false if (!originalConfig.value) return false
return ( return (
@@ -200,6 +230,20 @@ export function useSystemConfig() {
}, },
}) })
const redisMemoryTotalGB = computed({
get: () => toGBFromBytes(systemConfig.value.redis_memory_total_bytes),
set: (val: number) => {
systemConfig.value.redis_memory_total_bytes = toBytesFromGB(val)
},
})
const postgresStorageTotalGB = computed({
get: () => toGBFromBytes(systemConfig.value.postgres_storage_total_bytes),
set: (val: number) => {
systemConfig.value.postgres_storage_total_bytes = toBytesFromGB(val)
},
})
// 敏感请求头数组和字符串之间的转换 // 敏感请求头数组和字符串之间的转换
const sensitiveHeadersStr = computed({ const sensitiveHeadersStr = computed({
get: () => systemConfig.value.sensitive_headers.join(', '), get: () => systemConfig.value.sensitive_headers.join(', '),
@@ -397,6 +441,40 @@ export function useSystemConfig() {
} }
} }
async function saveMonitoringConfig() {
monitoringConfigLoading.value = true
try {
const configItems = [
{
key: 'redis_memory_total_bytes',
value: systemConfig.value.redis_memory_total_bytes,
description: 'Redis 总内存容量(字节)',
},
{
key: 'postgres_storage_total_bytes',
value: systemConfig.value.postgres_storage_total_bytes,
description: 'PostgreSQL 总存储空间(字节)',
},
]
await Promise.all(
configItems.map((item) =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
if (originalConfig.value) {
originalConfig.value.redis_memory_total_bytes = systemConfig.value.redis_memory_total_bytes
originalConfig.value.postgres_storage_total_bytes = systemConfig.value.postgres_storage_total_bytes
}
success('监控容量配置已保存')
} catch (err) {
error('保存监控容量配置失败')
log.error('保存监控容量配置失败:', err)
} finally {
monitoringConfigLoading.value = false
}
}
async function saveCleanupConfig() { async function saveCleanupConfig() {
cleanupConfigLoading.value = true cleanupConfigLoading.value = true
try { try {
@@ -497,17 +575,21 @@ export function useSystemConfig() {
siteInfoLoading, siteInfoLoading,
proxyConfigLoading, proxyConfigLoading,
basicConfigLoading, basicConfigLoading,
monitoringConfigLoading,
logConfigLoading, logConfigLoading,
cleanupConfigLoading, cleanupConfigLoading,
// 变动检测 // 变动检测
hasSiteInfoChanges, hasSiteInfoChanges,
hasProxyConfigChanges, hasProxyConfigChanges,
hasBasicConfigChanges, hasBasicConfigChanges,
hasMonitoringConfigChanges,
hasLogConfigChanges, hasLogConfigChanges,
hasCleanupConfigChanges, hasCleanupConfigChanges,
// 计算属性 // 计算属性
maxRequestBodySizeKB, maxRequestBodySizeKB,
maxResponseBodySizeKB, maxResponseBodySizeKB,
redisMemoryTotalGB,
postgresStorageTotalGB,
sensitiveHeadersStr, sensitiveHeadersStr,
// 加载函数 // 加载函数
loadSystemConfig, loadSystemConfig,
@@ -516,6 +598,7 @@ export function useSystemConfig() {
saveSiteInfo, saveSiteInfo,
saveProxyConfig, saveProxyConfig,
saveBasicConfig, saveBasicConfig,
saveMonitoringConfig,
saveLogConfig, saveLogConfig,
saveCleanupConfig, saveCleanupConfig,
handleAutoCleanupToggle, handleAutoCleanupToggle,
File diff suppressed because it is too large Load Diff
+105 -318
View File
@@ -1,79 +1,13 @@
<template> <template>
<div class="space-y-6 pb-8"> <div class="space-y-6 pb-8">
<!-- 面包屑旁的折叠按钮 --> <!-- 跳转数据分析链接 -->
<Teleport <div class="text-right">
to="#header-actions-right" <RouterLink
defer :to="isAdminPage ? '/admin/analytics' : '/dashboard/reports'"
> class="text-xs text-muted-foreground hover:text-foreground transition-colors"
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="statsExpanded ? '收起用量分析' : '展开用量分析'"
@click="statsExpanded = !statsExpanded"
> >
<PanelTopClose {{ isAdminPage ? '查看数据分析 →' : '查看综合报表 →' }}
v-if="statsExpanded" </RouterLink>
class="h-4 w-4"
/>
<PanelTopOpen
v-else
class="h-4 w-4"
/>
</button>
</Teleport>
<!-- 用量分析面板(可折叠) -->
<div
v-if="statsExpanded"
class="space-y-4"
>
<!-- 活跃度热图 + 请求间隔时间线 -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<ActivityHeatmapCard
:data="activityHeatmapData"
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
:is-loading="isLoadingHeatmap"
:has-error="heatmapError"
/>
<IntervalTimelineCard
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
:is-admin="isAdminPage"
:hours="24"
/>
</div>
<!-- 分析统计 -->
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
<div
v-if="isAdminPage"
class="grid grid-cols-1 lg:grid-cols-3 gap-4"
>
<UsageModelTable
:data="enhancedModelStats"
:is-admin="authStore.isAdmin"
/>
<UsageProviderTable
:data="providerStats"
:is-admin="authStore.isAdmin"
/>
<UsageApiFormatTable
:data="apiFormatStats"
:is-admin="authStore.isAdmin"
/>
</div>
<!-- 用户:模型 + API格式(2列) -->
<div
v-else
class="grid grid-cols-1 lg:grid-cols-2 gap-4"
>
<UsageModelTable
:data="enhancedModelStats"
:is-admin="authStore.isAdmin"
/>
<UsageApiFormatTable
:data="apiFormatStats"
:is-admin="false"
/>
</div>
</div> </div>
<!-- 使用记录 --> <!-- 使用记录 -->
@@ -85,13 +19,17 @@
:time-range="timeRange" :time-range="timeRange"
:filter-search="filterSearch" :filter-search="filterSearch"
:filter-user="filterUser" :filter-user="filterUser"
:filter-api-key="filterApiKey"
:filter-model="filterModel" :filter-model="filterModel"
:filter-provider="filterProvider" :filter-provider="filterProvider"
:filter-api-format="filterApiFormat" :filter-api-format="filterApiFormat"
:filter-status="filterStatus" :filter-status="filterStatus"
:available-users="availableUsers" :available-users="availableUsers"
:available-api-keys="availableApiKeys"
:available-models="availableModels" :available-models="availableModels"
:available-api-formats="availableApiFormats"
:available-providers="availableProviders" :available-providers="availableProviders"
:available-statuses="availableStatuses"
:current-page="currentPage" :current-page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:total-records="effectiveTotalRecords" :total-records="effectiveTotalRecords"
@@ -100,6 +38,7 @@
@update:time-range="handleTimeRangeChange" @update:time-range="handleTimeRangeChange"
@update:filter-search="handleFilterSearchChange" @update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange" @update:filter-user="handleFilterUserChange"
@update:filter-api-key="handleFilterApiKeyChange"
@update:filter-model="handleFilterModelChange" @update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange" @update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange" @update:filter-api-format="handleFilterApiFormatChange"
@@ -124,29 +63,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core' // useLocalStorage no longer needed (analytics panel removed)
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { usageApi } from '@/api/usage' import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
import { usersApi } from '@/api/users'
import { meApi } from '@/api/me'
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
import { import {
UsageModelTable,
UsageProviderTable,
UsageApiFormatTable,
UsageRecordsTable, UsageRecordsTable,
ActivityHeatmapCard,
RequestDetailDrawer, RequestDetailDrawer,
IntervalTimelineCard
} from '@/features/usage/components' } from '@/features/usage/components'
import { import {
useUsageData, useUsageData,
getDateRangeFromPeriod getDateRangeFromPeriod
} from '@/features/usage/composables' } from '@/features/usage/composables'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types' import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity' // ActivityHeatmap moved to analytics page
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const route = useRoute() const route = useRoute()
@@ -156,9 +86,6 @@ const authStore = useAuthStore()
// 判断是否是管理员页面 // 判断是否是管理员页面
const isAdminPage = computed(() => route.path.startsWith('/admin')) const isAdminPage = computed(() => route.path.startsWith('/admin'))
// 用量分析面板折叠状态(默认展开,持久化到 localStorage)
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
// 时间范围选择 // 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today')) const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
@@ -170,155 +97,28 @@ const pageSizeOptions = [10, 20, 50, 100]
// 筛选状态 // 筛选状态
const filterSearch = ref('') const filterSearch = ref('')
const filterUser = ref('__all__') const filterUser = ref('__all__')
const filterApiKey = ref('__all__')
const filterModel = ref('__all__') const filterModel = ref('__all__')
const filterProvider = ref('__all__') const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__') const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__') const filterStatus = ref<FilterStatusValue>('__all__')
// 用户列表(仅管理员页面使用)
const availableUsers = ref<UserOption[]>([])
// 使用 composables // 使用 composables
const { const {
isLoadingRecords, isLoadingRecords,
providerStats,
apiFormatStats,
currentRecords, currentRecords,
totalRecords, totalRecords,
enhancedModelStats, availableUsers,
availableApiKeys,
availableModels, availableModels,
availableApiFormats,
availableProviders, availableProviders,
availableStatuses,
loadStats, loadStats,
loadRecords loadRecords
} = useUsageData({ isAdminPage }) } = useUsageData({ isAdminPage })
// 热力图状态 // 热力图已移至数据分析页
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
const isLoadingHeatmap = ref(false)
const heatmapError = ref(false)
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
let lastAdminAnalyticsRefreshAt = 0
// 加载热力图数据
async function loadHeatmapData() {
isLoadingHeatmap.value = true
heatmapError.value = false
try {
if (isAdminPage.value) {
activityHeatmapData.value = await usageApi.getActivityHeatmap()
} else {
activityHeatmapData.value = await meApi.getActivityHeatmap()
}
} catch (error) {
log.error('加载热力图数据失败:', error)
heatmapError.value = true
} finally {
isLoadingHeatmap.value = false
}
}
async function loadAdminUsers() {
try {
const users = await usersApi.getAllUsers()
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
} catch (error) {
log.error('加载用户列表失败:', error)
}
}
async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
if (!isAdminPage.value) return
if (!options.force && !isPageVisible.value) return
const now = Date.now()
if (!options.force && now - lastAdminAnalyticsRefreshAt < ADMIN_ANALYTICS_REFRESH_INTERVAL) {
return
}
if (adminAnalyticsRefreshInFlight) {
return adminAnalyticsRefreshInFlight
}
adminAnalyticsRefreshInFlight = (async () => {
let hasSuccessfulRefresh = false
try {
await loadStats(timeRange.value)
hasSuccessfulRefresh = true
} catch (error) {
log.error('加载统计数据失败:', error)
warning('统计数据加载失败,请刷新重试')
}
try {
await loadHeatmapData()
hasSuccessfulRefresh = true
} catch (error) {
log.error('加载热力图数据失败:', error)
}
if (hasSuccessfulRefresh) {
lastAdminAnalyticsRefreshAt = Date.now()
}
})()
try {
await adminAnalyticsRefreshInFlight
} finally {
adminAnalyticsRefreshInFlight = null
}
}
// 用户页面需要前端筛选
const filteredRecords = computed(() => {
if (!isAdminPage.value) {
let records = [...currentRecords.value]
if (filterModel.value !== '__all__') {
records = records.filter(record => record.model === filterModel.value)
}
if (filterProvider.value !== '__all__') {
records = records.filter(record => record.provider === filterProvider.value)
}
if (filterApiFormat.value !== '__all__') {
records = records.filter(record =>
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
)
}
if (filterStatus.value !== '__all__') {
if (filterStatus.value === 'stream') {
records = records.filter(record =>
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
)
} else if (filterStatus.value === 'standard') {
records = records.filter(record =>
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
)
} else if (filterStatus.value === 'active') {
records = records.filter(record =>
record.status === 'pending' || record.status === 'streaming'
)
} else if (filterStatus.value === 'failed') {
// 失败请求需要同时考虑新旧两种判断方式:
// 1. 新方式:status = "failed"
// 2. 旧方式:status_code >= 400 或 error_message 不为空
records = records.filter(record =>
record.status === 'failed' ||
(record.status_code && record.status_code >= 400) ||
record.error_message
)
} else if (filterStatus.value === 'cancelled') {
records = records.filter(record => record.status === 'cancelled')
}
}
return records
}
return currentRecords.value
})
// 获取活跃请求的 ID 列表 // 获取活跃请求的 ID 列表
const activeRequestIds = computed(() => { const activeRequestIds = computed(() => {
@@ -349,11 +149,10 @@ async function pollActiveRequests() {
pollInFlight = true pollInFlight = true
try { try {
// 根据页面类型选择不同的 API const { requests } = await analyticsApi.getActiveRequests({
const idsParam = activeRequestIds.value.join(',') scope: isAdminPage.value ? { kind: 'global' } : { kind: 'me' },
const { requests } = isAdminPage.value ids: activeRequestIds.value,
? await usageApi.getActiveRequests(activeRequestIds.value) })
: await meApi.getActiveRequests(idsParam)
let shouldRefresh = false let shouldRefresh = false
@@ -512,23 +311,8 @@ onUnmounted(() => {
stopGlobalAutoRefresh() stopGlobalAutoRefresh()
}) })
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选) const paginatedRecords = computed(() => currentRecords.value)
const paginatedRecords = computed(() => { const effectiveTotalRecords = computed(() => totalRecords.value)
if (!isAdminPage.value) {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredRecords.value.slice(start, end)
}
return currentRecords.value
})
// 用户页面使用前端筛选后的总数,管理员页面使用后端返回的总数
const effectiveTotalRecords = computed(() => {
if (!isAdminPage.value) {
return filteredRecords.value.length
}
return totalRecords.value
})
// 显示的记录 // 显示的记录
const displayRecords = computed(() => paginatedRecords.value) const displayRecords = computed(() => paginatedRecords.value)
@@ -538,33 +322,64 @@ const displayRecords = computed(() => paginatedRecords.value)
const detailModalOpen = ref(false) const detailModalOpen = ref(false)
const selectedRequestId = ref<string | null>(null) const selectedRequestId = ref<string | null>(null)
function hasOption(options: AnalyticsFilterOption[], value: string) {
return options.some(option => option.value === value)
}
function normalizeFilterSelections() {
let changed = false
if (filterUser.value !== '__all__' && !hasOption(availableUsers.value, filterUser.value)) {
filterUser.value = '__all__'
changed = true
}
if (filterApiKey.value !== '__all__' && !hasOption(availableApiKeys.value, filterApiKey.value)) {
filterApiKey.value = '__all__'
changed = true
}
if (filterModel.value !== '__all__' && !hasOption(availableModels.value, filterModel.value)) {
filterModel.value = '__all__'
changed = true
}
if (filterProvider.value !== '__all__' && !hasOption(availableProviders.value, filterProvider.value)) {
filterProvider.value = '__all__'
changed = true
}
if (filterApiFormat.value !== '__all__' && !hasOption(availableApiFormats.value, filterApiFormat.value)) {
filterApiFormat.value = '__all__'
changed = true
}
if (filterStatus.value !== '__all__' && !hasOption(availableStatuses.value, filterStatus.value)) {
filterStatus.value = '__all__'
changed = true
}
return changed
}
async function syncFilterOptions() {
await loadStats(timeRange.value, getCurrentFilters())
if (!normalizeFilterSelections()) {
return
}
await loadStats(timeRange.value, getCurrentFilters())
}
// 初始化加载 // 初始化加载
onMounted(async () => { onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
if (isAdminPage.value) { const statsTask = syncFilterOptions().catch(err => {
// 管理员页面优先加载记录,统计面板在后台顺序刷新,避免瞬时并发打满后端。 log.error('加载统计数据失败:', err)
await loadRecords( warning('统计数据加载失败,请刷新重试')
{ page: currentPage.value, pageSize: pageSize.value }, })
getCurrentFilters(), await statsTask
timeRange.value await loadRecords(
) { page: currentPage.value, pageSize: pageSize.value },
void (async () => { getCurrentFilters()
await refreshAdminAnalytics({ force: true }) )
await loadAdminUsers()
})()
} else {
// 用户页面:loadStats 已包含记录加载,不需要单独调用 loadRecords
await Promise.allSettled([
loadStats(timeRange.value).catch(err => {
log.error('加载统计数据失败:', err)
warning('统计数据加载失败,请刷新重试')
}),
loadHeatmapData().catch(err => {
log.error('加载热力图数据失败:', err)
})
])
}
if (globalAutoRefresh.value && isPageVisible.value) { if (globalAutoRefresh.value && isPageVisible.value) {
startGlobalAutoRefresh() startGlobalAutoRefresh()
@@ -575,32 +390,21 @@ onMounted(async () => {
async function handleTimeRangeChange(value: DateRangeParams) { async function handleTimeRangeChange(value: DateRangeParams) {
timeRange.value = value timeRange.value = value
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) { await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
await refreshAdminAnalytics({ force: true })
return
}
await loadStats(timeRange.value)
// 用户页面:loadStats 已包含记录加载
} }
// 处理分页变化 // 处理分页变化
async function handlePageChange(page: number) { async function handlePageChange(page: number) {
currentPage.value = page currentPage.value = page
if (isAdminPage.value) { await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
// 用户页面使用前端分页,无需重新请求
} }
// 处理每页大小变化 // 处理每页大小变化
async function handlePageSizeChange(size: number) { async function handlePageSizeChange(size: number) {
pageSize.value = size pageSize.value = size
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) { await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value)
}
// 用户页面使用前端分页,无需重新请求
} }
// 获取当前筛选参数 // 获取当前筛选参数
@@ -608,6 +412,7 @@ function getCurrentFilters() {
return { return {
search: filterSearch.value.trim() || undefined, search: filterSearch.value.trim() || undefined,
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined, user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
api_key_id: filterApiKey.value !== '__all__' ? filterApiKey.value : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined, model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined, provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined, api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
@@ -619,57 +424,49 @@ function getCurrentFilters() {
async function handleFilterSearchChange(value: string) { async function handleFilterSearchChange(value: string) {
filterSearch.value = value filterSearch.value = value
currentPage.value = 1 currentPage.value = 1
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
// 用户页面:search 需要重新从后端拉取数据(后端支持 search 参数)
// 但通过 filteredRecords 做前端过滤已覆盖,无需额外请求
} }
async function handleFilterUserChange(value: string) { async function handleFilterUserChange(value: string) {
filterUser.value = value filterUser.value = value
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
if (isAdminPage.value) { async function handleFilterApiKeyChange(value: string) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value) filterApiKey.value = value
} currentPage.value = 1
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
} }
async function handleFilterModelChange(value: string) { async function handleFilterModelChange(value: string) {
filterModel.value = value filterModel.value = value
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页
await syncFilterOptions()
if (isAdminPage.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
} }
async function handleFilterProviderChange(value: string) { async function handleFilterProviderChange(value: string) {
filterProvider.value = value filterProvider.value = value
currentPage.value = 1 currentPage.value = 1
await syncFilterOptions()
if (isAdminPage.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
} }
async function handleFilterApiFormatChange(value: string) { async function handleFilterApiFormatChange(value: string) {
filterApiFormat.value = value filterApiFormat.value = value
currentPage.value = 1 currentPage.value = 1
await syncFilterOptions()
if (isAdminPage.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
} }
async function handleFilterStatusChange(value: string) { async function handleFilterStatusChange(value: string) {
filterStatus.value = value as FilterStatusValue filterStatus.value = value as FilterStatusValue
currentPage.value = 1 currentPage.value = 1
await syncFilterOptions()
if (isAdminPage.value) { await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
} }
// 刷新数据 // 刷新数据
@@ -678,18 +475,8 @@ async function refreshData() {
if (refreshInFlight) return refreshInFlight if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => { refreshInFlight = (async () => {
if (isAdminPage.value) { await syncFilterOptions()
await loadRecords( await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters(),
timeRange.value
)
void refreshAdminAnalytics()
return
}
await loadStats(timeRange.value)
// 用户页面:loadStats 已包含记录加载
})() })()
try { try {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -46,6 +46,7 @@ dependencies = [
"aiosmtplib>=5.1.0", "aiosmtplib>=5.1.0",
"redis>=7.1.0", "redis>=7.1.0",
"prometheus-client>=0.24.1", "prometheus-client>=0.24.1",
"psutil>=5.9.8",
"apscheduler>=3.11.2", "apscheduler>=3.11.2",
"ldap3>=2.9.1", "ldap3>=2.9.1",
"msgpack>=1.1.2", "msgpack>=1.1.2",
-2
View File
@@ -17,7 +17,6 @@ from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router from .providers import router as providers_router
from .security import router as security_router from .security import router as security_router
from .stats import router as stats_router
from .system import router as system_router from .system import router as system_router
from .usage import router as usage_router from .usage import router as usage_router
from .users import router as users_router from .users import router as users_router
@@ -39,7 +38,6 @@ router.include_router(provider_oauth_router)
router.include_router(adaptive_router) router.include_router(adaptive_router)
router.include_router(models_router) router.include_router(models_router)
router.include_router(security_router) router.include_router(security_router)
router.include_router(stats_router)
router.include_router(provider_query_router) router.include_router(provider_query_router)
router.include_router(modules_router) router.include_router(modules_router)
router.include_router(pool_router) router.include_router(pool_router)
+351 -1
View File
@@ -2,14 +2,21 @@
from __future__ import annotations from __future__ import annotations
import os
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import case, func from sqlalchemy import case, func, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
try:
import psutil
except ModuleNotFoundError:
psutil = None
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pagination import PaginationMeta, build_pagination_payload from src.api.base.pagination import PaginationMeta, build_pagination_payload
@@ -17,6 +24,7 @@ from src.api.base.pipeline import get_pipeline
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.database.database import get_pool_status
from src.models.database import ( from src.models.database import (
ApiKey, ApiKey,
AuditEventType, AuditEventType,
@@ -25,7 +33,9 @@ from src.models.database import (
Usage, Usage,
) )
from src.models.database import User as DBUser from src.models.database import User as DBUser
from src.clients.redis_client import get_redis_client
from src.services.health.monitor import HealthMonitor from src.services.health.monitor import HealthMonitor
from src.services.system.config import SystemConfigService
from src.services.system.audit import audit_service from src.services.system.audit import audit_service
from src.utils.cache_decorator import cache_result from src.utils.cache_decorator import cache_result
from src.utils.database_helpers import escape_like_pattern from src.utils.database_helpers import escape_like_pattern
@@ -34,6 +44,334 @@ router = APIRouter(prefix="/api/admin/monitoring", tags=["Admin - Monitoring"])
pipeline = get_pipeline() pipeline = get_pipeline()
def _unavailable_metric(message: str, **fields: Any) -> dict[str, Any]:
return {
"status": "unknown",
"label": _label_for_status("unknown"),
**fields,
"message": message,
}
def _round_metric(value: float | int | None, digits: int = 1) -> float | None:
if value is None:
return None
return round(float(value), digits)
def _metric_status(percent: float | None, *, warning: float, danger: float) -> str:
if percent is None:
return "unknown"
if percent >= danger:
return "danger"
if percent >= warning:
return "warning"
return "ok"
def _remaining_status(percent: float | None, *, warning: float, danger: float) -> str:
if percent is None:
return "unknown"
if percent <= danger:
return "danger"
if percent <= warning:
return "warning"
return "ok"
def _label_for_status(status: str) -> str:
return {
"ok": "正常",
"warning": "偏高",
"danger": "紧张",
"degraded": "降级",
"error": "异常",
"unknown": "未知",
}.get(status, "未知")
def _optional_int(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _get_manual_capacity_bytes(db: Session, key: str) -> int | None:
value = _optional_int(SystemConfigService.get_config(db, key, default=0))
if value is None or value <= 0:
return None
return value
def _build_cpu_metric() -> dict[str, Any]:
core_count = int((psutil.cpu_count() if psutil is not None else os.cpu_count()) or 0)
load_percent: float | None = None
try:
if hasattr(os, "getloadavg") and core_count > 0:
load_percent = _round_metric((os.getloadavg()[0] / core_count) * 100)
except OSError:
load_percent = None
if psutil is None:
status = _metric_status(load_percent, warning=70, danger=85)
return {
"status": status,
"label": _label_for_status(status),
"usage_percent": None,
"load_percent": load_percent,
"core_count": core_count,
"message": "psutil is not installed",
}
cpu_percent = _round_metric(psutil.cpu_percent(interval=0.05))
status = _metric_status(cpu_percent, warning=70, danger=85)
return {
"status": status,
"label": _label_for_status(status),
"usage_percent": cpu_percent,
"load_percent": load_percent,
"core_count": core_count,
}
def _build_memory_metric() -> dict[str, Any]:
if psutil is None:
return _unavailable_metric(
"psutil is not installed",
used_percent=None,
used_bytes=None,
available_bytes=None,
total_bytes=None,
)
memory = psutil.virtual_memory()
used_percent = _round_metric(memory.percent)
status = _metric_status(used_percent, warning=75, danger=90)
return {
"status": status,
"label": _label_for_status(status),
"used_percent": used_percent,
"used_bytes": int(memory.used),
"available_bytes": int(memory.available),
"total_bytes": int(memory.total),
}
async def _build_redis_metric(db: Session) -> dict[str, Any]:
started = time.perf_counter()
try:
redis = await get_redis_client(require_redis=False)
if redis is None:
status = "degraded"
return {
"status": status,
"label": _label_for_status(status),
"latency_ms": None,
"memory_status": "unknown",
"memory_label": _label_for_status("unknown"),
"used_memory_bytes": None,
"peak_memory_bytes": None,
"maxmemory_bytes": None,
"memory_ceiling_bytes": None,
"memory_source": "unknown",
"available_memory_bytes": None,
"memory_percent": None,
"message": "Redis client not initialized",
}
await redis.ping()
latency_ms = _round_metric((time.perf_counter() - started) * 1000)
# 优先使用 Redis 自身的 INFO 输出,避免依赖应用所在机器的本地内存视角。
memory_info = await redis.info(section="memory")
used_memory_bytes = _optional_int(memory_info.get("used_memory"))
peak_memory_bytes = _optional_int(memory_info.get("used_memory_peak"))
maxmemory_bytes = _optional_int(memory_info.get("maxmemory"))
total_system_memory_bytes = _optional_int(memory_info.get("total_system_memory"))
configured_memory_bytes = _get_manual_capacity_bytes(db, "redis_memory_total_bytes")
if maxmemory_bytes is not None and maxmemory_bytes <= 0:
maxmemory_bytes = None
memory_ceiling_bytes = configured_memory_bytes or maxmemory_bytes or total_system_memory_bytes
if configured_memory_bytes is not None:
memory_source = "configured"
elif maxmemory_bytes is not None:
memory_source = "maxmemory"
elif total_system_memory_bytes is not None:
memory_source = "system"
else:
memory_source = "unknown"
available_memory_bytes = (
max(int(memory_ceiling_bytes) - int(used_memory_bytes), 0)
if memory_ceiling_bytes is not None and used_memory_bytes is not None
else None
)
memory_percent = _round_metric(
(used_memory_bytes / memory_ceiling_bytes * 100)
if used_memory_bytes is not None and memory_ceiling_bytes not in (None, 0)
else None
)
status = "warning" if latency_ms is not None and latency_ms >= 80 else "ok"
memory_status = _metric_status(memory_percent, warning=75, danger=90)
return {
"status": status,
"label": _label_for_status(status),
"latency_ms": latency_ms,
"memory_status": memory_status,
"memory_label": _label_for_status(memory_status),
"used_memory_bytes": used_memory_bytes,
"peak_memory_bytes": peak_memory_bytes,
"maxmemory_bytes": maxmemory_bytes,
"memory_ceiling_bytes": memory_ceiling_bytes,
"memory_source": memory_source,
"available_memory_bytes": available_memory_bytes,
"memory_percent": memory_percent,
"message": None,
}
except Exception as exc:
return {
"status": "error",
"label": _label_for_status("error"),
"latency_ms": None,
"memory_status": "unknown",
"memory_label": _label_for_status("unknown"),
"used_memory_bytes": None,
"peak_memory_bytes": None,
"maxmemory_bytes": None,
"memory_ceiling_bytes": None,
"memory_source": "unknown",
"available_memory_bytes": None,
"memory_percent": None,
"message": str(exc),
}
def _build_postgres_metric(db: Session) -> dict[str, Any]:
try:
pool_status = get_pool_status()
checked_out = int(pool_status.get("checked_out") or 0)
max_capacity = int(pool_status.get("max_capacity") or 0)
pool_usage_percent = _round_metric(
(checked_out / max_capacity * 100) if max_capacity > 0 else 0
)
usage_percent = pool_usage_percent
storage_total_bytes: int | None = None
storage_free_bytes: int | None = None
storage_free_percent: float | None = None
database_size_bytes: int | None = None
server_connections: int | None = None
server_max_connections: int | None = None
server_usage_percent: float | None = None
message: str | None = None
storage_message: str | None = None
storage_total_bytes = _get_manual_capacity_bytes(db, "postgres_storage_total_bytes")
bind = db.get_bind()
dialect_name = bind.dialect.name if bind is not None else ""
if dialect_name == "postgresql":
try:
# 只读取 PostgreSQL 原生 SQL 可见的指标,避免依赖本机数据目录或宿主机磁盘。
stats = (
db.execute(
text(
"""
SELECT
pg_database_size(current_database()) AS database_size_bytes,
current_setting('max_connections')::int AS server_max_connections,
COALESCE(
(
SELECT numbackends
FROM pg_stat_database
WHERE datname = current_database()
),
0
) AS server_connections
"""
)
)
.mappings()
.one()
)
database_size_bytes = _optional_int(stats.get("database_size_bytes"))
server_max_connections = _optional_int(stats.get("server_max_connections"))
server_connections = _optional_int(stats.get("server_connections"))
server_usage_percent = _round_metric(
(server_connections / server_max_connections * 100)
if server_connections is not None and server_max_connections not in (None, 0)
else None
)
if server_usage_percent is not None:
usage_percent = server_usage_percent
if storage_total_bytes is not None and database_size_bytes is not None:
storage_free_bytes = max(storage_total_bytes - database_size_bytes, 0)
storage_free_percent = _round_metric(
(storage_free_bytes / storage_total_bytes * 100)
if storage_total_bytes > 0
else None
)
else:
storage_message = "请在系统设置中填写 PostgreSQL 总空间"
except Exception as exc:
message = f"PostgreSQL 原生指标不可用: {exc}"
if storage_total_bytes is not None:
storage_message = "已配置 PostgreSQL 总空间,但当前库体积不可用"
else:
storage_message = "请在系统设置中填写 PostgreSQL 总空间"
else:
storage_message = "当前数据库不是 PostgreSQL"
status = _metric_status(usage_percent, warning=70, danger=90)
storage_status = _remaining_status(storage_free_percent, warning=20, danger=10)
return {
"status": status,
"label": _label_for_status(status),
"usage_percent": usage_percent,
"pool_usage_percent": pool_usage_percent,
"checked_out": checked_out,
"pool_size": int(pool_status.get("pool_size") or 0),
"overflow": int(pool_status.get("overflow") or 0),
"max_capacity": max_capacity,
"pool_timeout": int(pool_status.get("pool_timeout") or 0),
"server_connections": server_connections,
"server_max_connections": server_max_connections,
"server_usage_percent": server_usage_percent,
"storage_status": storage_status,
"storage_label": _label_for_status(storage_status),
"storage_total_bytes": storage_total_bytes,
"storage_free_bytes": storage_free_bytes,
"storage_free_percent": storage_free_percent,
"database_size_bytes": database_size_bytes,
"storage_message": storage_message,
"message": message,
}
except Exception as exc:
return {
"status": "error",
"label": _label_for_status("error"),
"usage_percent": None,
"pool_usage_percent": None,
"checked_out": 0,
"pool_size": 0,
"overflow": 0,
"max_capacity": 0,
"pool_timeout": 0,
"server_connections": None,
"server_max_connections": None,
"server_usage_percent": None,
"storage_status": "unknown",
"storage_label": _label_for_status("unknown"),
"storage_total_bytes": None,
"storage_free_bytes": None,
"storage_free_percent": None,
"database_size_bytes": None,
"storage_message": None,
"message": str(exc),
}
@router.get("/audit-logs") @router.get("/audit-logs")
async def get_audit_logs( async def get_audit_logs(
request: Request, request: Request,
@@ -96,6 +434,7 @@ async def get_system_status(request: Request, db: Session = Depends(get_db)) ->
- `api_keys`: API Key 统计(total: 总数, active: 活跃数) - `api_keys`: API Key 统计(total: 总数, active: 活跃数)
- `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本) - `today_stats`: 今日统计(requests: 请求数, tokens: token 数, cost_usd: 成本)
- `recent_errors`: 最近 1 小时内的错误数 - `recent_errors`: 最近 1 小时内的错误数
- `system_metrics`: 系统资源摘要(cpu / memory / redis / postgres)
""" """
adapter = AdminSystemStatusAdapter() adapter = AdminSystemStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -381,6 +720,11 @@ class AdminSystemStatusAdapter(AdminApiAdapter):
recent_errors=int(recent_errors or 0), recent_errors=int(recent_errors or 0),
) )
cpu_metric = _build_cpu_metric()
memory_metric = _build_memory_metric()
redis_metric = await _build_redis_metric(db)
postgres_metric = _build_postgres_metric(db)
return { return {
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"users": {"total": total_users, "active": active_users}, "users": {"total": total_users, "active": active_users},
@@ -392,6 +736,12 @@ class AdminSystemStatusAdapter(AdminApiAdapter):
"cost_usd": f"${today_cost:.4f}", "cost_usd": f"${today_cost:.4f}",
}, },
"recent_errors": recent_errors, "recent_errors": recent_errors,
"system_metrics": {
"cpu": cpu_metric,
"memory": memory_metric,
"redis": redis_metric,
"postgres": postgres_metric,
},
} }
-22
View File
@@ -1,22 +0,0 @@
"""Stats admin routes export."""
from fastapi import APIRouter
from .comparison import router as comparison_router
from .cost import router as cost_router
from .errors import router as errors_router
from .leaderboard import router as leaderboard_router
from .performance import router as performance_router
from .quota import router as quota_router
from .time_series import router as time_series_router
router = APIRouter(prefix="/api/admin/stats", tags=["Admin - Stats"])
router.include_router(leaderboard_router)
router.include_router(time_series_router)
router.include_router(cost_router)
router.include_router(quota_router)
router.include_router(performance_router)
router.include_router(errors_router)
router.include_router(comparison_router)
__all__ = ["router"]
-196
View File
@@ -1,196 +0,0 @@
"""Shared helpers for admin stats routes."""
from __future__ import annotations
import hashlib
import json
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Literal
from fastapi import HTTPException
from sqlalchemy import and_, or_
from src.api.base.pipeline import get_pipeline
from src.config.settings import config
from src.models.database import Usage
from src.services.system.time_range import TimeRangeParams
pipeline = get_pipeline()
def _apply_admin_default_range(
params: TimeRangeParams | None,
) -> TimeRangeParams | None:
"""Apply a default range to avoid unbounded scans."""
if params is not None:
return params
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
if days <= 0:
return None
today = datetime.now(timezone.utc).date()
start_date = today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=today,
timezone="UTC",
tz_offset_minutes=0,
).validate_and_resolve()
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _hash_filters(filters: dict[str, Any]) -> str:
raw = json.dumps(filters, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def _build_time_range_from_days(
days: int, timezone_name: str | None, tz_offset_minutes: int | None
) -> TimeRangeParams:
base = TimeRangeParams(
preset="today",
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
user_today = base.start_date
start_date = user_today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=user_today,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
def _linear_regression(values: list[float]) -> tuple[float, float]:
n = len(values)
if n <= 1:
return 0.0, values[0] if values else 0.0
xs = list(range(n))
sum_x = sum(xs)
sum_y = sum(values)
sum_x2 = sum(x * x for x in xs)
sum_xy = sum(x * y for x, y in zip(xs, values))
denom = n * sum_x2 - sum_x * sum_x
if denom == 0:
return 0.0, values[-1]
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
return slope, intercept
def _build_cache_key(
leaderboard_type: str,
metric: str,
time_range: TimeRangeParams | None,
filters: dict[str, Any],
) -> str:
start_value = time_range.start_date.isoformat() if time_range else "all"
end_value = time_range.end_date.isoformat() if time_range else "all"
tz_value = time_range.timezone if time_range else "utc"
offset_value = time_range.tz_offset_minutes if time_range else 0
return (
f"leaderboard:{leaderboard_type}:{metric}:{start_value}:{end_value}:"
f"{tz_value}:{offset_value}:{_hash_filters(filters)}"
)
def _is_today_range(time_range: TimeRangeParams | None) -> bool:
if not time_range:
return False
try:
user_today = time_range._get_user_today()
except Exception:
return False
return time_range.end_date == user_today
def _split_daily_and_usage_segments(
time_range: TimeRangeParams | None,
use_daily: bool,
) -> tuple[tuple[datetime, datetime] | None, list[tuple[datetime, datetime]] | None]:
if not time_range:
return None, None
start_utc, end_utc = time_range.to_utc_datetime_range()
if not use_daily:
return None, [(start_utc, end_utc)]
complete_dates, head_boundary, tail_boundary = time_range.get_complete_utc_dates()
daily_range = None
if complete_dates:
daily_start = datetime.combine(complete_dates[0], time.min, tzinfo=timezone.utc)
daily_end = datetime.combine(
complete_dates[-1] + timedelta(days=1), time.min, tzinfo=timezone.utc
)
daily_range = (daily_start, daily_end)
usage_segments: list[tuple[datetime, datetime]] = []
if head_boundary:
usage_segments.append(head_boundary)
if tail_boundary:
usage_segments.append(tail_boundary)
if not daily_range and not usage_segments:
usage_segments = [(start_utc, end_utc)]
return daily_range, usage_segments
def _apply_usage_time_segments(
query: Any, segments: list[tuple[datetime, datetime]] | None
) -> Any | None:
if segments is None:
return query
if not segments:
return None
conditions = []
for start_utc, end_utc in segments:
if start_utc >= end_utc:
continue
conditions.append(and_(Usage.created_at >= start_utc, Usage.created_at < end_utc))
if not conditions:
return None
return query.filter(or_(*conditions))
def _union_queries(queries: list[Any]) -> Any | None:
base = None
for query in queries:
if query is None:
continue
if base is None:
base = query
else:
base = base.union_all(query)
return base
def _metric_order(
metric: Literal["requests", "tokens", "cost"], order: Literal["asc", "desc"], expr: Any
) -> Any:
return expr.asc() if order == "asc" else expr.desc()
-142
View File
@@ -1,142 +0,0 @@
"""Admin comparison stats routes."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import pipeline
router = APIRouter()
class AdminComparisonAdapter(AdminApiAdapter):
def __init__(
self,
current_start: date,
current_end: date,
comparison_type: Literal["period", "year"],
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> None:
self.current_start = current_start
self.current_end = current_end
self.comparison_type = comparison_type
self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes
@cache_result(
key_prefix="admin:stats:comparison",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"current_start",
"current_end",
"comparison_type",
"timezone_name",
"tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if self.current_start > self.current_end:
raise HTTPException(status_code=400, detail="current_start must be <= current_end")
days = (self.current_end - self.current_start).days + 1
def _safe_year_shift(value: date) -> date:
try:
return value.replace(year=value.year - 1)
except ValueError:
return value.replace(year=value.year - 1, day=28)
if self.comparison_type == "period":
comparison_end = self.current_start - timedelta(days=1)
comparison_start = comparison_end - timedelta(days=days - 1)
else:
comparison_start = _safe_year_shift(self.current_start)
comparison_end = _safe_year_shift(self.current_end)
current_range = TimeRangeParams(
start_date=self.current_start,
end_date=self.current_end,
timezone=self.timezone_name,
tz_offset_minutes=self.tz_offset_minutes or 0,
).validate_and_resolve()
comparison_range = TimeRangeParams(
start_date=comparison_start,
end_date=comparison_end,
timezone=self.timezone_name,
tz_offset_minutes=self.tz_offset_minutes or 0,
).validate_and_resolve()
current_stats = query_stats_hybrid(context.db, current_range, filters=StatsFilter())
comparison_stats = query_stats_hybrid(context.db, comparison_range, filters=StatsFilter())
def _stats_payload(stats: AggregatedStats) -> dict[str, Any]:
total_tokens = (
stats.input_tokens
+ stats.output_tokens
+ stats.cache_creation_tokens
+ stats.cache_read_tokens
)
return {
"total_requests": stats.total_requests,
"total_tokens": total_tokens,
"total_cost": float(stats.total_cost),
"actual_total_cost": float(stats.actual_total_cost),
"avg_response_time_ms": float(stats.avg_response_time_ms),
"error_requests": stats.error_requests,
}
def _pct_change(current: float, previous: float) -> float | None:
if previous == 0:
return None if current != 0 else 0.0
return round((current - previous) / previous * 100, 2)
current_payload = _stats_payload(current_stats)
comparison_payload = _stats_payload(comparison_stats)
changes = {
key: _pct_change(float(current_payload[key]), float(comparison_payload[key]))
for key in current_payload.keys()
}
return {
"current": current_payload,
"comparison": comparison_payload,
"change_percent": changes,
"current_start": self.current_start.isoformat(),
"current_end": self.current_end.isoformat(),
"comparison_start": comparison_start.isoformat(),
"comparison_end": comparison_end.isoformat(),
}
@router.get("/comparison")
async def get_comparison(
request: Request,
db: Session = Depends(get_db),
current_start: date = Query(...),
current_end: date = Query(...),
comparison_type: Literal["period", "year"] = Query("period"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
adapter = AdminComparisonAdapter(
current_start=current_start,
current_end=current_end,
comparison_type=comparison_type,
timezone_name=timezone_name,
tz_offset_minutes=tz_offset_minutes,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
-215
View File
@@ -1,215 +0,0 @@
"""Admin cost stats routes."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db
from src.models.database import Usage
from src.services.system.stats_aggregator import query_time_series
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import (
_apply_admin_default_range,
_build_time_range_from_days,
_build_time_range_params,
_linear_regression,
pipeline,
)
router = APIRouter()
class AdminCostForecastAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
days: int,
forecast_days: int,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> None:
self.time_range = time_range
self.days = days
self.forecast_days = forecast_days
self.timezone_name = timezone_name
self.fallback_tz_offset_minutes = tz_offset_minutes
@cache_result(
key_prefix="admin:stats:cost:forecast",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"days",
"forecast_days",
"timezone_name",
"fallback_tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
time_range = self.time_range or _build_time_range_from_days(
self.days, self.timezone_name, self.fallback_tz_offset_minutes
)
time_range.granularity = "day"
try:
series = query_time_series(context.db, time_range)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
history = [
{"date": item["date"], "total_cost": float(item.get("total_cost", 0.0))}
for item in series
]
values = [item["total_cost"] for item in history]
slope, intercept = _linear_regression(values)
forecast = []
if history:
last_date = date.fromisoformat(history[-1]["date"])
else:
last_date = time_range.end_date
for i in range(self.forecast_days):
idx = len(values) + i
predicted = max(0.0, slope * idx + intercept)
forecast.append(
{
"date": (last_date + timedelta(days=i + 1)).isoformat(),
"total_cost": round(predicted, 4),
}
)
return {
"history": history,
"forecast": forecast,
"slope": round(slope, 6),
"intercept": round(intercept, 6),
"start_date": time_range.start_date.isoformat(),
"end_date": time_range.end_date.isoformat(),
}
@router.get("/cost/forecast")
async def get_cost_forecast(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
days: int = Query(30, ge=7, le=365),
forecast_days: int = Query(7, ge=1, le=90),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminCostForecastAdapter(
time_range=time_range,
days=days,
forecast_days=forecast_days,
timezone_name=timezone_name,
tz_offset_minutes=tz_offset_minutes,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class AdminCostSavingsAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
provider_name: str | None,
model: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.provider_name = provider_name
self.model = model
@cache_result(
key_prefix="admin:stats:cost:savings",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"provider_name",
"model",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return {
"cache_read_tokens": 0,
"cache_read_cost": 0.0,
"cache_creation_cost": 0.0,
"estimated_full_cost": 0.0,
"cache_savings": 0.0,
}
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = context.db.query(
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(
func.coalesce(Usage.output_price_per_1m, 0) * Usage.cache_read_input_tokens
).label("estimated_full_cost_raw"),
).filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
if self.provider_name:
query = query.filter(Usage.provider_name == self.provider_name)
if self.model:
query = query.filter(Usage.model == self.model)
row = query.first()
cache_read_tokens = int(getattr(row, "cache_read_tokens", 0) or 0)
cache_read_cost = float(getattr(row, "cache_read_cost", 0) or 0.0)
cache_creation_cost = float(getattr(row, "cache_creation_cost", 0) or 0.0)
estimated_full_cost = float(getattr(row, "estimated_full_cost_raw", 0) or 0.0) / 1_000_000
if estimated_full_cost <= 0 and cache_read_cost > 0:
estimated_full_cost = cache_read_cost * 10
cache_savings = estimated_full_cost - cache_read_cost
return {
"cache_read_tokens": cache_read_tokens,
"cache_read_cost": round(cache_read_cost, 6),
"cache_creation_cost": round(cache_creation_cost, 6),
"estimated_full_cost": round(estimated_full_cost, 6),
"cache_savings": round(cache_savings, 6),
}
@router.get("/cost/savings")
async def get_cost_savings(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminCostSavingsAdapter(
time_range=time_range, provider_name=provider_name, model=model
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
-125
View File
@@ -1,125 +0,0 @@
"""Admin error stats routes."""
from __future__ import annotations
from datetime import date, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db
from src.models.database import StatsDailyError, Usage
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminErrorDistributionAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range)
@cache_result(
key_prefix="admin:stats:errors:distribution",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return {"distribution": [], "trend": []}
time_range = self.time_range
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
distribution: dict[str, int] = {}
trend: dict[str, dict[str, int]] = {}
if is_utc:
start_utc, end_utc = time_range.to_utc_datetime_range()
rows = (
context.db.query(StatsDailyError)
.filter(StatsDailyError.date >= start_utc, StatsDailyError.date < end_utc)
.all()
)
for row in rows:
date_str = (
row.date.astimezone(timezone.utc).date().isoformat()
if row.date.tzinfo
else row.date.date().isoformat()
)
distribution[row.error_category] = distribution.get(row.error_category, 0) + int(
row.count or 0
)
trend.setdefault(date_str, {})
trend[date_str][row.error_category] = trend[date_str].get(
row.error_category, 0
) + int(row.count or 0)
else:
for local_date, day_start_utc, day_end_utc in time_range.get_local_day_hours():
rows = (
context.db.query(
Usage.error_category,
func.count(Usage.id).label("count"),
)
.filter(
Usage.created_at >= day_start_utc,
Usage.created_at < day_end_utc,
Usage.error_category.isnot(None),
)
.group_by(Usage.error_category)
.all()
)
date_str = local_date.isoformat()
for row in rows:
distribution[row.error_category] = distribution.get(
row.error_category, 0
) + int(row.count or 0)
trend.setdefault(date_str, {})
trend[date_str][row.error_category] = trend[date_str].get(
row.error_category, 0
) + int(row.count or 0)
trend_items = []
for day in sorted(trend.keys()):
counts = trend[day]
total = sum(counts.values())
trend_items.append({"date": day, "total": total, "categories": counts})
distribution_items = [
{"category": category, "count": count}
for category, count in sorted(
distribution.items(), key=lambda item: item[1], reverse=True
)
]
return {"distribution": distribution_items, "trend": trend_items}
@router.get("/errors/distribution")
async def get_error_distribution(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminErrorDistributionAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
-759
View File
@@ -1,759 +0,0 @@
"""Admin leaderboard stats routes."""
from __future__ import annotations
import json
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.clients.redis_client import get_redis_client_sync
from src.config.constants import CacheTTL
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import (
ApiKey,
StatsDailyApiKey,
StatsDailyModel,
StatsUserDaily,
Usage,
User,
)
from src.services.system.time_range import TimeRangeParams
from .common import (
_apply_admin_default_range,
_apply_usage_time_segments,
_build_cache_key,
_build_time_range_params,
_is_today_range,
_metric_order,
_split_daily_and_usage_segments,
_union_queries,
pipeline,
)
router = APIRouter()
class AdminUserLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
include_inactive: bool,
exclude_admin: bool,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
self.include_inactive = include_inactive
self.exclude_admin = exclude_admin
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"users",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
"include_inactive": self.include_inactive,
"exclude_admin": self.exclude_admin,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name and not self.model
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsUserDaily.user_id.label("entity_id"),
func.sum(StatsUserDaily.total_requests).label("requests"),
func.sum(
StatsUserDaily.input_tokens
+ StatsUserDaily.output_tokens
+ StatsUserDaily.cache_creation_tokens
+ StatsUserDaily.cache_read_tokens
).label("tokens"),
func.sum(StatsUserDaily.total_cost).label("cost"),
)
.filter(StatsUserDaily.date >= daily_range[0], StatsUserDaily.date < daily_range[1])
.group_by(StatsUserDaily.user_id)
)
usage_query = db.query(
Usage.user_id.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.user_id.isnot(None),
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is not None:
usage_query = usage_query.group_by(Usage.user_id)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = (
db.query(
User.id.label("id"),
User.username,
User.email,
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
.join(agg_subq, agg_subq.c.entity_id == User.id)
.filter(User.is_deleted.is_(False))
)
if not self.include_inactive:
base_query = base_query.filter(User.is_active.is_(True))
if self.exclude_admin:
base_query = base_query.filter(User.role != UserRole.ADMIN)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
name = row.username or row.email or str(row.id)
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": row.id,
"name": name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_users",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
include_inactive=self.include_inactive,
exclude_admin=self.exclude_admin,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
class AdminApiKeyLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
include_inactive: bool,
exclude_admin: bool,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
self.include_inactive = include_inactive
self.exclude_admin = exclude_admin
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"api_keys",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
"include_inactive": self.include_inactive,
"exclude_admin": self.exclude_admin,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name and not self.model
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsDailyApiKey.api_key_id.label("entity_id"),
func.sum(StatsDailyApiKey.total_requests).label("requests"),
func.sum(
StatsDailyApiKey.input_tokens
+ StatsDailyApiKey.output_tokens
+ StatsDailyApiKey.cache_creation_tokens
+ StatsDailyApiKey.cache_read_tokens
).label("tokens"),
func.sum(StatsDailyApiKey.total_cost).label("cost"),
)
.filter(
StatsDailyApiKey.date >= daily_range[0], StatsDailyApiKey.date < daily_range[1]
)
.group_by(StatsDailyApiKey.api_key_id)
)
usage_query = db.query(
Usage.api_key_id.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.api_key_id.isnot(None),
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
usage_query = usage_query.group_by(Usage.api_key_id)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = (
db.query(
ApiKey,
User,
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
.join(agg_subq, agg_subq.c.entity_id == ApiKey.id)
.join(User, User.id == ApiKey.user_id)
.filter(User.is_deleted.is_(False))
)
if not self.include_inactive:
base_query = base_query.filter(ApiKey.is_active.is_(True))
if self.exclude_admin:
base_query = base_query.filter(User.role != UserRole.ADMIN)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
api_key = row.ApiKey
name = api_key.name or api_key.get_display_key()
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": api_key.id,
"name": name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_api_keys",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
include_inactive=self.include_inactive,
exclude_admin=self.exclude_admin,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
class AdminModelLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"models",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsDailyModel.model.label("entity_id"),
func.sum(StatsDailyModel.total_requests).label("requests"),
func.sum(
StatsDailyModel.input_tokens
+ StatsDailyModel.output_tokens
+ StatsDailyModel.cache_creation_tokens
+ StatsDailyModel.cache_read_tokens
).label("tokens"),
func.sum(StatsDailyModel.total_cost).label("cost"),
)
.filter(
StatsDailyModel.date >= daily_range[0], StatsDailyModel.date < daily_range[1]
)
.group_by(StatsDailyModel.model)
)
if self.model:
daily_query = daily_query.filter(StatsDailyModel.model == self.model)
usage_query = db.query(
Usage.model.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is not None:
usage_query = usage_query.group_by(Usage.model)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = db.query(
agg_subq.c.entity_id.label("id"),
agg_subq.c.entity_id.label("name"),
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": row.id,
"name": row.name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_models",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
@router.get("/leaderboard/users")
async def get_user_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
include_inactive: bool = Query(False),
exclude_admin: bool = Query(False),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminUserLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
include_inactive=include_inactive,
exclude_admin=exclude_admin,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/leaderboard/api-keys")
async def get_api_key_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
include_inactive: bool = Query(False),
exclude_admin: bool = Query(False),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminApiKeyLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
include_inactive=include_inactive,
exclude_admin=exclude_admin,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/leaderboard/models")
async def get_model_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminModelLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
-93
View File
@@ -1,93 +0,0 @@
"""Admin performance stats routes."""
from __future__ import annotations
from datetime import date, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db
from src.models.database import StatsDaily
from src.services.system.stats_aggregator import StatsAggregatorService
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminPercentilesAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range)
@cache_result(
key_prefix="admin:stats:performance:percentiles",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return []
time_range = self.time_range
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
if is_utc:
start_utc, end_utc = time_range.to_utc_datetime_range()
rows = (
context.db.query(StatsDaily)
.filter(StatsDaily.date >= start_utc, StatsDaily.date < end_utc)
.order_by(StatsDaily.date.asc())
.all()
)
result = []
for row in rows:
date_str = (
row.date.astimezone(timezone.utc).date().isoformat()
if row.date.tzinfo
else row.date.date().isoformat()
)
result.append(
{
"date": date_str,
"p50_response_time_ms": row.p50_response_time_ms,
"p90_response_time_ms": row.p90_response_time_ms,
"p99_response_time_ms": row.p99_response_time_ms,
"p50_first_byte_time_ms": row.p50_first_byte_time_ms,
"p90_first_byte_time_ms": row.p90_first_byte_time_ms,
"p99_first_byte_time_ms": row.p99_first_byte_time_ms,
}
)
return result
return StatsAggregatorService.compute_percentiles_by_local_day(context.db, time_range)
@router.get("/performance/percentiles")
async def get_percentiles(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminPercentilesAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
-90
View File
@@ -1,90 +0,0 @@
"""Admin quota usage stats routes."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.core.enums import ProviderBillingType
from src.database import get_db
from src.models.database import Provider
from src.utils.cache_decorator import cache_result
from .common import pipeline
router = APIRouter()
class AdminQuotaUsageAdapter(AdminApiAdapter):
@cache_result(
key_prefix="admin:stats:providers:quota_usage",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
providers = (
db.query(Provider)
.filter(
(Provider.billing_type == ProviderBillingType.MONTHLY_QUOTA)
| (Provider.monthly_quota_usd.isnot(None))
)
.all()
)
now = datetime.now(timezone.utc)
result = []
for provider in providers:
quota = float(provider.monthly_quota_usd or 0)
used = float(provider.monthly_used_usd or 0)
remaining = max(quota - used, 0.0)
usage_percent = round((used / quota) * 100, 2) if quota > 0 else 0.0
reset_at = provider.quota_last_reset_at
if reset_at:
days_elapsed = max(1, (now - reset_at).days)
else:
days_elapsed = max(1, now.day - 1)
daily_rate = used / days_elapsed if used > 0 else 0.0
estimated_exhaust_at = None
if daily_rate > 0 and remaining > 0:
estimated_exhaust_at = now + timedelta(days=remaining / daily_rate)
if provider.quota_expires_at:
if not estimated_exhaust_at or provider.quota_expires_at < estimated_exhaust_at:
estimated_exhaust_at = provider.quota_expires_at
result.append(
{
"id": provider.id,
"name": provider.name,
"quota_usd": float(quota),
"used_usd": float(used),
"remaining_usd": float(remaining),
"usage_percent": usage_percent,
"quota_expires_at": (
provider.quota_expires_at.isoformat() if provider.quota_expires_at else None
),
"estimated_exhaust_at": (
estimated_exhaust_at.isoformat() if estimated_exhaust_at else None
),
}
)
result.sort(key=lambda x: x["usage_percent"], reverse=True)
return {"providers": result}
@router.get("/providers/quota-usage")
async def get_quota_usage(
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminQuotaUsageAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
-5
View File
@@ -1,5 +0,0 @@
"""Admin stats routes (compat export)."""
from . import router
__all__ = ["router"]
-93
View File
@@ -1,93 +0,0 @@
"""Admin time series stats routes."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db
from src.services.system.stats_aggregator import TimeSeriesFilter, query_time_series
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminTimeSeriesAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
user_id: str | None,
model: str | None,
provider_name: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.user_id = user_id
self.model = model
self.provider_name = provider_name
@cache_result(
key_prefix="admin:stats:time_series",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"time_range.granularity",
"user_id",
"model",
"provider_name",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return []
try:
return query_time_series(
context.db,
self.time_range,
filters=TimeSeriesFilter(
user_id=self.user_id, model=self.model, provider_name=self.provider_name
),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/time-series")
async def get_time_series(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
granularity: Literal["hour", "day", "week", "month"] = Query("day"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
user_id: str | None = Query(None),
model: str | None = Query(None),
provider_name: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
if time_range:
time_range.granularity = granularity
adapter = AdminTimeSeriesAdapter(
time_range=time_range,
user_id=user_id,
model=model,
provider_name=provider_name,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -14,9 +14,9 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline from src.api.base.pipeline import get_pipeline
from src.api.dashboard.routes import DashboardAdapter
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
@@ -232,7 +232,7 @@ async def proxy_video_stream(
@dataclass @dataclass
class VideoTaskListAdapter(DashboardAdapter): class VideoTaskListAdapter(AuthenticatedApiAdapter):
"""视频任务列表适配器""" """视频任务列表适配器"""
status: str | None status: str | None
@@ -338,7 +338,7 @@ class VideoTaskListAdapter(DashboardAdapter):
@dataclass @dataclass
class VideoTaskStatsAdapter(DashboardAdapter): class VideoTaskStatsAdapter(AuthenticatedApiAdapter):
"""视频任务统计适配器""" """视频任务统计适配器"""
@cache_result( @cache_result(
@@ -417,7 +417,7 @@ class VideoTaskStatsAdapter(DashboardAdapter):
@dataclass @dataclass
class VideoTaskDetailAdapter(DashboardAdapter): class VideoTaskDetailAdapter(AuthenticatedApiAdapter):
"""视频任务详情适配器""" """视频任务详情适配器"""
task_id: str task_id: str
@@ -507,7 +507,7 @@ class VideoTaskDetailAdapter(DashboardAdapter):
@dataclass @dataclass
class VideoTaskCancelAdapter(DashboardAdapter): class VideoTaskCancelAdapter(AuthenticatedApiAdapter):
"""视频任务取消适配器""" """视频任务取消适配器"""
task_id: str task_id: str
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .routes import router as analytics_router
router = APIRouter()
router.include_router(analytics_router)
__all__ = ["router"]
+545
View File
@@ -0,0 +1,545 @@
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import User
from src.services.analytics import AnalyticsQueryService
from src.services.analytics.query_service import (
DELETED_API_KEY_FILTER,
DELETED_USER_FILTER,
AnalyticsFilters,
AnalyticsSearch,
BreakdownMetric,
LeaderboardEntity,
LeaderboardMetric,
)
from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
from src.utils.auth_utils import get_current_user
router = APIRouter(prefix="/api/analytics", tags=["Analytics"])
class AnalyticsScopePayload(BaseModel):
kind: Literal["global", "me", "user", "api_key"] = "me"
user_id: str | None = None
api_key_id: str | None = None
class AnalyticsTimeRangePayload(BaseModel):
start_date: date | None = None
end_date: date | None = None
preset: (
Literal[
"today",
"last7days",
"last30days",
"last180days",
"last1year",
]
| None
) = None
granularity: Literal["hour", "day", "week", "month"] = "day"
timezone: str | None = "UTC"
tz_offset_minutes: int = 0
class AnalyticsFiltersPayload(BaseModel):
user_ids: list[str] = Field(default_factory=list)
provider_names: list[str] = Field(default_factory=list)
models: list[str] = Field(default_factory=list)
target_models: list[str] = Field(default_factory=list)
api_key_ids: list[str] = Field(default_factory=list)
api_formats: list[str] = Field(default_factory=list)
request_types: list[str] = Field(default_factory=list)
statuses: list[str] = Field(default_factory=list)
error_categories: list[str] = Field(default_factory=list)
is_stream: bool | None = None
has_format_conversion: bool | None = None
class AnalyticsBaseRequest(BaseModel):
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
time_range: AnalyticsTimeRangePayload = Field(
default_factory=lambda: AnalyticsTimeRangePayload(preset="last30days")
)
filters: AnalyticsFiltersPayload = Field(default_factory=AnalyticsFiltersPayload)
class AnalyticsBreakdownRequest(AnalyticsBaseRequest):
dimension: Literal["model", "provider", "api_format", "api_key", "user"] = "model"
metric: BreakdownMetric = "total_cost_usd"
limit: int = Field(default=50, ge=1, le=200)
class AnalyticsSearchPayload(BaseModel):
text: str | None = None
request_id: str | None = None
class AnalyticsPaginationPayload(BaseModel):
limit: int = Field(default=100, ge=1, le=5000)
offset: int = Field(default=0, ge=0)
class AnalyticsRecordsRequest(AnalyticsBaseRequest):
search: AnalyticsSearchPayload = Field(default_factory=AnalyticsSearchPayload)
pagination: AnalyticsPaginationPayload = Field(default_factory=AnalyticsPaginationPayload)
class AnalyticsLeaderboardRequest(AnalyticsBaseRequest):
entity: LeaderboardEntity = "user"
metric: LeaderboardMetric = "total_cost_usd"
limit: int = Field(default=20, ge=1, le=100)
class AnalyticsHeatmapRequest(BaseModel):
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
user_id: str | None = None
api_key_id: str | None = None
class AnalyticsActiveRequestsRequest(BaseModel):
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
ids: list[str] = Field(default_factory=list)
class AnalyticsIntervalTimelineRequest(BaseModel):
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
user_id: str | None = None
hours: int = Field(default=24, ge=1, le=720)
limit: int = Field(default=2000, ge=100, le=50000)
include_user_info: bool = False
class AnalyticsCacheAffinityRequest(BaseModel):
scope: AnalyticsScopePayload = Field(default_factory=AnalyticsScopePayload)
user_id: str | None = None
api_key_id: str | None = None
hours: int = Field(default=168, ge=1, le=720)
def _build_time_range(payload: AnalyticsTimeRangePayload) -> TimeRangeParams:
try:
return TimeRangeParams(
start_date=payload.start_date,
end_date=payload.end_date,
preset=payload.preset,
granularity=payload.granularity,
timezone=payload.timezone,
tz_offset_minutes=payload.tz_offset_minutes,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _build_filters(payload: AnalyticsFiltersPayload) -> AnalyticsFilters:
return AnalyticsFilters(
user_ids=payload.user_ids,
provider_names=payload.provider_names,
models=payload.models,
target_models=payload.target_models,
api_key_ids=payload.api_key_ids,
api_formats=payload.api_formats,
request_types=payload.request_types,
statuses=payload.statuses,
error_categories=payload.error_categories,
is_stream=payload.is_stream,
has_format_conversion=payload.has_format_conversion,
)
def _build_search(payload: AnalyticsSearchPayload) -> AnalyticsSearch:
return AnalyticsSearch(
text=payload.text.strip() if payload.text else None,
request_id=payload.request_id.strip() if payload.request_id else None,
)
def _validate_scope(current_user: User, scope: AnalyticsScopePayload) -> None:
if current_user.role != UserRole.ADMIN and scope.kind != "me":
raise HTTPException(status_code=403, detail="Only admin can access non-personal analytics scope")
if scope.kind == "user" and not scope.user_id:
raise HTTPException(status_code=400, detail="scope.user_id is required for user scope")
if scope.kind == "api_key" and not scope.api_key_id:
raise HTTPException(status_code=400, detail="scope.api_key_id is required for api_key scope")
def _redact_sensitive_cost_fields(payload: object) -> object:
if isinstance(payload, list):
return [_redact_sensitive_cost_fields(item) for item in payload]
if isinstance(payload, dict):
redacted = {
key: _redact_sensitive_cost_fields(value)
for key, value in payload.items()
}
if "providers" in redacted:
redacted["providers"] = []
if "provider_health" in redacted:
redacted["provider_health"] = []
if "provider_name" in redacted:
redacted["provider_name"] = None
if "provider_api_key_name" in redacted:
redacted["provider_api_key_name"] = None
if "rate_multiplier" in redacted:
redacted["rate_multiplier"] = 1.0
for key in list(redacted.keys()):
if key.startswith("actual_") and key.endswith("_usd"):
redacted[key] = 0.0
return redacted
return payload
def _ensure_non_admin_breakdown_allowed(
current_user: User,
body: AnalyticsBreakdownRequest,
) -> None:
if current_user.role == UserRole.ADMIN:
return
if body.dimension == "provider":
raise HTTPException(status_code=403, detail="Only admin can access provider breakdown")
if body.metric == "actual_total_cost_usd":
raise HTTPException(
status_code=403,
detail="Only admin can access actual cost breakdown",
)
def _resolve_scope_targets(
current_user: User,
scope: AnalyticsScopePayload,
*,
user_id: str | None = None,
api_key_id: str | None = None,
) -> tuple[str | None, str | None]:
_validate_scope(current_user, scope)
resolved_user_id = user_id
resolved_api_key_id = api_key_id
if scope.kind == "me":
current_user_id = str(current_user.id)
if resolved_user_id not in {None, current_user_id}:
raise HTTPException(status_code=400, detail="scope.kind=me cannot target another user")
resolved_user_id = current_user_id
elif scope.kind == "user":
if resolved_user_id not in {None, scope.user_id}:
raise HTTPException(status_code=400, detail="scope.user_id conflicts with user_id")
resolved_user_id = scope.user_id
elif scope.kind == "api_key":
if resolved_api_key_id not in {None, scope.api_key_id}:
raise HTTPException(status_code=400, detail="scope.api_key_id conflicts with api_key_id")
resolved_api_key_id = scope.api_key_id
return resolved_user_id, resolved_api_key_id
def _normalize_optional_ids(ids: list[str]) -> list[str]:
normalized = [item.strip() for item in ids if item and item.strip()]
return list(dict.fromkeys(normalized))
@router.post("/overview")
async def analytics_overview(
body: AnalyticsBaseRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.overview(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
)
result = {
"query_context": {
"scope": body.scope.model_dump(),
"time_range": body.time_range.model_dump(),
},
**result,
}
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
@router.post("/timeseries")
async def analytics_timeseries(
body: AnalyticsBaseRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.timeseries(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
)
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
@router.post("/breakdown")
async def analytics_breakdown(
body: AnalyticsBreakdownRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
_ensure_non_admin_breakdown_allowed(current_user, body)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.breakdown(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
dimension=body.dimension,
metric=body.metric,
limit=body.limit,
)
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
@router.post("/records")
async def analytics_records(
body: AnalyticsRecordsRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.records(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
search=_build_search(body.search),
limit=body.pagination.limit,
offset=body.pagination.offset,
)
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
@router.post("/filter-options")
async def analytics_filter_options(
body: AnalyticsBaseRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.filter_options(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
)
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
@router.post("/heatmap")
async def analytics_heatmap(
body: AnalyticsHeatmapRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
current_user,
body.scope,
user_id=body.user_id,
api_key_id=body.api_key_id,
)
result = await UsageService.get_cached_heatmap(
db=db,
user_id=None if resolved_user_id == DELETED_USER_FILTER else resolved_user_id,
api_key_id=None if resolved_api_key_id == DELETED_API_KEY_FILTER else resolved_api_key_id,
deleted_user_only=resolved_user_id == DELETED_USER_FILTER,
deleted_api_key_only=resolved_api_key_id == DELETED_API_KEY_FILTER,
include_actual_cost=current_user.role == UserRole.ADMIN,
)
return result if current_user.role == UserRole.ADMIN else _redact_sensitive_cost_fields(result)
@router.post("/active-requests")
async def analytics_active_requests(
body: AnalyticsActiveRequestsRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(current_user, body.scope)
requests = UsageService.get_active_requests_status(
db=db,
ids=_normalize_optional_ids(body.ids) or None,
user_id=resolved_user_id,
api_key_id=resolved_api_key_id,
include_admin_fields=current_user.role == UserRole.ADMIN,
maintain_status=True,
)
payload = {"requests": requests}
return payload if current_user.role == UserRole.ADMIN else _redact_sensitive_cost_fields(payload)
@router.post("/interval-timeline")
async def analytics_interval_timeline(
body: AnalyticsIntervalTimelineRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
current_user,
body.scope,
user_id=body.user_id,
)
if resolved_api_key_id:
raise HTTPException(status_code=400, detail="interval timeline does not support api_key scope")
if body.include_user_info and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only admin can request user timeline labels")
return UsageService.get_interval_timeline(
db=db,
hours=body.hours,
limit=body.limit,
user_id=resolved_user_id,
include_user_info=body.include_user_info and resolved_user_id is None,
)
@router.post("/cache-affinity/ttl-analysis")
async def analytics_cache_affinity_ttl_analysis(
body: AnalyticsCacheAffinityRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
current_user,
body.scope,
user_id=body.user_id,
api_key_id=body.api_key_id,
)
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only admin can access cache affinity analysis")
return UsageService.analyze_cache_affinity_ttl(
db=db,
user_id=resolved_user_id,
api_key_id=resolved_api_key_id,
hours=body.hours,
)
@router.post("/cache-affinity/hit-analysis")
async def analytics_cache_affinity_hit_analysis(
body: AnalyticsCacheAffinityRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, Any]:
resolved_user_id, resolved_api_key_id = _resolve_scope_targets(
current_user,
body.scope,
user_id=body.user_id,
api_key_id=body.api_key_id,
)
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only admin can access cache affinity analysis")
return UsageService.get_cache_hit_analysis(
db=db,
user_id=resolved_user_id,
api_key_id=resolved_api_key_id,
hours=body.hours,
)
@router.post("/leaderboard")
async def analytics_leaderboard(
body: AnalyticsLeaderboardRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.leaderboard(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
entity=body.entity,
metric=body.metric,
limit=body.limit,
)
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
@router.post("/performance")
async def analytics_performance(
body: AnalyticsBaseRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
_validate_scope(current_user, body.scope)
time_range = _build_time_range(body.time_range)
result = AnalyticsQueryService.performance(
db,
current_user,
time_range=time_range,
scope_kind=body.scope.kind,
scope_user_id=body.scope.user_id,
scope_api_key_id=body.scope.api_key_id,
filters=_build_filters(body.filters),
)
return (
result
if current_user.role == UserRole.ADMIN
else _redact_sensitive_cost_fields(result)
)
-3
View File
@@ -770,9 +770,6 @@ class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
"role": user.role.value, "role": user.role.value,
"is_active": user.is_active, "is_active": user.is_active,
"billing": WalletService.serialize_wallet_summary(wallet), "billing": WalletService.serialize_wallet_summary(wallet),
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"auth_source": user.auth_source.value, "auth_source": user.auth_source.value,
+41 -1
View File
@@ -73,7 +73,47 @@ async def _set_cached_models(
logger.warning(f"[ModelsService] 缓存写入失败: {e}") logger.warning(f"[ModelsService] 缓存写入失败: {e}")
__all__ = ["AccessRestrictions", "invalidate_models_list_cache", "ModelInfo"] _PUBLIC_GLOBAL_MODEL_CONFIG_KEYS = (
"description",
"icon_url",
"streaming",
"vision",
"function_calling",
"extended_thinking",
"image_generation",
"structured_output",
"family",
"knowledge_cutoff",
"input_modalities",
"output_modalities",
"context_limit",
"output_limit",
)
def sanitize_public_global_model_config(raw_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return only user-safe GlobalModel config keys.
Internal routing fields such as ``model_mappings`` should never be exposed
through public or user-facing model catalog APIs.
"""
if not isinstance(raw_config, dict):
return None
sanitized = {
key: raw_config[key]
for key in _PUBLIC_GLOBAL_MODEL_CONFIG_KEYS
if key in raw_config
}
return sanitized or None
__all__ = [
"AccessRestrictions",
"invalidate_models_list_cache",
"ModelInfo",
"sanitize_public_global_model_config",
]
@dataclass @dataclass
+2 -4
View File
@@ -35,10 +35,8 @@ from .context import ApiRequestContext
# 高频轮询端点,抑制其 debug 日志以减少噪音 # 高频轮询端点,抑制其 debug 日志以减少噪音
QUIET_POLLING_PATHS: set[str] = { QUIET_POLLING_PATHS: set[str] = {
"/api/admin/usage/active", "/api/analytics/active-requests",
"/api/admin/usage/records", "/api/analytics/records",
"/api/admin/usage/stats",
"/api/admin/usage/aggregation/stats",
"/api/admin/health/status", "/api/admin/health/status",
"/api/wallet/today-cost", "/api/wallet/today-cost",
} }
-10
View File
@@ -1,10 +0,0 @@
'"""Dashboard API routers."""'
from fastapi import APIRouter
from .routes import router as dashboard_router
router = APIRouter()
router.include_router(dashboard_router)
__all__ = ["router"]
File diff suppressed because it is too large Load Diff
+13 -51
View File
@@ -10,12 +10,13 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func, or_ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, joinedload, load_only from sqlalchemy.orm import Session, joinedload, load_only
from src.api.base.adapter import ApiAdapter, ApiMode from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.models_service import sanitize_public_global_model_config
from src.api.base.pipeline import get_pipeline from src.api.base.pipeline import get_pipeline
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.logger import logger from src.core.logger import logger
@@ -82,7 +83,7 @@ def get_site_info(db: Session = Depends(get_db)) -> dict[str, str]:
} }
@router.get("/providers", response_model=list[PublicProviderResponse]) @router.get("/providers", response_model=list[PublicProviderResponse], include_in_schema=False)
async def get_public_providers( async def get_public_providers(
request: Request, request: Request,
is_active: bool | None = Query(None, description="过滤活跃状态"), is_active: bool | None = Query(None, description="过滤活跃状态"),
@@ -90,38 +91,12 @@ async def get_public_providers(
limit: int = Query(100, description="返回记录数限制"), limit: int = Query(100, description="返回记录数限制"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Any: ) -> Any:
""" raise HTTPException(status_code=404, detail="Not Found")
获取提供商列表(用户视图)
返回系统中可用的提供商列表,包含提供商的基本信息和统计数据。
默认只返回活跃的提供商。
**查询参数**
- is_active: 可选,过滤活跃状态。None 表示只返回活跃提供商,True 返回活跃,False 返回非活跃
- skip: 跳过的记录数,用于分页,默认 0
- limit: 返回记录数限制,默认 100,最大 100
**返回字段**
- id: 提供商唯一标识符
- name: 提供商名称(英文标识)
- display_name: 提供商显示名称
- description: 提供商描述信息
- is_active: 是否活跃
- provider_priority: 提供商优先级
- models_count: 该提供商下的模型总数
- active_models_count: 该提供商下活跃的模型数
- endpoints_count: 该提供商下的端点总数
- active_endpoints_count: 该提供商下活跃的端点数
"""
adapter = PublicProvidersAdapter(is_active=is_active, skip=skip, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@router.get("/models", response_model=list[PublicModelResponse]) @router.get("/models", response_model=list[PublicModelResponse])
async def get_public_models( async def get_public_models(
request: Request, request: Request,
provider_id: str | None = Query(None, description="提供商ID过滤"),
is_active: bool | None = Query(None, description="过滤活跃状态"), is_active: bool | None = Query(None, description="过滤活跃状态"),
skip: int = Query(0, description="跳过记录数"), skip: int = Query(0, description="跳过记录数"),
limit: int = Query(100, description="返回记录数限制"), limit: int = Query(100, description="返回记录数限制"),
@@ -157,9 +132,7 @@ async def get_public_models(
- supports_streaming: 是否支持流式输出 - supports_streaming: 是否支持流式输出
- is_active: 是否活跃 - is_active: 是否活跃
""" """
adapter = PublicModelsAdapter( adapter = PublicModelsAdapter(is_active=is_active, skip=skip, limit=limit)
provider_id=provider_id, is_active=is_active, skip=skip, limit=limit
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@@ -186,7 +159,6 @@ async def get_public_stats(request: Request, db: Session = Depends(get_db)) -> A
async def search_models( async def search_models(
request: Request, request: Request,
q: str = Query(..., description="搜索关键词"), q: str = Query(..., description="搜索关键词"),
provider_id: int | None = Query(None, description="提供商ID过滤"),
limit: int = Query(20, description="返回记录数限制"), limit: int = Query(20, description="返回记录数限制"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Any: ) -> Any:
@@ -221,7 +193,7 @@ async def search_models(
- supports_streaming: 是否支持流式输出 - supports_streaming: 是否支持流式输出
- is_active: 是否活跃 - is_active: 是否活跃
""" """
adapter = PublicSearchModelsAdapter(query=q, provider_id=provider_id, limit=limit) adapter = PublicSearchModelsAdapter(query=q, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=ApiMode.PUBLIC)
@@ -425,7 +397,6 @@ class PublicProvidersAdapter(PublicApiAdapter):
@dataclass @dataclass
class PublicModelsAdapter(PublicApiAdapter): class PublicModelsAdapter(PublicApiAdapter):
provider_id: str | None
is_active: bool | None is_active: bool | None
skip: int skip: int
limit: int limit: int
@@ -434,13 +405,13 @@ class PublicModelsAdapter(PublicApiAdapter):
key_prefix="public:catalog:models", key_prefix="public:catalog:models",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION, ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False, user_specific=False,
vary_by=["provider_id", "is_active", "skip", "limit"], vary_by=["is_active", "skip", "limit"],
) )
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug("公共API请求模型列表") logger.debug("公共API请求模型列表")
query = ( query = (
db.query(Model, Provider) db.query(Model)
.options(joinedload(Model.global_model)) .options(joinedload(Model.global_model))
.join(Provider) .join(Provider)
.filter( .filter(
@@ -450,19 +421,15 @@ class PublicModelsAdapter(PublicApiAdapter):
) )
) )
) )
if self.provider_id is not None:
query = query.filter(Model.provider_id == self.provider_id)
results = query.offset(self.skip).limit(self.limit).all() results = query.offset(self.skip).limit(self.limit).all()
response = [] response = []
for model, provider in results: for model in results:
global_model = model.global_model global_model = model.global_model
display_name = global_model.display_name if global_model else model.provider_model_name display_name = global_model.display_name if global_model else model.provider_model_name
unified_name = global_model.name if global_model else model.provider_model_name unified_name = global_model.name if global_model else model.provider_model_name
model_data = PublicModelResponse( model_data = PublicModelResponse(
id=model.id, id=model.id,
provider_id=model.provider_id,
provider_name=provider.name,
name=unified_name, name=unified_name,
display_name=display_name, display_name=display_name,
description=( description=(
@@ -541,20 +508,19 @@ class PublicStatsAdapter(PublicApiAdapter):
@dataclass @dataclass
class PublicSearchModelsAdapter(PublicApiAdapter): class PublicSearchModelsAdapter(PublicApiAdapter):
query: str query: str
provider_id: int | None
limit: int limit: int
@cache_result( @cache_result(
key_prefix="public:catalog:search_models", key_prefix="public:catalog:search_models",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION, ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False, user_specific=False,
vary_by=["query", "provider_id", "limit"], vary_by=["query", "limit"],
) )
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug(f"公共API搜索模型: {self.query}") logger.debug(f"公共API搜索模型: {self.query}")
query_stmt = ( query_stmt = (
db.query(Model, Provider) db.query(Model)
.options(joinedload(Model.global_model)) .options(joinedload(Model.global_model))
.join(Provider) .join(Provider)
.outerjoin(GlobalModel, Model.global_model_id == GlobalModel.id) .outerjoin(GlobalModel, Model.global_model_id == GlobalModel.id)
@@ -571,19 +537,15 @@ class PublicSearchModelsAdapter(PublicApiAdapter):
| GlobalModel.display_name.ilike(f"%{self.query}%") | GlobalModel.display_name.ilike(f"%{self.query}%")
) )
query_stmt = query_stmt.filter(search_filter) query_stmt = query_stmt.filter(search_filter)
if self.provider_id is not None:
query_stmt = query_stmt.filter(Model.provider_id == self.provider_id)
results = query_stmt.limit(self.limit).all() results = query_stmt.limit(self.limit).all()
response = [] response = []
for model, provider in results: for model in results:
global_model = model.global_model global_model = model.global_model
display_name = global_model.display_name if global_model else model.provider_model_name display_name = global_model.display_name if global_model else model.provider_model_name
unified_name = global_model.name if global_model else model.provider_model_name unified_name = global_model.name if global_model else model.provider_model_name
model_data = PublicModelResponse( model_data = PublicModelResponse(
id=model.id, id=model.id,
provider_id=model.provider_id,
provider_name=provider.name,
name=unified_name, name=unified_name,
display_name=display_name, display_name=display_name,
description=( description=(
@@ -832,7 +794,7 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
default_price_per_request=gm.default_price_per_request, default_price_per_request=gm.default_price_per_request,
default_tiered_pricing=gm.default_tiered_pricing, default_tiered_pricing=gm.default_tiered_pricing,
supported_capabilities=gm.supported_capabilities, supported_capabilities=gm.supported_capabilities,
config=gm.config, config=sanitize_public_global_model_config(gm.config),
) )
) )
+3 -2
View File
@@ -33,6 +33,7 @@ from src.services.auth.service import AuthService
from src.services.provider.format import normalize_endpoint_signature from src.services.provider.format import normalize_endpoint_signature
router = APIRouter(tags=["System Catalog"]) router = APIRouter(tags=["System Catalog"])
OPENAI_MODEL_OWNER = "aether"
# 各格式对应的 API 格式列表(包括对应的 CLI 格式) # 各格式对应的 API 格式列表(包括对应的 CLI 格式)
_CLAUDE_FORMATS = ["claude:chat", "claude:cli"] _CLAUDE_FORMATS = ["claude:chat", "claude:cli"]
@@ -274,7 +275,7 @@ def _build_openai_list_response(models: list[ModelInfo]) -> dict:
"id": m.id, "id": m.id,
"object": "model", "object": "model",
"created": m.created_timestamp, "created": m.created_timestamp,
"owned_by": m.provider_name, "owned_by": OPENAI_MODEL_OWNER,
} }
for m in models for m in models
] ]
@@ -339,7 +340,7 @@ def _build_openai_model_response(model_info: ModelInfo) -> dict:
"id": model_info.id, "id": model_info.id,
"object": "model", "object": "model",
"created": model_info.created_timestamp, "created": model_info.created_timestamp,
"owned_by": model_info.provider_name, "owned_by": OPENAI_MODEL_OWNER,
} }
+14 -295
View File
@@ -9,95 +9,17 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import httpx from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session, load_only, selectinload from sqlalchemy.orm import Session
from src.api.handlers.base.request_builder import (
PassthroughRequestBuilder,
build_test_request_body,
get_provider_auth,
)
from src.clients.redis_client import get_redis_client from src.clients.redis_client import get_redis_client
from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.database.database import get_pool_status from src.database.database import get_pool_status
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Model, Provider
from src.services.provider.transport import build_provider_url
from src.utils.ssl_utils import get_ssl_context
router = APIRouter(tags=["System Catalog"]) router = APIRouter(tags=["System Catalog"])
# ============== 辅助函数 ==============
def _as_bool(value: str | None, default: bool) -> bool:
"""将字符串转换为布尔值"""
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def _serialize_provider(
provider: Provider,
include_models: bool,
include_endpoints: bool,
) -> dict[str, Any]:
"""序列化 Provider 对象"""
provider_data: dict[str, Any] = {
"id": provider.id,
"name": provider.name,
"is_active": provider.is_active,
"provider_priority": provider.provider_priority,
}
if include_endpoints:
provider_data["endpoints"] = [
{
"id": endpoint.id,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format if endpoint.api_format else None,
"is_active": endpoint.is_active,
}
for endpoint in provider.endpoints or []
]
if include_models:
provider_data["models"] = [
{
"id": model.id,
"name": (
model.global_model.name if model.global_model else model.provider_model_name
),
"display_name": (
model.global_model.display_name
if model.global_model
else model.provider_model_name
),
"is_active": model.is_active,
"supports_streaming": model.supports_streaming,
}
for model in provider.models or []
if model.is_active
]
return provider_data
def _select_provider(db: Session, provider_name: str | None) -> Provider | None:
"""选择 Provider(按 provider_priority 优先级选择)"""
query = db.query(Provider).filter(Provider.is_active.is_(True))
if provider_name:
provider = query.filter(Provider.name == provider_name).first()
if provider:
return provider
# 按优先级选择(provider_priority 最小的优先)
return query.order_by(Provider.provider_priority.asc()).first()
# ============== 端点 ============== # ============== 端点 ==============
@@ -161,234 +83,31 @@ async def health_check() -> Any:
@router.get("/") @router.get("/")
async def root(db: Session = Depends(get_db)) -> Any: async def root() -> Any:
"""Root endpoint - 服务信息概览""" """Root endpoint - 服务信息概览"""
# 按优先级选择最高优先级的提供商
top_provider = (
db.query(Provider)
.options(load_only(Provider.id, Provider.name, Provider.provider_priority))
.filter(Provider.is_active.is_(True))
.order_by(Provider.provider_priority.asc())
.first()
)
active_providers = (
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
)
return { return {
"message": "AI Proxy with Modular Architecture v4.0.0", "message": "AI Proxy with Modular Architecture v4.0.0",
"status": "running", "status": "running",
"current_provider": top_provider.name if top_provider else "None",
"available_providers": active_providers,
"config": {}, "config": {},
"endpoints": { "endpoints": {
"messages": "/v1/messages", "messages": "/v1/messages",
"count_tokens": "/v1/messages/count_tokens", "count_tokens": "/v1/messages/count_tokens",
"health": "/v1/health", "health": "/v1/health",
"providers": "/v1/providers",
"test_connection": "/v1/test-connection",
}, },
} }
@router.get("/v1/providers") @router.get("/v1/providers", include_in_schema=False)
async def list_providers( async def list_providers() -> Any:
db: Session = Depends(get_db), raise HTTPException(status_code=404, detail="Not Found")
include_models: bool = Query(False),
include_endpoints: bool = Query(False),
active_only: bool = Query(True),
) -> Any:
"""列出所有 Provider"""
load_options = [
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
]
if include_models:
load_options.append(
selectinload(Provider.models)
.load_only(
Model.id,
Model.provider_model_name,
Model.is_active,
Model.supports_streaming,
Model.global_model_id,
)
.selectinload(Model.global_model)
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
)
if include_endpoints:
load_options.append(
selectinload(Provider.endpoints).load_only(
ProviderEndpoint.id,
ProviderEndpoint.base_url,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
base_query = db.query(Provider)
if load_options:
base_query = base_query.options(*load_options)
if active_only:
base_query = base_query.filter(Provider.is_active.is_(True))
base_query = base_query.order_by(Provider.provider_priority.asc(), Provider.name.asc())
providers = base_query.all()
return {
"providers": [
_serialize_provider(provider, include_models, include_endpoints)
for provider in providers
]
}
@router.get("/v1/providers/{provider_identifier}") @router.get("/v1/providers/{provider_identifier}", include_in_schema=False)
async def provider_detail( async def provider_detail(provider_identifier: str) -> Any:
provider_identifier: str, raise HTTPException(status_code=404, detail="Not Found")
db: Session = Depends(get_db),
include_models: bool = Query(False),
include_endpoints: bool = Query(False),
) -> Any:
"""获取单个 Provider 详情"""
load_options = [
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
]
if include_models:
load_options.append(
selectinload(Provider.models)
.load_only(
Model.id,
Model.provider_model_name,
Model.is_active,
Model.supports_streaming,
Model.global_model_id,
)
.selectinload(Model.global_model)
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
)
if include_endpoints:
load_options.append(
selectinload(Provider.endpoints).load_only(
ProviderEndpoint.id,
ProviderEndpoint.base_url,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
base_query = db.query(Provider)
if load_options:
base_query = base_query.options(*load_options)
provider = base_query.filter(
(Provider.id == provider_identifier) | (Provider.name == provider_identifier)
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
return _serialize_provider(provider, include_models, include_endpoints)
@router.get("/v1/test-connection") @router.get("/v1/test-connection", include_in_schema=False)
@router.get("/test-connection") @router.get("/test-connection", include_in_schema=False)
async def test_connection( async def test_connection() -> Any:
request: Request, raise HTTPException(status_code=404, detail="Not Found")
db: Session = Depends(get_db),
provider: str | None = Query(None),
model: str = Query("claude-3-haiku-20240307"),
api_format: str | None = Query(None),
) -> Any:
"""测试 Provider 连接"""
selected_provider = _select_provider(db, provider)
if not selected_provider:
raise HTTPException(status_code=503, detail="No active provider available")
# Determine endpoint format: prefer explicit api_format; otherwise use the provider's first active endpoint.
active_endpoints: list[ProviderEndpoint] = [
ep for ep in (selected_provider.endpoints or []) if getattr(ep, "is_active", False)
]
if not active_endpoints:
raise HTTPException(status_code=503, detail="Provider has no active endpoints")
if api_format:
endpoint = next(
(ep for ep in active_endpoints if (ep.api_format or "") == api_format),
None,
)
if not endpoint:
raise HTTPException(
status_code=400,
detail=f"Provider has no active endpoint for api_format={api_format}",
)
format_value = api_format
else:
endpoint = active_endpoints[0]
format_value = endpoint.api_format or "claude:chat"
# Pick an active ProviderAPIKey that supports this format (best-effort).
active_keys: list[ProviderAPIKey] = [
k for k in (selected_provider.api_keys or []) if getattr(k, "is_active", False)
]
if not active_keys:
raise HTTPException(status_code=503, detail="Provider has no active api keys")
def _key_supports_format(k: ProviderAPIKey) -> bool:
formats = getattr(k, "api_formats", None)
# None => supports all formats; [] => supports none
if formats is None:
return True
if isinstance(formats, list):
return str(format_value) in {str(x) for x in formats}
# unexpected type: be permissive
return True
key = next((k for k in active_keys if _key_supports_format(k)), active_keys[0])
# Build a safe test request body in the endpoint's format (via format conversion registry).
payload = build_test_request_body(
format_value,
request_data={
"model": model,
"messages": [{"role": "user", "content": "Health check"}],
"max_tokens": 5,
},
)
try:
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
request_builder = PassthroughRequestBuilder()
provider_payload, provider_headers = request_builder.build(
payload,
{},
endpoint,
key,
is_stream=False,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
url = build_provider_url(
endpoint,
query_params=dict(request.query_params),
path_params={"model": model},
is_stream=False,
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
resp = await client.post(url, json=provider_payload, headers=provider_headers)
resp.raise_for_status()
response = resp.json()
return {
"status": "success",
"provider": selected_provider.name,
"endpoint_id": getattr(endpoint, "id", None),
"api_format": format_value,
"timestamp": datetime.now(timezone.utc).isoformat(),
"response_id": response.get("id", "unknown"),
}
except Exception as exc:
logger.error(f"API connectivity test failed: {exc}")
raise HTTPException(status_code=503, detail=str(exc))
+3 -794
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.models_service import sanitize_public_global_model_config
from src.api.base.pipeline import get_pipeline from src.api.base.pipeline import get_pipeline
from src.config.constants import CacheTTL
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.enums import UserRole from src.core.enums import UserRole
from src.core.exceptions import ( from src.core.exceptions import (
@@ -32,7 +32,6 @@ from src.models.api import (
CreateMyApiKeyRequest, CreateMyApiKeyRequest,
PublicGlobalModelListResponse, PublicGlobalModelListResponse,
PublicGlobalModelResponse, PublicGlobalModelResponse,
UpdateApiKeyProvidersRequest,
UpdateMyApiKeyRequest, UpdateMyApiKeyRequest,
UpdatePreferencesRequest, UpdatePreferencesRequest,
UpdateProfileRequest, UpdateProfileRequest,
@@ -51,33 +50,14 @@ from src.models.database import (
from src.services.auth.session_service import SessionService from src.services.auth.session_service import SessionService
from src.services.cache.user_cache import UserCacheService from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.system.time_range import TimeRangeParams
from src.services.usage.query import input_context_expr
from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.bulk_cleanup import pre_clean_api_key from src.services.user.bulk_cleanup import pre_clean_api_key
from src.services.user.preference import PreferenceService from src.services.user.preference import PreferenceService
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/users/me", tags=["User Profile"]) router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
pipeline = get_pipeline() pipeline = get_pipeline()
def _calculate_token_cache_hit_rate(total_input_context: int, cache_read_tokens: int) -> float:
"""计算缓存命中率。
Args:
total_input_context: 已归一化的总输入上下文 token 数
(由 query.py 的 input_context_expr() 统一计算,为 input + cache_read)。
cache_read_tokens: 缓存读取 token 数。
"""
context = max(0, int(total_input_context))
cached = max(0, int(cache_read_tokens))
if context == 0:
return 0.0
return round(cached / context * 100, 2)
def _update_profile_sync( def _update_profile_sync(
user_id: str, user_id: str,
@@ -292,7 +272,6 @@ def _update_my_api_key_sync(
"key_display": updated.get_display_key(), "key_display": updated.get_display_key(),
"is_active": updated.is_active, "is_active": updated.is_active,
"is_locked": updated.is_locked, "is_locked": updated.is_locked,
"allowed_providers": updated.allowed_providers,
"force_capabilities": updated.force_capabilities, "force_capabilities": updated.force_capabilities,
"rate_limit": updated.rate_limit, "rate_limit": updated.rate_limit,
"last_used_at": updated.last_used_at.isoformat() if updated.last_used_at else None, "last_used_at": updated.last_used_at.isoformat() if updated.last_used_at else None,
@@ -302,41 +281,6 @@ def _update_my_api_key_sync(
} }
def _update_api_key_providers_sync(
user_id: str,
api_key_id: str,
request: UpdateApiKeyProvidersRequest,
) -> dict[str, str]:
with get_db_context() as db:
api_key = (
db.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.user_id == user_id).first()
)
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
if request.allowed_providers is not None and len(request.allowed_providers) > 0:
provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
valid = (
db.query(Provider.id)
.filter(Provider.id.in_(provider_ids), Provider.is_active.is_(True))
.all()
)
valid_ids = {p.id for p in valid}
invalid = set(provider_ids) - valid_ids
if invalid:
raise InvalidRequestException(f"无效的提供商ID: {', '.join(invalid)}")
api_key.allowed_providers = (
[cfg.provider_id for cfg in request.allowed_providers]
if request.allowed_providers is not None
else None
)
api_key.updated_at = datetime.now(timezone.utc)
return {"message": "API密钥可用提供商已更新"}
def _update_api_key_capabilities_sync( def _update_api_key_capabilities_sync(
user_id: str, user_id: str,
api_key_id: str, api_key_id: str,
@@ -397,7 +341,6 @@ def _update_preferences_sync(user_id: str, request: UpdatePreferencesRequest) ->
user_id=user_id, user_id=user_id,
avatar_url=request.avatar_url, avatar_url=request.avatar_url,
bio=request.bio, bio=request.bio,
default_provider_id=request.default_provider_id,
theme=request.theme, theme=request.theme,
language=request.language, language=request.language,
timezone=request.timezone, timezone=request.timezone,
@@ -459,29 +402,6 @@ def _update_model_capability_settings_sync(
"message": "模型能力配置已更新", "message": "模型能力配置已更新",
"model_capability_settings": user.model_capability_settings, "model_capability_settings": user.model_capability_settings,
}, user.email }, user.email
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("") @router.get("")
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any: async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
""" """
@@ -661,109 +581,6 @@ async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 使用统计 ==============
@router.get("/usage")
async def get_my_usage(
request: Request,
start_date: date | None = Query(None, description="开始日期(YYYY-MM-DD)"),
end_date: date | None = Query(None, description="结束日期(YYYY-MM-DD)"),
preset: str | None = Query(None, description="时间预设(today/last7days 等)"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
search: str | None = Query(None, description="搜索关键词(密钥名、模型名)"),
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
db: Session = Depends(get_db),
) -> Any:
"""
获取使用统计
获取当前用户的 API 使用统计数据,包括总量汇总、按模型/提供商分组统计及详细记录。
**返回字段**:
- `total_requests`: 总请求数
- `total_tokens`: 总 Token 数
- `total_cost`: 总成本(USD)
- `summary_by_model`: 按模型分组统计(含 `cache_read_tokens`、`cache_hit_rate`)
- `summary_by_provider`: 按提供商分组统计(含 `cache_read_tokens`、`cache_hit_rate`)
- `records`: 详细使用记录列表
- `pagination`: 分页信息
"""
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = GetUsageAdapter(time_range=time_range, search=search, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/usage/active")
async def get_my_active_requests(
request: Request,
ids: str | None = Query(None, description="请求 ID 列表,逗号分隔"),
db: Session = Depends(get_db),
) -> Any:
"""
获取活跃请求状态
查询正在进行中的请求状态,用于前端轮询更新流式请求的进度。
**查询参数**:
- `ids`: 要查询的请求 ID 列表,逗号分隔
"""
adapter = GetActiveRequestsAdapter(ids=ids)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/usage/interval-timeline")
async def get_my_interval_timeline(
request: Request,
hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"),
limit: int = Query(2000, ge=100, le=20000, description="最大返回数据点数量"),
db: Session = Depends(get_db),
) -> Any:
"""
获取请求间隔时间线
获取请求间隔时间线数据,用于散点图展示请求分布情况。
**返回**: 包含时间戳和间隔时间的数据点列表
"""
adapter = GetMyIntervalTimelineAdapter(hours=hours, limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/usage/heatmap")
async def get_my_activity_heatmap(
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取活动热力图数据
获取过去 365 天的活动热力图数据,用于展示每日使用频率。
此接口有 5 分钟缓存。
**返回**: 包含日期和请求数量的数据列表
"""
adapter = GetMyActivityHeatmapAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/providers")
async def list_available_providers(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取可用提供商列表
获取当前用户可用的所有提供商及其模型信息。
**返回字段**: id, name, display_name, endpoints, models 等
"""
adapter = ListAvailableProvidersAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/available-models") @router.get("/available-models")
async def list_available_models( async def list_available_models(
request: Request, request: Request,
@@ -805,33 +622,6 @@ async def get_endpoint_status(request: Request, db: Session = Depends(get_db)) -
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== API密钥与提供商关联 ==============
# UpdateApiKeyProvidersRequest 已移至 src/models/api.py
@router.put("/api-keys/{api_key_id}/providers")
async def update_api_key_providers(
api_key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
更新 API 密钥可用提供商
设置指定 API 密钥可以使用哪些提供商。未设置时使用用户默认权限。
**路径参数**:
- `api_key_id`: API 密钥 ID
**请求体**:
- `allowed_providers`: 允许的提供商 ID 列表
"""
adapter = UpdateApiKeyProvidersAdapter(api_key_id=api_key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/api-keys/{api_key_id}/capabilities") @router.put("/api-keys/{api_key_id}/capabilities")
async def update_api_key_capabilities( async def update_api_key_capabilities(
api_key_id: str, api_key_id: str,
@@ -1099,7 +889,6 @@ class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
"total_requests": real_stats["total_requests"], "total_requests": real_stats["total_requests"],
"total_cost_usd": real_stats["total_cost_usd"], "total_cost_usd": real_stats["total_cost_usd"],
"rate_limit": key.rate_limit, "rate_limit": key.rate_limit,
"allowed_providers": key.allowed_providers,
"force_capabilities": key.force_capabilities, "force_capabilities": key.force_capabilities,
} }
) )
@@ -1178,7 +967,6 @@ class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
"key_display": api_key.get_display_key(), "key_display": api_key.get_display_key(),
"is_active": api_key.is_active, "is_active": api_key.is_active,
"is_locked": api_key.is_locked, "is_locked": api_key.is_locked,
"allowed_providers": api_key.allowed_providers,
"force_capabilities": api_key.force_capabilities, "force_capabilities": api_key.force_capabilities,
"rate_limit": api_key.rate_limit, "rate_limit": api_key.rate_limit,
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None, "last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
@@ -1228,484 +1016,6 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id) return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id)
@dataclass
class GetUsageAdapter(AuthenticatedApiAdapter):
"""获取用户使用统计的适配器"""
time_range: TimeRangeParams | None
search: str | None = None
limit: int = 100
offset: int = 0
@cache_result(
key_prefix="user:usage:records",
ttl=3, # 使用记录页强调实时性,避免 15s 缓存导致列表滞后
user_specific=True,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"search",
"limit",
"offset",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from sqlalchemy import or_
from sqlalchemy.orm import load_only
from src.models.database import ProviderEndpoint
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
db = context.db
user = context.user
start_utc = end_utc = None
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
summary_list = UsageService.get_usage_summary(
db=db,
user_id=user.id,
start_date=start_utc,
end_date=end_utc,
group_by=None,
)
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
filtered_summary = [
item
for item in summary_list
if item.get("provider") not in ("unknown", "pending", None)
]
total_requests = 0
total_input_tokens = 0
total_output_tokens = 0
total_tokens = 0
total_cost = 0.0
total_actual_cost = 0.0
model_summary = {}
provider_summary = {}
for item in filtered_summary:
total_requests += item["requests"]
total_input_tokens += item["input_tokens"]
total_output_tokens += item["output_tokens"]
total_tokens += item["total_tokens"]
total_cost += item["total_cost_usd"]
if user.role == UserRole.ADMIN:
total_actual_cost += item.get("actual_total_cost_usd", 0.0)
model_name = item["model"]
base_stats = {
"model": model_name,
"requests": 0,
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"cache_read_tokens": 0,
"cache_creation_tokens": 0,
"total_input_context": 0,
"cache_hit_rate": 0.0,
"total_cost_usd": 0.0,
}
# 管理员可以看到真实成本
if user.role == UserRole.ADMIN:
base_stats["actual_total_cost_usd"] = 0.0
stats = model_summary.setdefault(model_name, base_stats)
stats["requests"] += item["requests"]
stats["input_tokens"] += item["input_tokens"]
stats["output_tokens"] += item["output_tokens"]
stats["total_tokens"] += item["total_tokens"]
stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
stats["cache_creation_tokens"] += int(item.get("cache_creation_tokens", 0) or 0)
stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
stats["total_cost_usd"] += item["total_cost_usd"]
# 管理员可以看到真实成本
if user.role == UserRole.ADMIN:
stats["actual_total_cost_usd"] += item.get("actual_total_cost_usd", 0.0)
provider_name = item["provider"]
provider_base_stats = {
"provider": provider_name,
"requests": 0,
"total_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"cache_creation_tokens": 0,
"total_input_context": 0,
"total_cost_usd": 0.0,
"success_count": 0,
"total_response_time_ms": 0.0,
"response_time_count": 0,
}
provider_stats = provider_summary.setdefault(provider_name, provider_base_stats)
provider_stats["requests"] += item["requests"]
provider_stats["total_tokens"] += item["total_tokens"]
provider_stats["output_tokens"] += item.get("output_tokens", 0) or 0
provider_stats["cache_read_tokens"] += int(item.get("cache_read_tokens", 0) or 0)
provider_stats["cache_creation_tokens"] += int(
item.get("cache_creation_tokens", 0) or 0
)
provider_stats["total_input_context"] += int(item.get("total_input_context", 0) or 0)
provider_stats["total_cost_usd"] += item["total_cost_usd"]
provider_stats["success_count"] += int(item.get("success_count", 0) or 0)
success_response_time_count = int(item.get("success_response_time_count", 0) or 0)
if success_response_time_count > 0:
provider_stats["total_response_time_ms"] += float(
item.get("success_response_time_sum_ms", 0.0) or 0.0
)
provider_stats["response_time_count"] += success_response_time_count
for model_stats in model_summary.values():
model_stats["cache_hit_rate"] = _calculate_token_cache_hit_rate(
total_input_context=int(model_stats.get("total_input_context", 0) or 0),
cache_read_tokens=int(model_stats.get("cache_read_tokens", 0) or 0),
)
summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True)
summary_by_provider = []
for provider_stats in provider_summary.values():
avg_response_time_ms = (
provider_stats["total_response_time_ms"] / provider_stats["response_time_count"]
if provider_stats["response_time_count"] > 0
else 0
)
success_rate = (
(provider_stats["success_count"] / provider_stats["requests"] * 100)
if provider_stats["requests"] > 0
else 100
)
summary_by_provider.append(
{
"provider": provider_stats["provider"],
"requests": provider_stats["requests"],
"total_tokens": provider_stats["total_tokens"],
"total_input_context": provider_stats["total_input_context"],
"output_tokens": provider_stats["output_tokens"],
"cache_read_tokens": provider_stats["cache_read_tokens"],
"cache_creation_tokens": provider_stats["cache_creation_tokens"],
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=int(provider_stats.get("total_input_context", 0) or 0),
cache_read_tokens=int(provider_stats.get("cache_read_tokens", 0) or 0),
),
"total_cost_usd": provider_stats["total_cost_usd"],
"success_rate": round(success_rate, 2),
"avg_response_time_ms": round(avg_response_time_ms, 2),
}
)
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
# 按 api_format 聚合统计(独立查询,因为 get_usage_summary 按 provider+model 分组无此维度)
api_format_query = db.query(
Usage.api_format,
func.count(Usage.id).label("request_count"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(input_context_expr()).label("total_input_context"),
func.sum(Usage.output_tokens).label("output_tokens"),
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
).filter(
Usage.user_id == user.id,
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
Usage.api_format.isnot(None),
)
if start_utc and end_utc:
api_format_query = api_format_query.filter(
Usage.created_at >= start_utc, Usage.created_at < end_utc
)
api_format_stats = (
api_format_query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).all()
)
summary_by_api_format = [
{
"api_format": api_format or "unknown",
"request_count": count,
"total_tokens": int(total_tokens or 0),
"total_input_context": int(total_input_context or 0),
"output_tokens": int(output_tokens or 0),
"cache_read_tokens": int(cache_read_tokens or 0),
"cache_creation_tokens": int(cache_creation_tokens or 0),
"cache_hit_rate": _calculate_token_cache_hit_rate(
total_input_context=total_input_context,
cache_read_tokens=cache_read_tokens,
),
"total_cost_usd": float(total_cost_usd or 0),
"avg_response_time_ms": float(avg_response_time_ms or 0),
}
for (
api_format,
count,
total_tokens,
cache_read_tokens,
total_input_context,
output_tokens,
cache_creation_tokens,
total_cost_usd,
avg_response_time_ms,
) in api_format_stats
]
query = (
db.query(Usage, ApiKey, ProviderEndpoint)
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
.filter(Usage.user_id == user.id)
)
if start_utc and end_utc:
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
# 通用搜索:密钥名、模型名
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
if self.search and self.search.strip():
keywords = [kw for kw in self.search.strip().split() if kw][:10]
for keyword in keywords:
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
search_pattern = f"%{escaped}%"
query = query.filter(
or_(
ApiKey.name.ilike(search_pattern, escape="\\"),
Usage.model.ilike(search_pattern, escape="\\"),
)
)
# 计算总数用于分页
# Perf: avoid Query.count() building a subquery selecting many columns
total_records = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
# Perf: do not load large request/response columns for list view
query = query.options(
load_only(
Usage.id,
Usage.user_id,
Usage.api_key_id,
Usage.provider_name,
Usage.model,
Usage.target_model,
Usage.input_tokens,
Usage.output_tokens,
Usage.total_tokens,
Usage.total_cost_usd,
Usage.response_time_ms,
Usage.first_byte_time_ms,
Usage.is_stream,
Usage.status,
Usage.created_at,
Usage.cache_creation_input_tokens,
Usage.cache_read_input_tokens,
Usage.status_code,
Usage.error_message,
Usage.api_format,
Usage.endpoint_api_format,
Usage.has_format_conversion,
Usage.input_price_per_1m,
Usage.output_price_per_1m,
Usage.cache_creation_price_per_1m,
Usage.cache_read_price_per_1m,
Usage.actual_total_cost_usd,
Usage.rate_multiplier,
),
load_only(ApiKey.id, ApiKey.name, ApiKey.key_encrypted),
load_only(ProviderEndpoint.id, ProviderEndpoint.api_format),
)
usage_records = (
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
)
# 复用 summary 聚合中的成功请求响应时间,避免额外 AVG SQL
total_success_response_time_ms = sum(
float(item.get("success_response_time_sum_ms", 0.0) or 0.0) for item in summary_list
)
total_success_response_count = sum(
int(item.get("success_response_time_count", 0) or 0) for item in summary_list
)
avg_response_time = (
total_success_response_time_ms / total_success_response_count / 1000.0
if total_success_response_count > 0
else 0.0
)
wallet = WalletService.get_wallet(db, user_id=user.id)
# 构建响应数据
response_data = {
"total_requests": total_requests,
"total_input_tokens": total_input_tokens,
"total_output_tokens": total_output_tokens,
"total_tokens": total_tokens,
"total_cost": total_cost,
"avg_response_time": avg_response_time,
"billing": WalletService.serialize_wallet_summary(wallet),
"summary_by_model": summary_by_model,
"summary_by_provider": summary_by_provider,
"summary_by_api_format": summary_by_api_format,
# 分页信息
"pagination": {
"total": total_records,
"limit": self.limit,
"offset": self.offset,
"has_more": self.offset + self.limit < total_records,
},
"records": self._build_usage_records(
usage_records, is_admin=(user.role == UserRole.ADMIN)
),
}
# 管理员可以看到真实成本
if user.role == UserRole.ADMIN:
response_data["total_actual_cost"] = total_actual_cost
# 为每条记录添加真实成本和倍率信息
for i, (r, _, _) in enumerate(usage_records):
# 确保字段有值,避免前端显示 -
actual_cost = (
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
)
rate_mult = r.rate_multiplier if r.rate_multiplier is not None else 1.0
response_data["records"][i]["actual_cost"] = actual_cost
response_data["records"][i]["rate_multiplier"] = rate_mult
return response_data
def _build_usage_records(self, usage_records: list, is_admin: bool = False) -> list:
"""构建使用记录列表,包含格式转换信息的回填逻辑
Args:
usage_records: 使用记录列表
is_admin: 是否为管理员,管理员可以看到模型映射信息
"""
from src.core.api_format.metadata import can_passthrough_endpoint
from src.core.api_format.signature import normalize_signature_key
records = []
for r, api_key, endpoint in usage_records:
# 格式转换追踪(兼容历史数据:尽量回填可展示信息)
api_format = r.api_format
endpoint_api_format = r.endpoint_api_format or (
endpoint.api_format if endpoint else None
)
has_format_conversion = r.has_format_conversion
if has_format_conversion is None:
# 新模式:仅对 signature 进行推断(历史旧值保持 False,避免解析失败)
client_raw = str(api_format or "").strip()
endpoint_raw = str(endpoint_api_format or "").strip()
if client_raw and endpoint_raw and ":" in client_raw and ":" in endpoint_raw:
client_fmt = normalize_signature_key(client_raw)
endpoint_fmt = normalize_signature_key(endpoint_raw)
has_format_conversion = not can_passthrough_endpoint(client_fmt, endpoint_fmt)
else:
has_format_conversion = False
records.append(
{
"id": r.id,
"model": r.model,
# 只有管理员可以看到模型映射信息,普通用户只能看到请求的模型
"target_model": r.target_model if is_admin else None,
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": bool(has_format_conversion),
"input_tokens": r.input_tokens,
"output_tokens": r.output_tokens,
"total_tokens": r.total_tokens,
"cost": float(r.total_cost_usd or 0),
"response_time_ms": r.response_time_ms,
"first_byte_time_ms": r.first_byte_time_ms,
"is_stream": r.is_stream,
"status": r.status, # 请求状态: pending, streaming, completed, failed
"created_at": r.created_at.isoformat(),
"cache_creation_input_tokens": r.cache_creation_input_tokens,
"cache_read_input_tokens": r.cache_read_input_tokens,
"status_code": r.status_code,
"error_message": r.error_message,
"input_price_per_1m": r.input_price_per_1m,
"output_price_per_1m": r.output_price_per_1m,
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
"cache_read_price_per_1m": r.cache_read_price_per_1m,
"api_key": (
{
"id": str(api_key.id),
"name": api_key.name,
"display": api_key.get_display_key(),
}
if api_key
else None
),
}
)
return records
@dataclass
class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
"""轻量级活跃请求状态查询适配器(用于用户端轮询)"""
ids: str | None = None
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.services.usage import UsageService
db = context.db
user = context.user
id_list = None
if self.ids:
id_list = [id.strip() for id in self.ids.split(",") if id.strip()]
if not id_list:
return {"requests": []}
requests = UsageService.get_active_requests_status(
db=db,
ids=id_list,
user_id=user.id,
maintain_status=True,
)
return {"requests": requests}
@dataclass
class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
"""获取当前用户的请求间隔时间线适配器"""
hours: int
limit: int
@cache_result(
key_prefix="user:usage:interval_timeline",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=True,
vary_by=["hours", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
user = context.user
result = UsageService.get_interval_timeline(
db=db,
hours=self.hours,
limit=self.limit,
user_id=str(user.id),
)
return result
class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
"""获取用户活动热力图数据的适配器(带 Redis 缓存)"""
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
user = context.user
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=user.id,
include_actual_cost=user.role == UserRole.ADMIN,
)
context.add_audit_metadata(action="activity_heatmap")
return result
@dataclass @dataclass
class ListAvailableModelsAdapter(AuthenticatedApiAdapter): class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
"""获取用户可用模型列表的适配器 """获取用户可用模型列表的适配器
@@ -1807,7 +1117,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
default_price_per_request=gm.default_price_per_request, default_price_per_request=gm.default_price_per_request,
default_tiered_pricing=gm.default_tiered_pricing, default_tiered_pricing=gm.default_tiered_pricing,
supported_capabilities=gm.supported_capabilities, supported_capabilities=gm.supported_capabilities,
config=gm.config, config=sanitize_public_global_model_config(gm.config),
usage_count=user_usage_map.get(gm.name, 0), usage_count=user_usage_map.get(gm.name, 0),
) )
for gm in models for gm in models
@@ -1914,103 +1224,6 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
return get_available_provider_ids(db, formats, provider_to_formats) return get_available_provider_ids(db, formats, provider_to_formats)
class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
"""获取可用提供商列表的适配器"""
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from sqlalchemy.orm import selectinload
db = context.db
# 使用 selectinload 预加载所有关联数据,避免 N+1 查询
providers = (
db.query(Provider)
.options(
selectinload(Provider.endpoints),
selectinload(Provider.models).selectinload(Model.global_model),
)
.filter(Provider.is_active.is_(True))
.all()
)
result = []
for provider in providers:
# 直接使用预加载的 endpoints,无需额外查询
endpoints_data = [
{
"id": ep.id,
"api_format": ep.api_format if ep.api_format else None,
"base_url": ep.base_url,
"is_active": ep.is_active,
}
for ep in provider.endpoints
]
models_data = []
# 直接使用预加载的 models,无需额外查询
direct_models = provider.models
for model in direct_models:
global_model = model.global_model
display_name = (
global_model.display_name if global_model else model.provider_model_name
)
unified_name = global_model.name if global_model else model.provider_model_name
models_data.append(
{
"id": model.id,
"name": unified_name,
"display_name": display_name,
"input_price_per_1m": model.input_price_per_1m,
"output_price_per_1m": model.output_price_per_1m,
"cache_creation_price_per_1m": model.cache_creation_price_per_1m,
"cache_read_price_per_1m": model.cache_read_price_per_1m,
"supports_vision": model.supports_vision,
"supports_function_calling": model.supports_function_calling,
"supports_streaming": model.supports_streaming,
}
)
result.append(
{
"id": provider.id,
"name": provider.name,
"description": provider.description,
"provider_priority": provider.provider_priority,
"endpoints": endpoints_data,
"models": models_data,
}
)
return result
@dataclass
class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
"""更新 API 密钥可用提供商的适配器"""
api_key_id: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
user = context.user
payload = context.ensure_json_body()
try:
request = UpdateApiKeyProvidersRequest.model_validate(payload)
except ValidationError as e:
errors = e.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
result = await run_in_threadpool(
_update_api_key_providers_sync,
user.id,
self.api_key_id,
request,
)
logger.debug(f"用户 {user.id} 更新API密钥 {self.api_key_id} 的可用提供商")
return result
@dataclass @dataclass
class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter): class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
"""更新 API Key 的强制能力配置""" """更新 API Key 的强制能力配置"""
@@ -2046,10 +1259,6 @@ class GetPreferencesAdapter(AuthenticatedApiAdapter):
return { return {
"avatar_url": preferences.avatar_url, "avatar_url": preferences.avatar_url,
"bio": preferences.bio, "bio": preferences.bio,
"default_provider_id": preferences.default_provider_id,
"default_provider": (
preferences.default_provider.name if preferences.default_provider else None
),
"theme": preferences.theme, "theme": preferences.theme,
"language": preferences.language, "language": preferences.language,
"timezone": preferences.timezone, "timezone": preferences.timezone,
+2 -2
View File
@@ -17,11 +17,11 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from src.api.admin import router as admin_router from src.api.admin import router as admin_router
from src.api.analytics import router as analytics_router
from src.api.announcements import router as announcement_router from src.api.announcements import router as announcement_router
# API路由 # API路由
from src.api.auth import router as auth_router from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.internal import router as internal_router from src.api.internal import router as internal_router
from src.api.monitoring import router as monitoring_router from src.api.monitoring import router as monitoring_router
from src.api.payment import router as payment_router from src.api.payment import router as payment_router
@@ -802,7 +802,7 @@ app.include_router(me_router) # 用户个人端点
app.include_router(wallet_router) # 钱包端点 app.include_router(wallet_router) # 钱包端点
app.include_router(payment_router) # 支付回调端点 app.include_router(payment_router) # 支付回调端点
app.include_router(announcement_router) # 公告系统 app.include_router(announcement_router) # 公告系统
app.include_router(dashboard_router) # 仪表盘端点 app.include_router(analytics_router) # 新分析域
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型) app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
app.include_router(monitoring_router) # 监控端点 app.include_router(monitoring_router) # 监控端点
app.include_router(internal_router) # Hub 本地控制面端点 app.include_router(internal_router) # Hub 本地控制面端点

Some files were not shown because too many files have changed in this diff Show More