diff --git a/migrations/000017_unify_input_tokens_cache_semantics.sql b/migrations/000017_unify_input_tokens_cache_semantics.sql index 5fcf7cd..a227374 100644 --- a/migrations/000017_unify_input_tokens_cache_semantics.sql +++ b/migrations/000017_unify_input_tokens_cache_semantics.sql @@ -1,17 +1,4 @@ -- +goose Up --- input_tokens 口径统一:bil_usage_logs.input_tokens 从此统一为「含缓存的总输入」。 --- 历史问题:OpenAI/Gemini 渠道(promptTokenCount/prompt_tokens 原生含缓存)与 Claude 渠道 --- (input_tokens 原生不含缓存)写入同一列但语义不同,跨渠道 SUM 聚合与缓存命中率分母失真。 --- 写侧已改为统一按 TotalInputTokens() 入库(Claude 口径补加 cache_read + cache_creation), --- 本迁移回填历史 Claude 渠道行(channel_type=2)使存量数据同口径。 --- 上线后需用 aggregate-usage 命令回填 bil_usage_daily 历史区间(ON CONFLICT 幂等): --- team-api aggregate-usage --from <最早用量日期> --to <今天> - -UPDATE bil_usage_logs -SET input_tokens = input_tokens + COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0), - total_tokens = total_tokens + COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0) -WHERE channel_type = 2 - AND (COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)) > 0; -- 列语义注释同步更新(cache_creation_tokens 兼收 OpenAI Responses 的 cache_write_tokens) COMMENT ON COLUMN bil_usage_logs.input_tokens IS '输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)'; @@ -20,11 +7,3 @@ COMMENT ON COLUMN bil_usage_daily.input_tokens IS '输入Token合计(含缓存 COMMENT ON COLUMN bil_usage_daily.cache_creation_tokens IS '写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)'; -- +goose Down --- 逆转回填:Claude 渠道行恢复「不含缓存」口径(OpenAI/Gemini 行本就含缓存,不受影响)。 --- 注意:Down 后需再次执行 aggregate-usage 回滚 bil_usage_daily。 - -UPDATE bil_usage_logs -SET input_tokens = input_tokens - COALESCE(cache_read_tokens, 0) - COALESCE(cache_creation_tokens, 0), - total_tokens = total_tokens - COALESCE(cache_read_tokens, 0) - COALESCE(cache_creation_tokens, 0) -WHERE channel_type = 2 - AND (COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)) > 0; diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index 02b62ed..40ececb 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router' import { Message, Tag, Tooltip, Space, Button, } from '@arco-design/web-vue' +import { IconArchive, IconSave } from '@arco-design/web-vue/es/icon' import type { TableColumnData } from '@arco-design/web-vue' import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' @@ -278,7 +279,7 @@ const columns: TableColumnData[] = [ }, }, { - title: 'Token', dataIndex: 'tokens', width: 250, + title: 'Token', dataIndex: 'tokens', width: 180, render({ record }) { const tooltipContent = [ h('div', { class: 'dark-tooltip-title' }, 'Token 详情'), @@ -305,19 +306,31 @@ const columns: TableColumnData[] = [ position: 'right', }, { default: () => h('div', { class: 'token-cell' }, [ - h('div', { class: 'token-row' }, [ - h('span', { class: 'token-item', style: 'color: #18a058' }, `↑${(record.input_tokens || 0).toLocaleString()}`), - h('span', { class: 'token-item', style: 'color: #722ed1' }, `↓${(record.output_tokens || 0).toLocaleString()}`), - h('span', { class: 'token-item', style: 'color: #ff7d00' }, `✎${(record.cache_creation_tokens || 0).toLocaleString()}`), - h('span', { class: 'token-item', style: 'color: #0fc6c2' }, `⚡${(record.cache_read_tokens || 0).toLocaleString()}`), + h('div', { class: 'token-row' }, [ + h('span', { class: 'token-item', style: 'color: #18a058', 'aria-label': `输入 Token ${(record.input_tokens || 0).toLocaleString()}` }, [ + h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '↑'), + h('span', { class: 'token-value' }, (record.input_tokens || 0).toLocaleString()), ]), + h('span', { class: 'token-item', style: 'color: #722ed1', 'aria-label': `输出 Token ${(record.output_tokens || 0).toLocaleString()}` }, [ + h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '↓'), + h('span', { class: 'token-value' }, (record.output_tokens || 0).toLocaleString()), + ]), + h('span', { class: 'token-item', style: 'color: #ff7d00', 'aria-label': `缓存写入 ${(record.cache_creation_tokens || 0).toLocaleString()}` }, [ + h(IconSave, { size: 13, 'aria-hidden': 'true' }), + h('span', { class: 'token-value' }, (record.cache_creation_tokens || 0).toLocaleString()), + ]), + h('span', { class: 'token-item', style: 'color: #0fc6c2', 'aria-label': `缓存读取 ${(record.cache_read_tokens || 0).toLocaleString()}` }, [ + h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '⚡'), + h('span', { class: 'token-value' }, (record.cache_read_tokens || 0).toLocaleString()), + ]), + ]), ]), content: () => h('div', { class: 'dark-tooltip' }, tooltipContent), }) }, }, { - title: '费用', dataIndex: 'cost', width: 120, + title: '费用', dataIndex: 'cost', width: 100, render({ record }) { const tooltipContent = [ h('div', { class: 'dark-tooltip-title' }, '费用明细'), @@ -364,7 +377,7 @@ const columns: TableColumnData[] = [ }, }, { - title: '状态', dataIndex: 'status', width: 90, + title: '状态', dataIndex: 'status', width: 80, render({ record }) { const items: any[] = [ h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusLabel[record.status] || record.status), @@ -376,7 +389,7 @@ const columns: TableColumnData[] = [ }, }, { - title: '时间', dataIndex: 'created_at', width: 180, + title: '时间', dataIndex: 'created_at', width: 160, render({ record }) { return h('span', { class: 'time-text' }, formatTime(record.created_at)) }, @@ -1041,13 +1054,16 @@ const { exporting, exportFile } = useExport({ background: rgba(22, 93, 255, 0.06); } .token-row { - display: flex; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: center; - justify-content: flex-start; - line-height: 1.8; + column-gap: 12px; + row-gap: 0; + line-height: 1.4; } .token-item { - min-width: 50px; + min-width: 0; + width: 100%; font-size: 12px; font-weight: 600; white-space: nowrap; @@ -1055,6 +1071,16 @@ const { exporting, exportFile } = useExport({ align-items: center; gap: 4px; } +.token-item > svg, +.token-symbol { + flex-shrink: 0; +} +.token-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} .token-in { color: #18a058; } .token-out { color: #722ed1; } .token-cache-read { color: #0fc6c2; }