feat(usage):用量记录添加统计信息

This commit is contained in:
chengqian committed 2026-08-19 20:54:52 +08:00
1 parent 330d02f19d
commit 54ccfb9b80
8 files changed
+330 -146

No files matched your search

+1
View File
@@ -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)
+25 -17
View File
@@ -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{}
@@ -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)
}
+127 -118
View File
@@ -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)
+1
View File
@@ -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"},
+2
View File
@@ -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 获取所有租户钱包(管理后台)
+1
View File
@@ -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)
+170 -11
View File
@@ -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<any[]>([])
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<number | undefined>(undefined)
const filterTenantId = ref<number | undefined>(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<string, any> = {}
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' }]"
/>
<div class="table-footer">
<TableStats :total="pagination.total" />
<a-pagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-size-options="pagination.pageSizeOptions"
show-page-size
@change="fetchData"
@page-size-change="(size: number) => { pagination.pageSize = size; pagination.current = 1; fetchData() }"
/>
<div class="table-footer-left">
<!-- 统计汇总 -->
<a-space v-if="summary" :size="16" wrap>
<div class="summary-item summary-cost">
<span class="summary-label">总费用:</span>
<span class="summary-value">{{ formatMoney(summary.total_cost) }}</span>
</div>
<a-divider direction="vertical" style="height: 16px; margin: 0" />
<div class="summary-item summary-tokens">
<span class="summary-label">总输出Token:</span>
<span class="summary-value">{{ formatNumber(summary.total_output_tokens) }}</span>
</div>
<a-divider direction="vertical" style="height: 16px; margin: 0" />
<div class="summary-item summary-cache">
<span class="summary-label">缓存读取占比:</span>
<span class="summary-value">{{ (summary.cache_read_ratio || 0).toFixed(2) }}%</span>
</div>
</a-space>
</div>
<div class="table-footer-right">
<TableStats :total="pagination.total" />
<a-pagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-size-options="pagination.pageSizeOptions"
:simple="isMobileView"
show-page-size
@change="fetchData"
@page-size-change="(size: number) => { pagination.pageSize = size; pagination.current = 1; fetchData() }"
/>
</div>
</div>
</a-card>
@@ -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;