diff --git a/api/admin/admin.go b/api/admin/admin.go index 9695efd..b2f478a 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -83,6 +83,7 @@ type IAdminV1 interface { AdminDashboardRecentAlerts(ctx context.Context, req *v1.AdminDashboardRecentAlertsReq) (res *v1.AdminDashboardRecentAlertsRes, err error) AdminDashboardModelHourly(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (res *v1.AdminDashboardModelHourlyRes, err error) AdminUsageLogList(ctx context.Context, req *v1.AdminUsageLogListReq) (res *v1.AdminUsageLogListRes, err error) + AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error) AdminBillingRecordList(ctx context.Context, req *v1.AdminBillingRecordListReq) (res *v1.AdminBillingRecordListRes, err error) AdminWalletList(ctx context.Context, req *v1.AdminWalletListReq) (res *v1.AdminWalletListRes, err error) AdminWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (res *v1.AdminWalletInfoRes, err error) diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index b7875d8..29cb8cb 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -118,10 +118,8 @@ type AdminDashboardModelHourlyRes struct { // === 用量日志 === -type AdminUsageLogListReq struct { - g.Meta `path:"/usage-logs" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志列表"` - Page int `json:"page" d:"1" dc:"页码"` - PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` +// AdminUsageLogFilter 用量日志共用筛选条件(列表 / 统计汇总 / 导出三个接口共用,保证筛选口径一致) +type AdminUsageLogFilter struct { ID int64 `json:"id" dc:"用量记录ID"` TenantID int64 `json:"tenant_id" dc:"租户ID"` UserID int64 `json:"user_id" dc:"用户ID"` @@ -135,6 +133,13 @@ type AdminUsageLogListReq struct { EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } +type AdminUsageLogListReq struct { + g.Meta `path:"/usage-logs" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志列表"` + Page int `json:"page" d:"1" dc:"页码"` + PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` + AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件 +} + type AdminUsageLogItem struct { Id int64 `json:"id"` TenantId int64 `json:"tenant_id"` @@ -203,6 +208,19 @@ type AdminUsageLogListRes struct { PageSize int `json:"page_size"` } +// AdminUsageLogSummaryReq 用量日志统计请求(复用筛选条件,不包含分页) +type AdminUsageLogSummaryReq struct { + g.Meta `path:"/usage-logs/summary" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志统计汇总"` + AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件 +} + +// AdminUsageLogSummary 用量日志统计汇总 +type AdminUsageLogSummaryRes struct { + TotalCost float64 `json:"total_cost" dc:"总费用(USD)"` + TotalOutputTokens int64 `json:"total_output_tokens" dc:"总输出token数"` + CacheReadRatio float64 `json:"cache_read_ratio" dc:"缓存读取占比(%)"` +} + // === 计费记录 === type AdminBillingRecordListReq struct { @@ -391,19 +409,9 @@ type AdminWalletSetWarningThresholdRes struct{} // AdminUsageLogExportReq 导出用量日志请求 type AdminUsageLogExportReq struct { - g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"管理后台-用量" summary:"导出用量日志"` - Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` - ID int64 `json:"id" in:"query" dc:"用量记录ID"` - TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID"` - UserID int64 `json:"user_id" in:"query" dc:"用户ID"` - Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"` - ApiKeyID int64 `json:"api_key_id" in:"query" dc:"API Key ID"` - ChannelID int64 `json:"channel_id" in:"query" dc:"渠道ID"` - Model string `json:"model" in:"query" dc:"模型名称"` - Status string `json:"status" in:"query" dc:"状态"` - RequestType int `json:"request_type" in:"query" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"` - StartDate string `json:"start_date" in:"query" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` - EndDate string `json:"end_date" in:"query" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"管理后台-用量" summary:"导出用量日志"` + Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` + AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件 } type AdminUsageLogExportRes struct{} diff --git a/internal/controller/admin/admin_v1_dashboard.go b/internal/controller/admin/admin_v1_dashboard.go index 0a90f52..cdbdca8 100644 --- a/internal/controller/admin/admin_v1_dashboard.go +++ b/internal/controller/admin/admin_v1_dashboard.go @@ -70,3 +70,6 @@ func (c *ControllerV1) AdminWalletFrozenRelease(ctx context.Context, req *v1.Adm func (c *ControllerV1) AdminWalletFrozenReleaseAll(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (res *v1.AdminWalletFrozenReleaseAllRes, err error) { return service.Admin().ReleaseAllWalletFrozenItems(ctx, req) } +func (c *ControllerV1) AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error) { + return service.Admin().GetUsageLogSummary(ctx, req) +} diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index 1c0f932..76daff4 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -349,69 +349,78 @@ func (s *sAdmin) GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardM }, nil } +// validateUsageLogDateRange 校验用量日志筛选的起止时间参数(列表 / 统计 / 导出共用) +func validateUsageLogDateRange(start, end string) error { + if err := common.ValidateDateTimeParam(start, "开始时间"); err != nil { + return err + } + return common.ValidateDateTimeParam(end, "结束时间") +} + +// buildUsageLogFilter 构建用量日志查询的 WHERE 条件与绑定参数(列表 / 统计 / 导出共用,保证筛选口径一致)。 +// 注意:Username 筛选条件引用 t.username,调用方需按需 JOIN tnt_users(别名 t)。 +func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) { + var conditions []string + + if f.ID > 0 { + conditions = append(conditions, "u.id = ?") + args = append(args, f.ID) + } + if f.TenantID > 0 { + conditions = append(conditions, "u.tenant_id = ?") + args = append(args, f.TenantID) + } + if f.UserID > 0 { + conditions = append(conditions, "u.user_id = ?") + args = append(args, f.UserID) + } + if f.Username != "" { + conditions = append(conditions, "t.username LIKE ?") + args = append(args, "%"+f.Username+"%") + } + if f.ApiKeyID > 0 { + conditions = append(conditions, "u.api_key_id = ?") + args = append(args, f.ApiKeyID) + } + if f.ChannelID > 0 { + conditions = append(conditions, "u.channel_id = ?") + args = append(args, f.ChannelID) + } + if f.Model != "" { + conditions = append(conditions, "u.model_name = ?") + args = append(args, f.Model) + } + if f.Status != "" { + conditions = append(conditions, "u.status = ?") + args = append(args, f.Status) + } + if f.RequestType > 0 { + conditions = append(conditions, "u.request_type = ?") + args = append(args, f.RequestType) + } + if f.StartDate != "" { + conditions = append(conditions, "u.created_at >= ?") + args = append(args, common.StartOfRange(f.StartDate)) + } + if f.EndDate != "" { + conditions = append(conditions, "u.created_at <= ?") + args = append(args, common.EndOfRange(f.EndDate)) + } + + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + return where, args +} + // GetAllUsageLogs 获取所有租户的用量日志(管理后台) func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) { - if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { - return nil, err - } - if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil { return nil, err } page, pageSize := common.NormalizePagination(req.Page, req.PageSize) - - var conditions []string - var args []any - - if req.ID > 0 { - conditions = append(conditions, "u.id = ?") - args = append(args, req.ID) - } - if req.TenantID > 0 { - conditions = append(conditions, "u.tenant_id = ?") - args = append(args, req.TenantID) - } - if req.UserID > 0 { - conditions = append(conditions, "u.user_id = ?") - args = append(args, req.UserID) - } - if req.Username != "" { - conditions = append(conditions, "t.username LIKE ?") - args = append(args, "%"+req.Username+"%") - } - if req.ApiKeyID > 0 { - conditions = append(conditions, "u.api_key_id = ?") - args = append(args, req.ApiKeyID) - } - if req.ChannelID > 0 { - conditions = append(conditions, "u.channel_id = ?") - args = append(args, req.ChannelID) - } - if req.Model != "" { - conditions = append(conditions, "u.model_name = ?") - args = append(args, req.Model) - } - if req.Status != "" { - conditions = append(conditions, "u.status = ?") - args = append(args, req.Status) - } - if req.RequestType > 0 { - conditions = append(conditions, "u.request_type = ?") - args = append(args, req.RequestType) - } - if req.StartDate != "" { - conditions = append(conditions, "u.created_at >= ?") - args = append(args, common.StartOfRange(req.StartDate)) - } - if req.EndDate != "" { - conditions = append(conditions, "u.created_at <= ?") - args = append(args, common.EndOfRange(req.EndDate)) - } - - where := "" - if len(conditions) > 0 { - where = " WHERE " + strings.Join(conditions, " AND ") - } + where, args := buildUsageLogFilter(req.AdminUsageLogFilter) fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id" @@ -427,8 +436,8 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR dataSQL := `SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.created_at FROM ` + fromClause + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?` - args = append(args, pageSize, (page-1)*pageSize) - result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...) + dataArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) + result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, dataArgs...) if err != nil { return nil, err } @@ -450,6 +459,61 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR }, nil } +// GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径) +func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error) { + if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil { + return nil, err + } + where, args := buildUsageLogFilter(req.AdminUsageLogFilter) + + // 统计聚合不展示名称,仅在按用户名筛选时才 JOIN 用户表,避免大表 SUM 背负无关 JOIN + fromClause := "bil_usage_logs u" + if req.Username != "" { + fromClause += " LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id" + } + + summarySQL := `SELECT + COALESCE(SUM(u.total_cost), 0) AS total_cost, + COALESCE(SUM(u.output_tokens), 0) AS total_output_tokens, + COALESCE(SUM(u.input_tokens), 0) AS total_input_tokens, + COALESCE(SUM(u.cache_read_tokens), 0) AS total_cache_read + FROM ` + fromClause + where + + summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...) + if err != nil { + return nil, err + } + // 无 GROUP BY 的聚合必返回一行;空结果兜底为零值 + if len(summaryResult) == 0 { + return &v1.AdminUsageLogSummaryRes{}, nil + } + + var row struct { + TotalCost float64 `json:"total_cost"` + TotalOutputTokens int64 `json:"total_output_tokens"` + TotalInputTokens int64 `json:"total_input_tokens"` + TotalCacheRead int64 `json:"total_cache_read"` + } + if err := summaryResult[0].Struct(&row); err != nil { + return nil, err + } + + // 计算缓存读取占比 = cache_read / input_tokens。 + // input_tokens 入库口径为「含缓存的总输入」(见 relay/common/usage.go TotalInputTokens), + // cache_read 是其子集,分母直接取总和即可,禁止再加 cache_read(会重复计入导致占比偏低)。 + // 口径与租户个人仪表盘 HitRatio(personal_dashboard.go)保持一致。 + var cacheReadRatio float64 + if row.TotalInputTokens > 0 { + cacheReadRatio = float64(row.TotalCacheRead) / float64(row.TotalInputTokens) * 100 + } + + return &v1.AdminUsageLogSummaryRes{ + TotalCost: row.TotalCost, + TotalOutputTokens: row.TotalOutputTokens, + CacheReadRatio: cacheReadRatio, + }, nil +} + // GetAllBillingRecords 获取所有计费记录(管理后台) func (s *sAdmin) GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error) { page, pageSize := common.NormalizePagination(req.Page, req.PageSize) @@ -1185,10 +1249,7 @@ func (s *sAdmin) GetDashboardRecentAlerts(ctx context.Context, req *v1.AdminDash // ExportUsageLogs exports usage logs to CSV or Excel. func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExportReq) (*v1.AdminUsageLogExportRes, error) { - if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { - return nil, err - } - if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil { return nil, err } @@ -1211,59 +1272,7 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor Columns: columns, } - buildUsageWhere := func() (string, []any) { - var conditions []string - var args []any - if req.ID > 0 { - conditions = append(conditions, "u.id = ?") - args = append(args, req.ID) - } - if req.TenantID > 0 { - conditions = append(conditions, "u.tenant_id = ?") - args = append(args, req.TenantID) - } - if req.UserID > 0 { - conditions = append(conditions, "u.user_id = ?") - args = append(args, req.UserID) - } - if req.Username != "" { - conditions = append(conditions, "t.username LIKE ?") - args = append(args, "%"+req.Username+"%") - } - if req.ApiKeyID > 0 { - conditions = append(conditions, "u.api_key_id = ?") - args = append(args, req.ApiKeyID) - } - if req.ChannelID > 0 { - conditions = append(conditions, "u.channel_id = ?") - args = append(args, req.ChannelID) - } - if req.Model != "" { - conditions = append(conditions, "u.model_name = ?") - args = append(args, req.Model) - } - if req.Status != "" { - conditions = append(conditions, "u.status = ?") - args = append(args, req.Status) - } - if req.RequestType > 0 { - conditions = append(conditions, "u.request_type = ?") - args = append(args, req.RequestType) - } - if req.StartDate != "" { - conditions = append(conditions, "u.created_at >= ?") - args = append(args, common.StartOfRange(req.StartDate)) - } - if req.EndDate != "" { - conditions = append(conditions, "u.created_at <= ?") - args = append(args, common.EndOfRange(req.EndDate)) - } - where := "" - if len(conditions) > 0 { - where = " WHERE " + strings.Join(conditions, " AND ") - } - return where, args - } + where, filterArgs := buildUsageLogFilter(req.AdminUsageLogFilter) fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id" selectFields := "u.id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, u.model_name, u.request_type, u.input_tokens, u.output_tokens, u.total_cost, u.status, u.created_at" @@ -1271,9 +1280,9 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { offset := 0 for { - where, args := buildUsageWhere() sql := "SELECT " + selectFields + " FROM " + fromClause + where + " ORDER BY u.created_at DESC LIMIT ? OFFSET ?" - batchArgs := append(args, 1000, offset) + // 复制一份再追加分页参数,避免污染循环外构建的共用筛选参数 + batchArgs := append(append([]any{}, filterArgs...), 1000, offset) result, err := g.DB().Ctx(ctx).Query(ctx, sql, batchArgs...) if err != nil { g.Log().Errorf(ctx, "ExportUsageLogs: query batch at offset %d failed: %v", offset, err) diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 881f4fc..f1cfe01 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -129,6 +129,7 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/billing-records", perm: "billing:view"}, {method: "GET", path: "/api/admin/billing-records/export", perm: "billing:export"}, {method: "GET", path: "/api/admin/usage-logs", perm: "billing:view"}, + {method: "GET", path: "/api/admin/usage-logs/summary", perm: "billing:view"}, {method: "GET", path: "/api/admin/usage-logs/export", perm: "billing:export"}, {method: "GET", path: "/api/admin/transactions", perm: "billing:view"}, {method: "GET", path: "/api/admin/wallets", perm: "billing:view"}, diff --git a/internal/service/admin.go b/internal/service/admin.go index 0b9246b..5b83848 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -159,6 +159,8 @@ type ( GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (*v1.AdminDashboardModelHourlyRes, error) // GetAllUsageLogs 获取所有租户的用量日志(管理后台) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) + // GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径) + GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error) // GetAllBillingRecords 获取所有计费记录(管理后台) GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error) // GetTenantWallets 获取所有租户钱包(管理后台) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 229d1ec..12a5ad1 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -155,6 +155,7 @@ type ( // ListMemberApiKeys returns a paginated list of API keys belonging to a specific member. ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error) // ExportMembers exports the tenant member list as CSV or Excel. + // 导出列与成员列表页表格保持一致(用户/角色/状态/额度限制/可用模型/本月消费/加入时间/最后更新)。 ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error) // MemberImport parses CSV content, validates, creates an import record. MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index 40ececb..d693ebc 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -13,10 +13,13 @@ import request from '@/utils/request' import ResponsiveTable from '@/components/ResponsiveTable.vue' import { useExport } from '@/composables/useExport' import { useDateRange } from '@/composables/useDateRange' +import { useIsMobile } from '@/composables/useIsMobile' const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange() +const isMobileView = useIsMobile() const loading = ref(false) +const summaryLoading = ref(false) const data = ref([]) const pagination = reactive({ current: 1, @@ -25,6 +28,14 @@ const pagination = reactive({ showPageSize: true, pageSizeOptions: [10, 20, 50, 100], }) +const summary = ref<{ + total_cost: number + total_output_tokens: number + cache_read_ratio: number +} | null>(null) + +// 用于判断是否需要重新加载统计数据的筛选条件快照 +let lastSummaryFilters = '' const filterId = ref(undefined) const filterTenantId = ref(undefined) @@ -167,6 +178,18 @@ function formatTime(s: string): string { return s.replace('T', ' ').substring(0, 19) } +// 格式化金额(保留2位小数) +function formatMoney(amount: number): string { + if (amount == null || isNaN(amount)) return '$0.00' + return '$' + amount.toFixed(2) +} + +// 格式化数字(添加千分位) +function formatNumber(num: number): string { + if (num == null || isNaN(num)) return '0' + return num.toLocaleString() +} + function hasUpstreamModel(log: any): boolean { return log.upstream_model && log.upstream_model !== log.model_name && log.upstream_model !== '' } @@ -437,9 +460,49 @@ async function fetchData() { } } +// 获取统计数据(独立接口异步加载) +async function fetchSummary() { + // 构建筛选条件参数(不包含分页) + const params: Record = {} + if (filterId.value) params.id = filterId.value + if (filterTenantId.value) params.tenant_id = filterTenantId.value + if (filterUserId.value) params.user_id = filterUserId.value + if (filterModel.value) params.model = filterModel.value + if (filterApiKeyId.value) params.api_key_id = filterApiKeyId.value + if (filterRequestType.value) params.request_type = filterRequestType.value + if (filterChannelId.value) params.channel_id = filterChannelId.value + if (filterStatus.value) params.status = filterStatus.value + if (filterDateRange.value && filterDateRange.value.length === 2) { + params.start_date = filterDateRange.value[0] + if (filterDateRange.value[1] && filterDateRange.value[1] !== defaultEnd) { + params.end_date = filterDateRange.value[1] + } + } + + // 生成筛选条件的唯一标识 + const currentFilters = JSON.stringify(params) + + // 如果筛选条件没有变化,不重新加载统计数据 + if (currentFilters === lastSummaryFilters && summary.value !== null) { + return + } + + summaryLoading.value = true + try { + const res: any = await request.get('/admin/usage-logs/summary', { params }) + summary.value = res.data?.data || null + lastSummaryFilters = currentFilters + } catch { + summary.value = null + } finally { + summaryLoading.value = false + } +} + function handleFilter() { pagination.current = 1 fetchData() + fetchSummary() // 筛选条件变化时重新加载统计数据 } function handleReset() { @@ -454,6 +517,7 @@ function handleReset() { filterDateRange.value = defaultTodayRange() pagination.current = 1 fetchData() + fetchSummary() // 重置后重新加载统计数据 } // 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在) @@ -465,6 +529,7 @@ onMounted(() => { fetchTenantOptions() fetchModelOptions() fetchData() + fetchSummary() // 初始加载时获取统计数据 }) const { exporting, exportFile } = useExport({ @@ -605,17 +670,45 @@ const { exporting, exportFile } = useExport({ card-badge-key="status" :card-fields="[{ key: 'username' }, { key: 'channel_name' }, { key: 'tokens', full: true }, { key: 'request_type' }, { key: 'cost' }, { key: 'latency_ms' }, { key: 'created_at' }]" /> + @@ -1401,15 +1494,81 @@ const { exporting, exportFile } = useExport({ .text-success { color: #00b42a !important; } .text-warning { color: #ff7d00 !important; } +/* 统计汇总样式 */ +.summary-item { + display: inline-flex; + align-items: center; + font-size: 13px; +} + +.summary-label { + color: var(--color-text-2); + margin-right: 6px; +} + +.summary-value { + font-weight: 600; + font-size: 14px; +} + +/* 不同指标使用不同颜色 */ +.summary-cost .summary-value { + color: rgb(var(--primary-6)); /* 蓝色 - 总费用 */ +} + +.summary-tokens .summary-value { + color: rgb(var(--success-6)); /* 绿色 - Token数 */ +} + +.summary-cache .summary-value { + color: rgb(var(--warning-6)); /* 橙色 - 缓存占比 */ +} + .table-footer { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 16px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--color-border-light, #e5e6eb); } + +.table-footer-left { + flex: 1; + min-width: 0; +} + +.table-footer-right { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +/* 移动端:统计栏和分页栏分两行显示 */ +@media (max-width: 768px) { + .table-footer { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .table-footer-right { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .summary-item { + font-size: 12px; + } + + .summary-item .summary-value { + font-size: 13px; + } +} + /* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */ .table-footer :deep(.table-stats) { margin-bottom: 0;