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
-309
View File
@@ -380,102 +380,6 @@ export interface AdminApiKeysResponse {
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 {
id: string // UUID
is_active: boolean
@@ -753,197 +657,6 @@ export const adminApi = {
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'),
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'),
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
}
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 {
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
@@ -78,6 +275,11 @@ export const auditApi = {
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<{
analysis: Record<string, unknown>
-11
View File
@@ -24,13 +24,6 @@ export interface UserPreferences {
[key: string]: unknown // 允许扩展其他偏好设置
}
export interface UserStats {
total_requests?: number
total_cost?: number
last_request_at?: string
[key: string]: unknown // 允许扩展其他统计数据
}
export interface SendVerificationCodeRequest {
email: string
}
@@ -113,13 +106,9 @@ export interface User {
role: string // 'admin' or 'user'
is_active: boolean
billing?: BillingSummary
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string
last_login_at?: string
preferences?: UserPreferences
stats?: UserStats
}
export const authApi = {
+19 -3
View File
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): 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
},
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): 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
},
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
return cachedRequest(
cacheKey,
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
},
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
active: number
unhealthy: number
circuit_open: number
circuit_open?: number
}
}
-192
View File
@@ -1,7 +1,5 @@
import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session'
@@ -25,8 +23,6 @@ export interface Profile {
export interface UserPreferences {
avatar_url?: string
bio?: string
default_provider_id?: string // UUID
default_provider?: Record<string, unknown>
theme: string
language: 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 {
id: string // UUID
name: string
@@ -146,7 +45,6 @@ export interface ApiKey {
total_requests?: number
total_cost_usd?: number
rate_limit?: number | null
allowed_providers?: ProviderConfig[]
force_capabilities?: Record<string, boolean> | null // 强制能力配置
}
@@ -249,51 +147,6 @@ export const meApi = {
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?: {
skip?: number
@@ -334,16 +187,6 @@ export const meApi = {
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密钥能力配置
async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null
@@ -370,39 +213,4 @@ export const meApi = {
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`
)
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>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -18,6 +21,7 @@ import {
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -44,6 +48,7 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'bar'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'bar'> = {
responsive: true,
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
}
}
function createChart() {
if (!chartRef.value) return
function buildChartOptions(): ChartOptions<'bar'> {
const stackedOptions = props.stacked ? {
scales: {
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, {
type: 'bar',
data: props.data,
options: {
...defaultOptions,
...stackedOptions,
...props.options
}
data: resolveChartTheme(props.data),
options: buildChartOptions()
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = resolveChartTheme(props.data)
chart.update('none')
}
}
@@ -127,9 +136,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = buildChartOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -139,11 +156,8 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
chart.options = buildChartOptions()
chart.update('none')
}
}, { deep: true })
</script>
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -16,10 +19,12 @@ import {
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
options: undefined
options: undefined,
showLegend: true,
})
ChartJS.register(
@@ -34,15 +39,22 @@ interface Props {
data: ChartData<'doughnut'>
options?: ChartOptions<'doughnut'>
height?: number
showLegend?: boolean
}
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'doughnut'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'doughnut'> = {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
interaction: {
mode: 'nearest',
intersect: true,
},
plugins: {
legend: {
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() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'doughnut',
data: props.data,
options: {
...defaultOptions,
...props.options
}
data: resolveChartTheme(props.data),
options: resolvedOptions(),
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = resolveChartTheme(props.data)
chart.update('none')
}
}
@@ -94,9 +143,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = resolvedOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -106,8 +163,15 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = { ...defaultOptions, ...props.options }
chart.update()
chart.options = resolvedOptions()
chart.update('none')
}
}, { deep: true })
watch(() => props.showLegend, () => {
if (chart) {
chart.options = resolvedOptions()
chart.update('none')
}
})
</script>
+25 -6
View File
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -16,9 +19,11 @@ import {
Title,
Tooltip,
Legend,
Filler,
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -34,7 +39,8 @@ ChartJS.register(
LineController,
Title,
Tooltip,
Legend
Legend,
Filler
)
interface Props {
@@ -45,12 +51,17 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'line'> | null = null
let stopThemeObserver: (() => void) | null = null
function buildChartOptions(): ChartOptions<'line'> {
return {
return resolveChartTheme({
...defaultOptions,
...props.options
}
})
}
function buildChartData(): ChartData<'line'> {
return resolveChartTheme(props.data)
}
const defaultOptions: ChartOptions<'line'> = {
@@ -95,14 +106,14 @@ function createChart() {
chart = new ChartJS(chartRef.value, {
type: 'line',
data: props.data,
data: buildChartData(),
options: buildChartOptions()
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = buildChartData()
chart.update('none') // 禁用动画以提高性能
}
}
@@ -110,9 +121,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = buildChartData()
chart.options = buildChartOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -3,39 +3,24 @@
<Select
v-model="selectedPreset"
>
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
<SelectTrigger :class="presetTriggerClass">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">
今天
</SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
<SelectItem value="last180days">
最近180天
</SelectItem>
<SelectItem value="this_week">
本周
</SelectItem>
<SelectItem value="last_week">
上周
</SelectItem>
<SelectItem value="this_month">
本月
</SelectItem>
<SelectItem value="last_month">
上月
</SelectItem>
<SelectItem value="this_year">
今年
<SelectItem value="last1year">
最近一年
</SelectItem>
<SelectItem value="custom">
自定义
@@ -50,13 +35,13 @@
<Input
v-model="startDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
:class="dateInputClass"
/>
<span class="text-xs text-muted-foreground">至</span>
<Input
v-model="endDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
:class="dateInputClass"
/>
</div>
@@ -64,10 +49,16 @@
v-if="showGranularity"
v-model="selectedGranularity"
>
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
<SelectTrigger :class="granularityTriggerClass">
<SelectValue placeholder="粒度" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-if="includeAutoGranularity"
value="auto"
>
自动
</SelectItem>
<SelectItem
v-if="allowHourly && canUseHourly"
value="hour"
@@ -100,26 +91,51 @@ import {
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
const props = defineProps<{
modelValue: DateRangeParams
showGranularity?: boolean
allowHourly?: boolean
includeAutoGranularity?: boolean
compact?: boolean
}>()
const emit = defineEmits<{
'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 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 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(() => {
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
if (selectedPreset.value === 'today') return true
if (selectedPreset.value === 'custom' && 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.start_date !== undefined) startDate.value = value.start_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 = getValueKey(value)
}, { deep: true })
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') {
selectedGranularity.value = 'day'
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
}
}
@@ -13,8 +13,11 @@
<p class="mt-0.5">
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
</p>
<p class="text-[11px] text-muted-foreground">
成本 {{ formatCurrency(tooltip.day.total_cost) }}
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
{{ 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>
</div>
</Teleport>
@@ -90,7 +93,7 @@
class="flex items-center invisible"
>周六</span>
</div>
<div class="flex-1 min-w-[200px]">
<div class="flex-1 min-w-0">
<div
ref="heatmapWrapper"
class="relative block w-full"
@@ -100,12 +103,12 @@
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
v-for="(week, weekIndex) in visibleWeekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="text-center"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
</div>
</div>
<div
@@ -113,7 +116,7 @@
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
v-for="(week, weekIndex) in visibleWeekColumns"
:key="weekIndex"
class="flex flex-col"
:style="verticalGapStyle"
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
title?: string
subtitle?: string
showHeader?: boolean
valueLabel?: string
actualValueLabel?: string
}>(), {
data: undefined,
title: undefined,
subtitle: undefined,
showHeader: true
showHeader: true,
valueLabel: '费用',
actualValueLabel: undefined,
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
if (month === lastMonth) {
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
})
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => {
const columnCount = weekColumns.value.length
const columnCount = visibleWeekColumns.value.length
if (!columnCount || !heatmapWidth.value) {
return
}
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount
// 自适应尺寸,最小 6px
cellSize.value = Math.max(6, rawSize)
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
}
watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
() => {
recalcCellSize()
},
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200
const tooltipWidth = 280
const tooltipHeight = 72
// Calculate horizontal position (centered on cell)
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
function buildTooltip(day: ActivityHeatmapDay): string {
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]
if (day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
}
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>
<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 class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<p
v-if="subtitle"
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
v-if="loading"
class="p-6"
@@ -16,57 +36,116 @@
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
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"
>
<DoughnutChart
:data="chartData"
:options="chartOptions"
/>
{{ 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
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>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { LoadingState } from '@/components/common'
import type { ErrorDistributionItem } from '@/api/admin'
interface ErrorDistributionItem {
category: string
label: string
count: number
}
interface Props {
title: string
subtitle?: string
distribution: ErrorDistributionItem[]
loading?: boolean
unavailable?: boolean
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
loading: false,
unavailable: false,
unavailableText: '数据暂不可用',
})
const chartData = computed(() => ({
labels: props.distribution.map(item => item.category),
const chartColors = [
'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: [
{
data: props.distribution.map(item => item.count),
backgroundColor: [
'rgba(239, 68, 68, 0.7)',
'rgba(59, 130, 246, 0.7)',
'rgba(234, 179, 8, 0.7)',
'rgba(34, 197, 94, 0.7)',
'rgba(148, 163, 184, 0.7)'
],
borderWidth: 0
}
]
data: distributionWithShare.value.map(item => item.count),
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
borderColor: 'rgba(255,255,255,0.92)',
borderWidth: 2,
hoverOffset: 6,
},
],
}))
const chartOptions = computed(() => ({
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
cutout: '68%',
plugins: {
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>
@@ -1,34 +1,30 @@
<template>
<TableCard :title="title">
<template #actions>
<Select
v-if="showMetricSelect"
:model-value="metric"
@update:model-value="emitMetric"
<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"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
更新中
</div>
</template>
<div
v-if="loading"
v-if="showLoadingState"
class="p-6"
>
<LoadingState />
</div>
<div
v-else-if="showUnavailableState"
class="p-6"
>
<EmptyState
type="error"
:title="unavailableTitle"
:description="unavailableDescription"
/>
</div>
<div
v-else-if="items.length === 0"
class="p-6"
@@ -38,20 +34,29 @@
description="当前时间范围内没有统计结果"
/>
</div>
<Table v-else>
<Table
v-else
class="table-fixed"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<TableHeader>
<TableRow>
<TableHead class="w-16">
<TableHead class="w-14 px-2.5">
排名
</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">
<TableHead class="w-[34%] px-2.5">
名称
</TableHead>
<TableHead class="w-[15%] px-2.5 text-right">
请求数
</TableHead>
<TableHead class="text-right">
<TableHead class="w-[18%] px-2.5 text-right">
Tokens
</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>
</TableRow>
@@ -60,68 +65,118 @@
<TableRow
v-for="item in items"
: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">
{{ item.rank }}
<TableCell class="px-2.5 py-2.5">
<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>{{ item.name }}</TableCell>
<TableCell class="text-right">
{{ item.requests }}
<TableCell class="px-2.5 py-2.5">
<div class="min-w-0">
<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 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) }}
</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) }}
</TableCell>
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
{{ formatCurrency(item.actualCost) }}
</TableCell>
</TableRow>
</TableBody>
</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>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
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 {
title: string
items: LeaderboardItem[]
metric: 'requests' | 'tokens' | 'cost'
selectedId?: string
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>(), {
selectedId: undefined,
loading: false,
showMetricSelect: true
hasLoaded: false,
currentPage: 1,
totalItems: 0,
pageSize: 5,
showPagination: false,
unavailable: false,
unavailableTitle: '排行榜暂不可用',
unavailableDescription: '接口未返回结果,请稍后重试',
})
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)
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
const selectedId = computed(() => props.selectedId)
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
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>
+193 -53
View File
@@ -1,27 +1,59 @@
<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 class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<p
v-if="subtitle"
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
v-if="loading"
class="p-6"
>
<LoadingState />
</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
v-else
class="h-[260px]"
class="rounded-2xl border border-border/60 bg-background p-3"
>
<LineChart
:data="chartData"
:options="chartOptions"
:height="248"
/>
</div>
</div>
@@ -29,9 +61,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import LineChart from '@/components/charts/LineChart.vue'
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 {
title: string
@@ -39,61 +81,159 @@ interface Props {
series: PercentileItem[]
mode: 'response' | 'ttfb'
loading?: boolean
unavailable?: boolean
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
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 {
if (ms == null) return null
return ms / 1000
return Number((ms / 1000).toFixed(3))
}
const chartData = computed(() => {
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_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
}
]
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
if (props.mode === 'response') {
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
return msToSeconds(item.p99_response_time_ms)
}
})
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: {
y: {
x: {
grid: {
color: 'rgba(148, 163, 184, 0.06)',
},
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>
@@ -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 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 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(() =>
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',
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
isHidden.value && 'hidden',
@@ -101,8 +101,16 @@ onBeforeUnmount(() => {
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
<SelectItemText
as="div"
class="min-w-0 flex-1 overflow-hidden"
>
<span
class="block w-full min-w-0 truncate"
:title="normalizedText"
>
<slot />
</span>
</SelectItemText>
</SelectItemPrimitive>
</template>
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
:class="triggerClass"
:disabled="disabled"
>
<span class="truncate">
<span class="min-w-0 flex-1 truncate">
<slot />
</span>
<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<{
open: boolean
providerId: string
/** @deprecated */
providerApiFormats?: string[]
models: Model[]
editingGroup?: AliasGroup | null
preselectedModelId?: string | null
@@ -544,12 +544,12 @@
v-if="refreshingQuota"
class="w-3 h-3 text-muted-foreground/70 animate-spin"
/>
<span
v-if="key.upstream_metadata.codex?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
</span>
<span
v-if="key.upstream_metadata.codex?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatUpdatedAt(key.upstream_metadata.codex.updated_at) }}
</span>
</div>
</div>
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
@@ -674,7 +674,7 @@
v-if="key.upstream_metadata.antigravity?.updated_at"
class="text-[9px] text-muted-foreground/70"
>
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
{{ formatUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
</span>
</div>
</div>
@@ -2471,10 +2471,6 @@ function formatUpdatedAt(updatedAt: number): string {
return `${days}天前更新`
}
// 兼容旧函数名
const formatCodexUpdatedAt = formatUpdatedAt
const formatAntigravityUpdatedAt = formatUpdatedAt
function secondsUntilReset(resetTime: string): number | null {
if (!resetTime) return null
const ts = Date.parse(resetTime)
@@ -165,7 +165,6 @@
<ModelMappingDialog
v-model:open="dialogOpen"
:provider-id="provider.id"
:provider-api-formats="providerApiFormats"
:models="models"
:editing-group="editingGroup"
:preselected-model-id="preselectedModelId"
@@ -195,7 +194,6 @@ import { useToast } from '@/composables/useToast'
import {
getProviderModels,
testModel,
API_FORMAT_LABELS,
type Model,
type ProviderModelAlias
} from '@/api/endpoints'
@@ -227,16 +225,6 @@ const preselectedModelId = ref<string | null>(null)
// 列表展开状态
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 {
if (!formats || formats.length === 0) return ''
@@ -20,26 +20,31 @@
</div>
<div
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" />
加载中...
</div>
<div
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" />
加载失败
数据暂不可用
</div>
<ActivityHeatmap
v-else-if="hasData"
:data="data"
:show-header="false"
:value-label="valueLabel"
:actual-value-label="actualValueLabel"
/>
<div
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>
@@ -58,6 +63,9 @@ const props = defineProps<{
title: string
isLoading?: boolean
hasError?: boolean
minHeight?: number
valueLabel?: string
actualValueLabel?: string
}>()
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(() =>
props.data && props.data.days && props.data.days.length > 0
)
const minHeight = computed(() => props.minHeight ?? 160)
</script>
@@ -780,11 +780,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
}
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
if (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.provider_id && typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
if (!merged.provider_name && typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
if (!merged.endpoint_id && typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
if (!merged.key_id && typeof raw.key_id === 'string') merged.key_id = raw.key_id
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.error_type === 'string') merged.error_type = raw.error_type
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">
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 { getProviderKeys } from '@/api/endpoints/keys'
import type { EndpointAPIKey } from '@/api/endpoints/types'
@@ -482,7 +482,7 @@ async function doReplay() {
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
replayResult.value = await dashboardApi.replayRequest(
replayResult.value = await requestDetailsApi.replayRequest(
props.requestId,
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 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 { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { requestDetailsApi, type RequestDetail } from '@/api/request-details'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { log } from '@/utils/logger'
@@ -1319,7 +1319,7 @@ async function ensureBodyContentLoaded() {
const requestId = ++bodyLoadRequestId
bodyLoading.value = true
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
detail.value = {
...detail.value,
@@ -1359,7 +1359,7 @@ async function loadDetail(id: string, silent = false) {
}
error.value = null
try {
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false })
const response = await requestDetailsApi.getRequestDetail(id, { includeBodies: false })
if (requestId !== loadDetailRequestId) return
const previousDetail = detail.value
@@ -1737,7 +1737,7 @@ async function copyCurlCommand() {
if (!props.requestId || curlCopying.value) return
curlCopying.value = true
try {
const data = await dashboardApi.getCurlData(props.requestId)
const data = await requestDetailsApi.getCurlData(props.requestId)
if (data.curl) {
copyToClipboard(data.curl, false)
curlCopied.value = true
@@ -159,7 +159,7 @@
import { computed, ref } from 'vue'
import Card from '@/components/ui/card.vue'
import JsonContent from './JsonContent.vue'
import type { RequestDetail } from '@/api/dashboard'
import type { RequestDetail } from '@/api/request-details'
const props = withDefaults(defineProps<{
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
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
:key="user.value"
: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>
</SelectContent>
</Select>
@@ -58,10 +81,10 @@
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
:key="model.value"
:value="model.value"
>
{{ model.replace('claude-', '') }}
{{ model.label }}
</SelectItem>
</SelectContent>
</Select>
@@ -81,10 +104,10 @@
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
:key="provider.value"
:value="provider.value"
>
{{ provider }}
{{ provider.label }}
</SelectItem>
</SelectContent>
</Select>
@@ -106,7 +129,7 @@
:key="format.value"
:value="format.value"
>
{{ format.label }}
{{ getApiFormatOptionLabel(format) }}
</SelectItem>
</SelectContent>
</Select>
@@ -123,26 +146,12 @@
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
<SelectItem
v-for="status in availableStatuses"
:key="status.value"
:value="status.value"
>
{{ getStatusOptionLabel(status) }}
</SelectItem>
</SelectContent>
</Select>
@@ -350,18 +359,18 @@
<TableCell
v-if="isAdmin"
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">
<span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
{{ getUserDisplayName(record) }}
</span>
<span
v-if="record.api_key?.name"
v-if="getUserApiKeyLabel(record)"
class="text-muted-foreground truncate"
:title="record.api_key.name"
:title="getUserApiKeyLabel(record) || undefined"
>
{{ record.api_key.name }}
{{ getUserApiKeyLabel(record) }}
</span>
</div>
</TableCell>
@@ -369,10 +378,10 @@
<TableCell
v-if="!isAdmin"
class="py-4 w-[100px]"
:title="record.api_key?.name || '-'"
:title="getUserApiKeyLabel(record) || '-'"
>
<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
v-if="record.api_key?.display"
class="text-muted-foreground truncate"
@@ -417,7 +426,7 @@
>
<div class="flex items-center gap-1">
<div class="flex flex-col text-xs gap-0.5">
<span>{{ record.provider }}</span>
<span>{{ formatProviderLabel(record.provider) }}</span>
<span
v-if="record.api_key_name"
class="text-muted-foreground truncate"
@@ -672,16 +681,11 @@ import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { AnalyticsFilterOption } from '@/api/analytics'
import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common'
import ElapsedTimeText from './ElapsedTimeText.vue'
export interface UserOption {
id: string
username: string
email: string
}
const props = defineProps<{
records: UsageRecord[]
isAdmin: boolean
@@ -692,13 +696,17 @@ const props = defineProps<{
// 筛选
filterSearch: string
filterUser: string
filterApiKey: string
filterModel: string
filterProvider: string
filterApiFormat: string
filterStatus: string
availableUsers: UserOption[]
availableModels: string[]
availableProviders: string[]
availableUsers: AnalyticsFilterOption[]
availableApiKeys: AnalyticsFilterOption[]
availableModels: AnalyticsFilterOption[]
availableApiFormats: AnalyticsFilterOption[]
availableProviders: AnalyticsFilterOption[]
availableStatuses: AnalyticsFilterOption[]
// 分页
currentPage: number
pageSize: number
@@ -712,6 +720,7 @@ const emit = defineEmits<{
'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string]
'update:filterUser': [value: string]
'update:filterApiKey': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
@@ -723,22 +732,6 @@ const emit = defineEmits<{
'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({
get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value)
@@ -760,6 +753,44 @@ watch(localSearch, (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()
@@ -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 ActivityHeatmapCard } from './ActivityHeatmapCard.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':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
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':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case 'last30days':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
case 'last90days':
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
case 'last180days':
startDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
break
case 'last1year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
break
default:
return {} // 返回空对象表示不过滤时间
@@ -1,16 +1,10 @@
import { ref, computed, type Ref } from 'vue'
import { usageApi } from '@/api/usage'
import { meApi } from '@/api/me'
import { ref, type Ref } from 'vue'
import { analyticsApi, type AnalyticsBaseRequest, type AnalyticsFilterOption, type AnalyticsRecord, type AnalyticsScope } from '@/api/analytics'
import { buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
import type {
UsageStatsState,
ModelStatsItem,
ProviderStatsItem,
ApiFormatStatsItem,
UsageRecord,
DateRangeParams,
EnhancedModelStatsItem
} from '../types'
import { createDefaultStats } from '../types'
import { log } from '@/utils/logger'
import { getErrorStatus } from '@/types/api-error'
@@ -26,27 +20,21 @@ export interface PaginationParams {
export interface FilterParams {
search?: string
user_id?: string
api_key_id?: string
model?: string
provider?: string
api_format?: string
status?: string
}
type FilterOptionDimension = 'user' | 'api_key' | 'model' | 'provider' | 'api_format' | 'status'
export function useUsageData(options: UseUsageDataOptions) {
const { isAdminPage } = options
// 加载状态
const isLoadingStats = ref(true)
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 totalRecords = ref(0)
@@ -56,195 +44,93 @@ export function useUsageData(options: UseUsageDataOptions) {
let loadRecordsRequestId = 0
// 可用的筛选选项(从统计数据获取,而不是从记录中)
const availableModels = ref<string[]>([])
const availableProviders = ref<string[]>([])
const availableUsers = ref<AnalyticsFilterOption[]>([])
const availableModels = ref<AnalyticsFilterOption[]>([])
const availableProviders = ref<AnalyticsFilterOption[]>([])
const availableApiKeys = ref<AnalyticsFilterOption[]>([])
const availableApiFormats = ref<AnalyticsFilterOption[]>([])
const availableStatuses = ref<AnalyticsFilterOption[]>([])
// 增强的模型统计(包含效率分析)
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
return modelStats.value.map(model => ({
...model,
costPerToken: model.total_tokens > 0
? `$${(model.total_cost / model.total_tokens * 1000000).toFixed(2)}/M`
: '-'
}))
})
function mapAnalyticsRecord(record: AnalyticsRecord): UsageRecord {
return {
id: record.id,
user_id: record.user_id || undefined,
username: record.username || undefined,
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
isLoadingStats.value = true
currentDateRange.value = dateRange
try {
if (isAdminPage.value) {
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
const statsData = await usageApi.getUsageStats(dateRange)
const response = await analyticsApi.getFilterOptions({
scope: buildScope(),
time_range: buildTimeRangeParams(dateRange || currentDateRange.value || {}),
filters: buildAnalyticsFilters(filters),
})
if (requestId !== loadStatsRequestId) {
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`
: '-'
}))
if (requestId !== loadStatsRequestId) {
return
}
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) {
if (requestId !== loadStatsRequestId) {
return
@@ -252,17 +138,17 @@ export function useUsageData(options: UseUsageDataOptions) {
if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error)
}
stats.value = createDefaultStats()
modelStats.value = []
availableUsers.value = []
availableModels.value = []
availableProviders.value = []
currentRecords.value = []
} finally {
if (requestId === loadStatsRequestId) {
isLoadingStats.value = false
}
availableApiKeys.value = []
availableApiFormats.value = []
availableStatuses.value = []
}
}
// 加载记录(真正的后端分页)
// 加载记录:前后端统一由后端分页和筛选
async function loadRecords(
pagination: PaginationParams,
filters?: FilterParams,
@@ -278,53 +164,24 @@ export function useUsageData(options: UseUsageDataOptions) {
currentDateRange.value = dateRange
}
// 构建请求参数
const params: Record<string, unknown> = {
limit: pagination.pageSize,
offset,
...effectiveDateRange
}
// 添加筛选条件
if (filters?.search?.trim()) {
params.search = filters.search.trim()
}
if (isAdminPage.value) {
// 管理员页面:使用管理员 API
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 response = await analyticsApi.getRecords({
scope: buildScope(),
time_range: buildTimeRangeParams(currentDateRange.value || {}),
filters: buildAnalyticsFilters(filters),
search: {
text: filters?.search || null,
},
pagination: {
limit: pagination.pageSize,
offset,
},
})
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (response.records || []).map(mapAnalyticsRecord)
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = response.total || 0
} catch (error) {
if (requestId !== loadRecordsRequestId) {
return
@@ -412,33 +269,19 @@ export function useUsageData(options: UseUsageDataOptions) {
})
}
// 刷新所有数据
async function refreshData(dateRange?: DateRangeParams) {
await loadStats(dateRange)
}
return {
// 状态
loading,
isLoadingStats,
isLoadingRecords,
stats,
modelStats,
providerStats,
apiFormatStats,
currentRecords,
totalRecords,
// 筛选选项
availableUsers,
availableModels,
availableProviders,
availableApiKeys,
availableApiFormats,
availableStatuses,
// 计算属性
enhancedModelStats,
// 方法
loadStats,
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 interface UsageRecord {
@@ -82,7 +11,7 @@ export interface UsageRecord {
display: string | null
} | null
provider?: string // 仅管理员可见
api_key_name?: string
api_key_name?: string // 提供商 Key 名称(管理员列使用)
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
@@ -113,29 +42,13 @@ export interface DateRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
granularity?: 'auto' | 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
// 时间段选项
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
export type PeriodValue = 'today' | 'last7days' | 'last30days' | 'last180days' | 'last1year'
// 筛选状态(简化为常用维度)
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled'
// 默认统计状态
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: ''
}
}
// 筛选状态由后端 filter-options 决定
export type FilterStatusValue = string
+3 -4
View File
@@ -516,6 +516,7 @@ const navigation = computed(() => {
items: [
{ name: '仪表盘', href: '/dashboard', icon: Home },
{ name: '健康监控', href: '/dashboard/endpoint-status', icon: Activity },
{ name: '综合报表', href: '/dashboard/reports', icon: BarChart3 },
]
},
{
@@ -529,7 +530,7 @@ const navigation = computed(() => {
title: '账户',
items: [
{ 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 },
]
}
@@ -573,10 +574,8 @@ const navigation = computed(() => {
title: '概览',
items: [
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
{ name: '数据分析', href: '/admin/analytics', icon: BarChart3 },
{ 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 { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
import type { User as AdminUser } from '@/api/users'
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'
// ========== 用户数据 ==========
@@ -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[] = [
@@ -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 = [
File diff suppressed because it is too large Load Diff
+8 -18
View File
@@ -112,6 +112,11 @@ const routes: RouteRecordRaw[] = [
name: 'MyUsage',
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
},
{
path: 'reports',
name: 'Reports',
component: () => importWithRetry(() => import('@/views/user/Reports.vue'))
},
{
path: 'endpoint-status',
name: 'EndpointStatus',
@@ -196,19 +201,9 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
},
{
path: 'user-stats',
name: 'UserStats',
component: () => importWithRetry(() => import('@/views/admin/UserStats.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: 'analytics',
name: 'Analytics',
component: () => importWithRetry(() => import('@/views/admin/Analytics.vue'))
},
{
path: 'system',
@@ -272,11 +267,6 @@ const routes: RouteRecordRaw[] = [
path: 'gemini-files',
name: 'GeminiFilesManagement',
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
export function formatTokens(num: number | undefined | null): string {
if (num === undefined || num === null || num === 0) {
export function formatTokens(num: number | string | undefined | null): string {
const normalized = toFiniteNumber(num)
if (normalized === null || normalized === 0) {
return '0'
}
// For very small values (< 1000), show as is without unit
if (num < 1000) {
return num.toString()
if (normalized < 1000) {
return normalized.toString()
}
// For values 1K-999K, show in thousands
if (num < 1000000) {
const thousands = num / 1000
if (normalized < 1000000) {
const thousands = normalized / 1000
if (thousands >= 100) {
return `${Math.round(thousands) }K`
} else if (thousands >= 10) {
@@ -22,7 +39,7 @@ export function formatTokens(num: number | undefined | null): string {
}
// For values >= 1M, show in millions
const millions = num / 1000000
const millions = normalized / 1000000
if (millions >= 100) {
return `${Math.round(millions) }M`
} else if (millions >= 10) {
@@ -33,53 +50,54 @@ export function formatTokens(num: number | undefined | null): string {
}
// Currency formatting with high precision for small values
export function formatCurrency(amount: number | undefined | null): string {
if (amount === undefined || amount === null || amount === 0) {
export function formatCurrency(amount: number | string | undefined | null): string {
const normalized = toFiniteNumber(amount)
if (normalized === null || normalized === 0) {
return '$0.00'
}
// For very small amounts (< $0.00001), show up to 8 decimal places
if (amount > 0 && amount < 0.00001) {
const formatted = amount.toFixed(8)
if (normalized > 0 && normalized < 0.00001) {
const formatted = normalized.toFixed(8)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For small amounts (< $0.0001), show up to 6 decimal places
if (amount < 0.0001) {
const formatted = amount.toFixed(6)
if (normalized < 0.0001) {
const formatted = normalized.toFixed(6)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For small amounts (< $0.01), show up to 5 decimal places
if (amount < 0.01) {
const formatted = amount.toFixed(5)
if (normalized < 0.01) {
const formatted = normalized.toFixed(5)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For amounts less than $1, show 4 decimal places
if (amount < 1) {
const formatted = amount.toFixed(4)
if (normalized < 1) {
const formatted = normalized.toFixed(4)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For amounts $1-$100, show 2-3 decimal places
if (amount < 100) {
const formatted = amount.toFixed(3)
if (normalized < 100) {
const formatted = normalized.toFixed(3)
// Remove trailing zeros but keep at least 2 decimal places
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
return `$${ trimmed}`
}
// For larger amounts, show 2 decimal places
return `$${ amount.toFixed(2)}`
return `$${ normalized.toFixed(2)}`
}
// Number formatting with locale support
@@ -157,7 +175,8 @@ export function formatRemainingTime(expireAt: number | undefined, currentTime: n
// Cache hit rate formatting
export function formatHitRate(rate: number | undefined): string {
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)
+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"
/>
<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
id="section-request-log"
@@ -211,6 +222,7 @@ import UserDataSection from './system-settings/UserDataSection.vue'
import DataManagementSection from './system-settings/DataManagementSection.vue'
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
import MonitoringCapacitySection from './system-settings/MonitoringCapacitySection.vue'
import RequestLogSection from './system-settings/RequestLogSection.vue'
import CleanupPolicySection from './system-settings/CleanupPolicySection.vue'
import ScheduledTasksSection from './system-settings/ScheduledTasksSection.vue'
@@ -230,6 +242,7 @@ const tocItems = [
{ id: 'section-data-mgmt', label: '数据管理' },
{ id: 'section-proxy', label: '网络代理' },
{ id: 'section-basic', label: '基础配置' },
{ id: 'section-monitoring-capacity', label: '监控容量' },
{ id: 'section-request-log', label: '请求记录' },
{ id: 'section-cleanup', label: '记录清理策略' },
{ id: 'section-scheduled', label: '定时任务' },
@@ -290,21 +303,26 @@ const {
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
monitoringConfigLoading,
logConfigLoading,
cleanupConfigLoading,
hasSiteInfoChanges,
hasProxyConfigChanges,
hasBasicConfigChanges,
hasMonitoringConfigChanges,
hasLogConfigChanges,
hasCleanupConfigChanges,
maxRequestBodySizeKB,
maxResponseBodySizeKB,
redisMemoryTotalGB,
postgresStorageTotalGB,
sensitiveHeadersStr,
loadSystemConfig,
loadSystemVersion,
saveSiteInfo,
saveProxyConfig,
saveBasicConfig,
saveMonitoringConfig,
saveLogConfig,
saveCleanupConfig,
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 { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage'
import { analyticsApi } from '@/api/analytics'
import { adminApi } from '@/api/admin'
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)
let userStatsRequestId = 0
const userWalletMap = ref<Record<string, AdminWallet>>({})
@@ -1181,10 +1188,26 @@ async function loadUserStats() {
const requestId = ++userStatsRequestId
loadingStats.value = true
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
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
acc[stat.user_id] = stat
userStats.value = response.rows.reduce((acc: Record<string, UserUsageSummary>, row) => {
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
}, {})
} 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
// 格式转换
enable_format_conversion: boolean
// 监控容量
redis_memory_total_bytes: number
postgres_storage_total_bytes: number
// 请求记录
request_record_level: string
max_request_body_size: number
@@ -55,6 +58,9 @@ const CONFIG_KEYS = [
'auto_delete_expired_keys',
// 格式转换
'enable_format_conversion',
// 监控容量
'redis_memory_total_bytes',
'postgres_storage_total_bytes',
// 请求记录
'request_record_level',
'max_request_body_size',
@@ -92,6 +98,9 @@ function createDefaultConfig(): SystemConfig {
auto_delete_expired_keys: false,
// 格式转换
enable_format_conversion: false,
// 监控容量
redis_memory_total_bytes: 0,
postgres_storage_total_bytes: 0,
// 请求记录
request_record_level: 'basic',
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() {
const { success, error } = useToast()
const { refreshSiteInfo } = useSiteInfo()
@@ -126,6 +147,7 @@ export function useSystemConfig() {
const siteInfoLoading = ref(false)
const proxyConfigLoading = ref(false)
const basicConfigLoading = ref(false)
const monitoringConfigLoading = ref(false)
const logConfigLoading = 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(() => {
if (!originalConfig.value) return false
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({
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() {
cleanupConfigLoading.value = true
try {
@@ -497,17 +575,21 @@ export function useSystemConfig() {
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
monitoringConfigLoading,
logConfigLoading,
cleanupConfigLoading,
// 变动检测
hasSiteInfoChanges,
hasProxyConfigChanges,
hasBasicConfigChanges,
hasMonitoringConfigChanges,
hasLogConfigChanges,
hasCleanupConfigChanges,
// 计算属性
maxRequestBodySizeKB,
maxResponseBodySizeKB,
redisMemoryTotalGB,
postgresStorageTotalGB,
sensitiveHeadersStr,
// 加载函数
loadSystemConfig,
@@ -516,6 +598,7 @@ export function useSystemConfig() {
saveSiteInfo,
saveProxyConfig,
saveBasicConfig,
saveMonitoringConfig,
saveLogConfig,
saveCleanupConfig,
handleAutoCleanupToggle,
File diff suppressed because it is too large Load Diff
+105 -318
View File
@@ -1,79 +1,13 @@
<template>
<div class="space-y-6 pb-8">
<!-- 面包屑旁的折叠按钮 -->
<Teleport
to="#header-actions-right"
defer
>
<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"
<!-- 跳转数据分析链接 -->
<div class="text-right">
<RouterLink
:to="isAdminPage ? '/admin/analytics' : '/dashboard/reports'"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<PanelTopClose
v-if="statsExpanded"
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>
{{ isAdminPage ? '查看数据分析 →' : '查看综合报表 →' }}
</RouterLink>
</div>
<!-- 使用记录 -->
@@ -85,13 +19,17 @@
:time-range="timeRange"
:filter-search="filterSearch"
:filter-user="filterUser"
:filter-api-key="filterApiKey"
:filter-model="filterModel"
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:available-users="availableUsers"
:available-api-keys="availableApiKeys"
:available-models="availableModels"
:available-api-formats="availableApiFormats"
:available-providers="availableProviders"
:available-statuses="availableStatuses"
:current-page="currentPage"
:page-size="pageSize"
:total-records="effectiveTotalRecords"
@@ -100,6 +38,7 @@
@update:time-range="handleTimeRangeChange"
@update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange"
@update:filter-api-key="handleFilterApiKeyChange"
@update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@@ -124,29 +63,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useLocalStorage } from '@vueuse/core'
// useLocalStorage no longer needed (analytics panel removed)
import { useAuthStore } from '@/stores/auth'
import { usageApi } from '@/api/usage'
import { usersApi } from '@/api/users'
import { meApi } from '@/api/me'
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
import { analyticsApi, type AnalyticsFilterOption } from '@/api/analytics'
import {
UsageModelTable,
UsageProviderTable,
UsageApiFormatTable,
UsageRecordsTable,
ActivityHeatmapCard,
RequestDetailDrawer,
IntervalTimelineCard
} from '@/features/usage/components'
import {
useUsageData,
getDateRangeFromPeriod
} from '@/features/usage/composables'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
// ActivityHeatmap moved to analytics page
import { useToast } from '@/composables/useToast'
const route = useRoute()
@@ -156,9 +86,6 @@ const authStore = useAuthStore()
// 判断是否是管理员页面
const isAdminPage = computed(() => route.path.startsWith('/admin'))
// 用量分析面板折叠状态(默认展开,持久化到 localStorage)
const statsExpanded = useLocalStorage('usage-stats-expanded', true)
// 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
@@ -170,155 +97,28 @@ const pageSizeOptions = [10, 20, 50, 100]
// 筛选状态
const filterSearch = ref('')
const filterUser = ref('__all__')
const filterApiKey = ref('__all__')
const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
// 用户列表(仅管理员页面使用)
const availableUsers = ref<UserOption[]>([])
// 使用 composables
const {
isLoadingRecords,
providerStats,
apiFormatStats,
currentRecords,
totalRecords,
enhancedModelStats,
availableUsers,
availableApiKeys,
availableModels,
availableApiFormats,
availableProviders,
availableStatuses,
loadStats,
loadRecords
} = 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 列表
const activeRequestIds = computed(() => {
@@ -349,11 +149,10 @@ async function pollActiveRequests() {
pollInFlight = true
try {
// 根据页面类型选择不同的 API
const idsParam = activeRequestIds.value.join(',')
const { requests } = isAdminPage.value
? await usageApi.getActiveRequests(activeRequestIds.value)
: await meApi.getActiveRequests(idsParam)
const { requests } = await analyticsApi.getActiveRequests({
scope: isAdminPage.value ? { kind: 'global' } : { kind: 'me' },
ids: activeRequestIds.value,
})
let shouldRefresh = false
@@ -512,23 +311,8 @@ onUnmounted(() => {
stopGlobalAutoRefresh()
})
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
const paginatedRecords = computed(() => {
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 paginatedRecords = computed(() => currentRecords.value)
const effectiveTotalRecords = computed(() => totalRecords.value)
// 显示的记录
const displayRecords = computed(() => paginatedRecords.value)
@@ -538,33 +322,64 @@ const displayRecords = computed(() => paginatedRecords.value)
const detailModalOpen = ref(false)
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 () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (isAdminPage.value) {
// 管理员页面优先加载记录,统计面板在后台顺序刷新,避免瞬时并发打满后端。
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters(),
timeRange.value
)
void (async () => {
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)
})
])
}
const statsTask = syncFilterOptions().catch(err => {
log.error('加载统计数据失败:', err)
warning('统计数据加载失败,请刷新重试')
})
await statsTask
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters()
)
if (globalAutoRefresh.value && isPageVisible.value) {
startGlobalAutoRefresh()
@@ -575,32 +390,21 @@ onMounted(async () => {
async function handleTimeRangeChange(value: DateRangeParams) {
timeRange.value = value
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalytics({ force: true })
return
}
await loadStats(timeRange.value)
// 用户页面:loadStats 已包含记录加载
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
// 处理分页变化
async function handlePageChange(page: number) {
currentPage.value = page
if (isAdminPage.value) {
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
// 用户页面使用前端分页,无需重新请求
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
}
// 处理每页大小变化
async function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters(), timeRange.value)
}
// 用户页面使用前端分页,无需重新请求
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
}
// 获取当前筛选参数
@@ -608,6 +412,7 @@ function getCurrentFilters() {
return {
search: filterSearch.value.trim() || 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,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
@@ -619,57 +424,49 @@ function getCurrentFilters() {
async function handleFilterSearchChange(value: string) {
filterSearch.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
// 用户页面:search 需要重新从后端拉取数据(后端支持 search 参数)
// 但通过 filteredRecords 做前端过滤已覆盖,无需额外请求
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterUserChange(value: string) {
filterUser.value = value
currentPage.value = 1 // 重置到第一页
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
async function handleFilterApiKeyChange(value: string) {
filterApiKey.value = value
currentPage.value = 1
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterModelChange(value: string) {
filterModel.value = value
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterProviderChange(value: string) {
filterProvider.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterApiFormatChange(value: string) {
filterApiFormat.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
async function handleFilterStatusChange(value: string) {
filterStatus.value = value as FilterStatusValue
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
}
await syncFilterOptions()
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
// 刷新数据
@@ -678,18 +475,8 @@ async function refreshData() {
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
if (isAdminPage.value) {
await loadRecords(
{ page: currentPage.value, pageSize: pageSize.value },
getCurrentFilters(),
timeRange.value
)
void refreshAdminAnalytics()
return
}
await loadStats(timeRange.value)
// 用户页面:loadStats 已包含记录加载
await syncFilterOptions()
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
})()
try {
File diff suppressed because it is too large Load Diff