@mzzsfy/dsh-usage-dash 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  - 三粒度视图,全部支持自定义时间范围:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天/自定义时刻段)、分钟(3 小时/24 小时/3 天/7 天/自定义时刻段,10 分钟桶粒度),预设挡为滚动窗口;自定义时刻段为起止 datetime 双输入,两端归一到桶边界(小时取所在整点、分钟起点对齐 10 分钟且终点保留原分钟,均闭区间),跨度上限与数据保留期一致(小时 15 天、分钟 7 天,超限自动钳起点),首次切到自定义挡预填最近 24 小时,时/分两视图共享同一对自定义输入;已知限制:自定义输入残缺(任一端为空)时面板不发请求且图表静默空白,与天视图行为一致,交互反馈留待后续增强
10
10
  - 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
11
- - 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细;仅按天视图展示
11
+ - 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细(配置定价规则后附「费用 ≈」估算行,受「费用显示」开关;费用数据依赖小时桶保留期,仅最近 15 天的天显示,更早的天无该行);仅按天视图展示
12
12
  - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
13
  - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量);每行名称两行展示(厂商/模型名,其余折叠为「其他」),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
14
- - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
15
- - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
14
+ - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题与左轴同步切金额读数(悬停明细保持 token 口径与估算费用行不变,命中率/速度/首 token 延迟曲线不受影响),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
15
+ - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
16
16
  - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
17
17
  - 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
18
18
  - 定价规则编辑器:设置面板内按**模型分组**编辑,组头 = 模型键(整组一次改名,改后即时重新聚合)+ 删除整组;组内 = **默认价**槽(价格四桶,不设条件——附加规则全不命中时兜底,禁排序/删除)+ **附加计费规则**列表(四桶单价 + 条件组合,「+添加条件」追加条件行,行内下拉切换类型,规则带 ↑/↓ 调序与删除),「添加额外计费规则」追加一条(默认带全天时段条件);货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一),显式「保存」整表写入
@@ -36,7 +36,7 @@
36
36
  - `weekdays`:`{ days: [0-6] }` 星期几(集合,非范围),0=周日,空数组不成立;编辑器为日~六七枚 pill 多选
37
37
  - `monthDays`:`{ from, to }` 月内号段双侧包含整数(1-31),from>to 跨月环绕(账单周期),from===to 单日(如 5~5 即 5 号);2 月无 31 号自然不触发
38
38
  - `dateRange`:`{ from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }` 零填充字典序双侧包含,from===to 单日,from>to 倒序不成立(不可保存)
39
- - 费用精度:**小时级**——聚合按小时桶起点时刻匹配价格,分钟槽费用由其所属小时桶价格导出;改价即时生效,历史费用下次查询按新规则重算(不回溯账单)
39
+ - 费用精度:**小时级**——聚合按小时桶起点时刻匹配价格,分钟槽费用由其所属小时桶价格导出;改价即时生效,历史费用下次查询按新规则重算(不回溯账单)。定价激活时聚合响应在槽级携带 `cost` 与逐模型拆分 `costByModel`(金额柱状图与悬浮读数的数据源)
40
40
  - 时区口径:匹配与聚合均用 host 进程本地时区;client 侧注入点(底栏费用项/回合费用芯片)按**浏览器本地时区**的当前时刻评估条件,跨时区访问时与面板费用存在预期内偏差;所有费用均为按当前费率的估算值(标注「≈」与「估算」),不构成账单
41
41
 
42
42
  ## 与 dsh-usage-statistics-panel 的关系
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-dash",
3
3
  "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
- "version": "0.7.1",
4
+ "version": "0.9.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -13,7 +13,7 @@ const MINUTE_PRESET_MINUTES = { '3h': 3 * 60, '24h': 24 * 60, '3d': 3 * 24 * 60,
13
13
 
14
14
  const DEFAULT_RANGE = '30'
15
15
  const DEFAULT_HOUR_PRESET = '24h'
16
- const DEFAULT_MINUTE_PRESET = '24h'
16
+ const DEFAULT_MINUTE_PRESET = '3h'
17
17
 
18
18
  // 天视图渲染上限;时/分上限 = 闭区间桶数(hour N+1 槽,minute N/10+1 槽)
19
19
  const DAY_MAX_SLOTS = 180
@@ -196,6 +196,7 @@ const MESSAGES_ZH = {
196
196
  avgSpeed: '平均生成速度',
197
197
  speedLegend: '平均生成速度',
198
198
  ttftLegend: '首 token 延迟',
199
+ avgPrice: '均价',
199
200
  topModel: '最常用模型',
200
201
  topModelHint: '按 token 用量排序,非调用次数',
201
202
  heatmap: '活跃热力图',
@@ -203,6 +204,10 @@ const MESSAGES_ZH = {
203
204
  heatMore: '较多',
204
205
  dailyTrend: '按天 Token 趋势',
205
206
  trendLimited: '仅显示最近 {n} 天',
207
+ dailyCostTrend: '按天金额趋势',
208
+ hourCostTrend: '按小时金额趋势',
209
+ minuteCostTrend: '按分钟金额趋势',
210
+ costTrend: '金额',
206
211
  modelUsage: '模型用量',
207
212
  modelCache: '缓存',
208
213
  modelInput: '输入',
@@ -339,6 +344,7 @@ const MESSAGES_EN = {
339
344
  avgSpeed: 'Avg speed',
340
345
  speedLegend: 'Avg speed',
341
346
  ttftLegend: 'First-token latency',
347
+ avgPrice: 'Avg price',
342
348
  topModel: 'Top model',
343
349
  topModelHint: 'Ranked by token usage, not call count',
344
350
  heatmap: 'Activity heatmap',
@@ -346,6 +352,10 @@ const MESSAGES_EN = {
346
352
  heatMore: 'More',
347
353
  dailyTrend: 'Daily token trend',
348
354
  trendLimited: 'Showing only the last {n} days',
355
+ dailyCostTrend: 'Daily cost trend',
356
+ hourCostTrend: 'Hourly cost trend',
357
+ minuteCostTrend: 'Per-minute cost trend',
358
+ costTrend: 'Cost',
349
359
  modelUsage: 'Model usage',
350
360
  modelCache: 'Cache',
351
361
  modelInput: 'Input',
@@ -545,24 +555,53 @@ function logTimeOf(ms) {
545
555
  const toRankedModels = (totals) =>
546
556
  [...totals.entries()].map(([model, tokens]) => ({ model, tokens })).sort((a, b) => b.tokens - a.tokens)
547
557
 
548
- // 逐槽 byModel 把非 top 模型并入哨兵桶,原明细留 otherByModel 供 tooltip;模型顺序 = 图例序(哨兵恒最后)
558
+ // 逐槽把非 top 模型并入哨兵桶,token 与费用双度量同折,原明细留 otherByModel/otherCostByModel 供
559
+ // tooltip;模型顺序 = 图例序(哨兵恒最后)
560
+ const foldMetricByTop = (entries, top) => {
561
+ const kept = {}
562
+ const other = {}
563
+ for (const [model, value] of Object.entries(entries)) {
564
+ if (top.has(model)) {
565
+ kept[model] = (kept[model] ?? 0) + value
566
+ continue
567
+ }
568
+ kept[OTHER_MODEL] = (kept[OTHER_MODEL] ?? 0) + value
569
+ other[model] = (other[model] ?? 0) + value
570
+ }
571
+ return { kept, other }
572
+ }
573
+
549
574
  const foldSlotsByTop = (slots, topModels) => {
550
575
  const top = new Set(topModels)
551
576
  return slots.map((slot) => {
552
- const byModel = {}
553
- const otherByModel = {}
554
- for (const [model, tokens] of Object.entries(slot.byModel)) {
555
- if (top.has(model)) {
556
- byModel[model] = (byModel[model] ?? 0) + tokens
557
- continue
558
- }
559
- byModel[OTHER_MODEL] = (byModel[OTHER_MODEL] ?? 0) + tokens
560
- otherByModel[model] = (otherByModel[model] ?? 0) + tokens
577
+ const tokens = foldMetricByTop(slot.byModel, top)
578
+ const costs = foldMetricByTop(slot.costByModel ?? {}, top)
579
+ return {
580
+ ...slot,
581
+ byModel: tokens.kept,
582
+ otherByModel: tokens.other,
583
+ byCostModel: costs.kept,
584
+ otherCostByModel: costs.other,
561
585
  }
562
- return { ...slot, byModel, otherByModel }
563
586
  })
564
587
  }
565
588
 
589
+ // 金额槽投影:total/byModel/otherByModel 切换为费用口径(调用方保证费用字段在场)
590
+ const costSlotsOf = (slots) => slots.map((slot) => ({
591
+ ...slot,
592
+ total: slot.cost,
593
+ byModel: slot.byCostModel,
594
+ otherByModel: slot.otherCostByModel,
595
+ }))
596
+
597
+ // 金额视图派生:存在正计价槽才可用(零计价规则/未配价的金额图全零有误导),
598
+ // 开关开且可用才投影金额口径,否则原槽透传(引用不变)
599
+ const moneyViewOf = (slots, costView) => {
600
+ const costReady = !!slots && slots.some((slot) => slot.cost > 0)
601
+ const money = costView && costReady
602
+ return { costReady, money, chartSlots: money ? costSlotsOf(slots) : slots }
603
+ }
604
+
566
605
  const topWithOther = (ranked) => {
567
606
  const models = ranked.slice(0, GROUP_TOP_COUNT)
568
607
  if (ranked.length > GROUP_TOP_COUNT) {
@@ -597,6 +636,15 @@ function groupPointSlots(slots) {
597
636
  return { models, daily: foldSlotsByTop(slots, topModels) }
598
637
  }
599
638
 
639
+ // 聚合展示区(统计卡/模型用量/统计截至)数据源:随视图联动——天视图用 range 聚合,时/分视图
640
+ // (预设与自定义挡)用点端点聚合,字段同形;点数据未回为 null,调用方判空不渲染
641
+ const cardsStatsOf = (view, stats, pointView) => (view === 'day' ? stats : pointView)
642
+
643
+ // 模型用量列表:天视图用预折叠分组,时/分视图折叠点窗口模型(top5+other),点数据未回为 null
644
+ const usageModelsOf = (view, grouped, pointView) => (
645
+ view === 'day' ? grouped?.models ?? null : pointView ? topWithOther(pointView.models) : null
646
+ )
647
+
600
648
  // 趋势图视口常量
601
649
  const CHART_HEIGHT = 220
602
650
  const CHART_PAD = { left: 46, right: 65, top: 10, bottom: 26 }
@@ -610,6 +658,8 @@ const SPEED_SCALE_FLOOR = 1
610
658
  const TTFT_SCALE_FLOOR = 1
611
659
  // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
612
660
  const AXIS_SCALE_HEADROOM = 1.1
661
+ // 金额刻度下限:仅防零值退化,亚分峰值不参与轴标定失真
662
+ const COST_TOTAL_FLOOR = 0.0001
613
663
  // 图例键:折线项与模型项共处同一显隐集合
614
664
  const LEGEND_KEY_RATE = 'rate'
615
665
  const LEGEND_KEY_SPEED = 'speed'
@@ -626,7 +676,7 @@ function niceTicks(max, count) {
626
676
  return ticks
627
677
  }
628
678
 
629
- // 图例显隐切换:current 为 null 表示全部可见;普通点击单选/再点恢复,
679
+ // 图例显隐切换:current 为当前实际生效集;普通点击单选/再点恢复,
630
680
  // ctrl 单项切换,隐藏最后一项为无操作(返回原引用)
631
681
  function legendToggle(current, key, ctrl) {
632
682
  if (ctrl) {
@@ -639,26 +689,26 @@ function legendToggle(current, key, ctrl) {
639
689
  return solo ? null : new Set([key])
640
690
  }
641
691
 
642
- // 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定 token;
692
+ // 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定柱度量(token 或金额,格式化由调用方注入);
643
693
  // 刻度值统一换算为绘图区高度占比
644
- function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax) {
694
+ function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax, format = formatCompact) {
645
695
  const useSpeed = speedTicks.length > 0
646
696
  return (useSpeed ? speedTicks : tokenTicks).map((tick) => ({
647
- label: formatCompact(tick),
697
+ label: (useSpeed ? formatCompact : format)(tick),
648
698
  ratio: tick / (useSpeed ? speedMax : tokenMax),
649
699
  }))
650
700
  }
651
701
 
652
702
  // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度;
653
- // maxTotal 按可见模型求和,单选模型时刻度跟随归一
654
- function trendLayout(slots, modelOrder, avail, labelMinPitch) {
703
+ // maxTotal 按可见模型求和,单选模型时刻度跟随归一;totalFloor 为量纲下限(token 个位数/金额亚分)
704
+ function trendLayout(slots, modelOrder, avail, labelMinPitch, totalFloor = 1) {
655
705
  const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
656
706
  const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
657
707
  const count = slots.length
658
708
  const step = count > 1 ? innerWidth / (count - 1) : innerWidth
659
709
  const barWidth = Math.max(BAR_MIN_WIDTH, Math.min(BAR_MAX_WIDTH, step * BAR_WIDTH_RATIO))
660
710
  const slotVisibleTotal = (slot) => modelOrder.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0)
661
- const maxTotal = Math.max(1, ...slots.map(slotVisibleTotal))
711
+ const maxTotal = Math.max(totalFloor, ...slots.map(slotVisibleTotal))
662
712
  // 轴上限 = 数据峰 × 留白系数,柱高分母与左轴刻度分母同源
663
713
  const scaleMax = maxTotal * AXIS_SCALE_HEADROOM
664
714
  // 可见模型无任何数据时左轴无标定对象,空刻度防钳底值漏成假刻度
@@ -862,6 +912,17 @@ function modelIoPercentText(item, t) {
862
912
  // 趋势 tooltip 内容门:全零槽(无任何用量)不渲染 tooltip
863
913
  const hasTipContent = (hoverSlot) => Boolean(hoverSlot) && hoverSlot.total > 0
864
914
 
915
+ // 折叠提醒判定:折叠层内有异常日志条目或写入失败即值得提醒
916
+ const hasAnomalyNews = (status) => !!status
917
+ && ((status.log?.length ?? 0) > 0 || (status.recordFailures ?? 0) > 0)
918
+
919
+ // 趋势折线默认显隐集:模型全显 + 速度线(有数据时);缓存命中率与首 token 延迟默认隐藏,
920
+ // 图例切换后以组件内键集接管(不持久化)
921
+ const trendDefaultVisibleKeys = (modelOrder, hasSpeed) => new Set([
922
+ ...modelOrder,
923
+ ...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
924
+ ])
925
+
865
926
  // 趋势 tooltip 模型行数据:主行 = 可见且当前时段有用量的模型;OTHER 子行 = OTHER 可见
866
927
  // 且当前时段有用量的其余模型明细,按 tokens 降序由本函数保证
867
928
  const tipModelEntries = (hoverSlot, visibleSet) => {
@@ -875,6 +936,11 @@ const tipModelEntries = (hoverSlot, visibleSet) => {
875
936
  return { main, other }
876
937
  }
877
938
 
939
+ // 趋势 tooltip 槽级均价:槽费用折每百万 token 单价;无槽或无有效费用(含零值)为 null
940
+ const tipAvgPriceText = (hoverSlot, currency) => (
941
+ hoverSlot ? avgPriceText(hoverSlot.cost, hoverSlot.total, currency) || null : null
942
+ )
943
+
878
944
  // 热力图:窗口固定 26 周,与所选范围无关
879
945
  const HEAT_WEEKS = 26
880
946
  const HEAT_ROW_COUNT = 7
@@ -1378,17 +1444,35 @@ const COST_MICRO_THRESHOLD = 0.01
1378
1444
  const COST_PLACEHOLDER = '—'
1379
1445
  const THOUSANDS_PATTERN = /(\d)(?=(\d{3})+(?!\d))/g
1380
1446
 
1381
- // 千分位 + 至少两位小数,正值小于 0.01 时四位;货币空串不加符号,「≈」前缀由调用方拼
1382
- function formatCost(value, currency) {
1383
- const decimals = value > 0 && value < COST_MICRO_THRESHOLD ? COST_MICRO_DECIMALS : COST_DECIMALS
1384
- const [whole, fraction] = value.toFixed(decimals).split('.')
1447
+ // 千分位 + 至少 decimals 位小数,正值小于 10^-decimals 时小数位 +2;货币空串不加符号,「≈」前缀由调用方拼
1448
+ function formatCost(value, currency, decimals = COST_DECIMALS) {
1449
+ const microThreshold = 10 ** -decimals
1450
+ const used = value > 0 && value < microThreshold ? decimals + (COST_MICRO_DECIMALS - COST_DECIMALS) : decimals
1451
+ const [whole, fraction] = value.toFixed(used).split('.')
1385
1452
  return `${currency}${whole.replace(THOUSANDS_PATTERN, '$1,')}.${fraction}`
1386
1453
  }
1387
1454
 
1455
+ const COST_CENT_SCALE = 100
1456
+ const COST_MICRO_SCALE = COST_CENT_SCALE * COST_CENT_SCALE
1457
+
1458
+ // 金额轴紧凑读数:千以上 k/M 一位小数,分上取两位内,分下四位(微观阈值与 formatCost 同源)
1459
+ function formatCostCompact(value, currency) {
1460
+ if (value >= COMPACT_BASE ** 2) return `${currency}${(value / COMPACT_BASE ** 2).toFixed(DECIMAL_DIGITS)}M`
1461
+ if (value >= COMPACT_BASE) return `${currency}${(value / COMPACT_BASE).toFixed(DECIMAL_DIGITS)}k`
1462
+ if (value >= COST_MICRO_THRESHOLD) return `${currency}${Math.round(value * COST_CENT_SCALE) / COST_CENT_SCALE}`
1463
+ return `${currency}${Math.round(value * COST_MICRO_SCALE) / COST_MICRO_SCALE}`
1464
+ }
1465
+
1466
+ // 模型均价:费用折算每百万 token 单价;无费用(cost 恒正数才有效,0 视为未配价)或零 token 无均价
1467
+ const AVG_PRICE_DECIMALS = 3
1468
+ function avgPriceText(cost, tokens, currency) {
1469
+ if (!(cost > 0) || !(tokens > 0)) return ''
1470
+ return `${formatCost(cost / (tokens / TOKENS_PER_MILLION), currency, AVG_PRICE_DECIMALS)}/M`
1471
+ }
1472
+
1388
1473
  // 全局显示货币:规则表首个非空 currency,所有费用显示点统一取此值(全局价格定位);
1389
1474
  // 无则空串即不带符号。数值仍按命中规则单价计算,符号不随命中规则变化
1390
- function aggregateCurrencyOf(rules) {
1391
- if (!Array.isArray(rules)) return ''
1475
+ function aggregateCurrencyOf(rules) { if (!Array.isArray(rules)) return ''
1392
1476
  const found = rules.find((rule) => typeof rule?.currency === 'string' && rule.currency !== '')
1393
1477
  return found ? found.currency : ''
1394
1478
  }
@@ -1762,6 +1846,12 @@ function removeRulesAt(rules, indexes) {
1762
1846
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1763
1847
  window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-usage-dash', factory })
1764
1848
 
1849
+ // 导航分区图标:表盘(dash 概念);键覆盖中英分区名 + 市场短名,语言切换下声明不漂移
1850
+ const NAV_ICON = { '使用统计': 'gauge', 'Usage': 'gauge', 'dsh-usage-dash': 'gauge' }
1851
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
1852
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
1853
+ else window.__navicIconQueue = [NAV_ICON]
1854
+
1765
1855
  function factory(require) {
1766
1856
  let React = null
1767
1857
  try {
@@ -1846,7 +1936,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1846
1936
  const ICON_STROKE_WIDTH = 2
1847
1937
  const NOTE_SEPARATOR = ' · '
1848
1938
  const TIP_Z_INDEX = 1100
1849
- const STYLE_ID = 'dsh-usage-dash'
1939
+ // 与注册 id 同源:既作样式查重键,又作 data-plugin 标记值,短名会使自身 rebuilt 删不到旧样式
1940
+ const STYLE_ID = '@mzzsfy/dsh-usage-dash'
1850
1941
  // 开关视觉常量(规约形态:隐藏 checkbox + track 胶囊 + thumb 圆点)
1851
1942
  const SWITCH_TRACK_WIDTH = 40
1852
1943
  const SWITCH_TRACK_HEIGHT = 22
@@ -1955,10 +2046,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1955
2046
  .ud-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-tertiary);text-align:center;padding:24px 16px;font-size:12px}
1956
2047
  .ud-foot{color:var(--dsw-alias-label-tertiary);font-size:11px}
1957
2048
  .ud-status{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:4px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
1958
- .ud-status-fold{display:inline-flex;align-items:center;justify-content:center;width:28px;height:100%;min-height:28px;border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:6px}
2049
+ .ud-status-fold{position:relative;display:inline-flex;align-items:center;justify-content:center;width:28px;height:100%;min-height:28px;border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:6px}
1959
2050
  .ud-status-fold:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
1960
2051
  .ud-status-fold-caret{display:block;width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid currentColor;transition:transform .15s ease}
1961
2052
  .ud-status-fold[aria-expanded="true"] .ud-status-fold-caret{transform:rotate(180deg)}
2053
+ .ud-status-fold-dot{position:absolute;top:4px;right:4px;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-error-primary)}
1962
2054
  .ud-status-track{display:inline-block;width:120px;height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden}
1963
2055
  .ud-status-fill{display:block;height:100%;background:var(--dsw-alias-state-business-primary)}
1964
2056
  .ud-status-err{color:var(--dsw-alias-state-error-primary)}
@@ -1987,6 +2079,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1987
2079
  .ud-section-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
1988
2080
  .ud-section-title{font-size:15px;font-weight:600}
1989
2081
  .ud-trend-note{font-size:11px;color:var(--dsw-alias-label-tertiary)}
2082
+ .ud-trend-metric{display:inline-flex;align-items:center;gap:6px;margin-left:auto}
2083
+ .ud-trend-metric-label{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
1990
2084
  .ud-chart-wrap{width:100%;min-width:0}
1991
2085
  .ud-chart{display:block;width:100%}
1992
2086
  .ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
@@ -2202,11 +2296,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2202
2296
  return `var(--ud-chart-${rank})`
2203
2297
  }
2204
2298
 
2205
- function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
2299
+ function TrendChart({ title, notes, slots, tipSlots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', money = false, costAvailable = false, onMetricToggle, t = defaultT }) {
2300
+ const metricId = React.useId()
2206
2301
  const wrapRef = useRef(null)
2207
2302
  const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
2208
2303
  const [hover, setHover] = useState(null)
2209
- // 图例显隐:null = 全部可见;键集 = 模型项与折线项(rate/speed)
2304
+ // 图例显隐:null = 默认集(trendDefaultVisibleKeys);切换以实际生效集为基线展开为键集
2210
2305
  const [visibleKeys, setVisibleKeys] = useState(null)
2211
2306
  // prefs 仅用于 hover tooltip 的费用显隐,经 ref 读取:开关切换不触发本组件重渲染,
2212
2307
  // 避免宿主设置弹窗滚动锚定被重渲染扰动而跳变
@@ -2223,16 +2318,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2223
2318
  }, [])
2224
2319
  const hasSpeed = slots.some((slot) => slot.speed !== undefined)
2225
2320
  const hasTtft = slots.some((slot) => slot.ttft !== undefined)
2226
- const visibleSet = visibleKeys ?? new Set([
2227
- ...modelOrder, LEGEND_KEY_RATE,
2228
- ...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
2229
- ...(hasTtft ? [LEGEND_KEY_TTFT] : []),
2230
- ])
2321
+ const visibleSet = visibleKeys ?? trendDefaultVisibleKeys(modelOrder, hasSpeed)
2231
2322
  const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
2232
2323
  const showRate = visibleSet.has(LEGEND_KEY_RATE)
2233
2324
  const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
2234
2325
  const showTtft = hasTtft && visibleSet.has(LEGEND_KEY_TTFT)
2235
- const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
2326
+ const layout = trendLayout(slots, visibleModels, avail, labelMinPitch, money ? COST_TOTAL_FLOOR : 1)
2236
2327
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
2237
2328
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
2238
2329
  const speedMax = speedScaleMax(slots)
@@ -2244,26 +2335,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2244
2335
  // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
2245
2336
  // (ttft 不设轴,读数走 tooltip,毫秒域与速度轴不同单位不混轴)
2246
2337
  const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
2247
- const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
2248
- const hoverSlot = hover ? slots[hover.index] : null
2338
+ // 金额视图左轴走紧凑金额读数,速度接管时仍为 tok/s 紧凑读数
2339
+ const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax,
2340
+ money ? (value) => formatCostCompact(value, costCurrency) : formatCompact)
2341
+ // 悬浮明细恒用 token 槽渲染:金额模式只切柱几何与左轴,悬浮文本不随口径变化
2342
+ const hoverSlot = hover ? (tipSlots ?? slots)[hover.index] : null
2249
2343
  const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
2250
2344
  const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
2251
2345
  const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
2252
2346
  const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
2253
2347
  const clear = () => setHover(null)
2254
2348
  const tipEntries = hasTipContent(hoverSlot) ? tipModelEntries(hoverSlot, visibleSet) : null
2349
+ const avgPrice = tipAvgPriceText(hoverSlot, costCurrency)
2255
2350
  return h('div', { className: 'ud-section' },
2256
2351
  h('div', { className: 'ud-section-head' },
2257
2352
  h('span', { className: 'ud-section-title' }, title),
2258
- notes.length > 0 ? h('span', { className: 'ud-trend-note' }, notes.join(NOTE_SEPARATOR)) : null),
2353
+ notes.length > 0 ? h('span', { className: 'ud-trend-note' }, notes.join(NOTE_SEPARATOR)) : null,
2354
+ costAvailable ? h('span', { className: 'ud-trend-metric' },
2355
+ h('span', { className: 'ud-trend-metric-label', id: metricId }, t('costTrend')),
2356
+ h(Switch, { checked: money, onChange: onMetricToggle, describedbyId: metricId, labelledById: metricId })) : null),
2259
2357
  h('div', { className: 'ud-chart-wrap', ref: wrapRef, style: busy ? { opacity: CHART_BUSY_OPACITY } : undefined },
2260
2358
  h('svg', {
2261
2359
  className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
2262
2360
  'aria-label': title, onMouseLeave: clear,
2263
2361
  },
2264
- yTicks.map((tick) => {
2362
+ yTicks.map((tick, tickIndex) => {
2265
2363
  const y = CHART_PAD.top + layout.plotHeight - tick.ratio * layout.plotHeight
2266
- return h('g', { key: tick.label },
2364
+ return h('g', { key: tickIndex },
2267
2365
  h('line', { className: 'ud-grid', x1: CHART_PAD.left, x2: plotRight, y1: y, y2: y }),
2268
2366
  h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, tick.label))
2269
2367
  }),
@@ -2317,7 +2415,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2317
2415
  h(Legend, {
2318
2416
  models: legendModels, colorFor, speedEnabled: hasSpeed, ttftEnabled: hasTtft,
2319
2417
  isVisible: (key) => visibleSet.has(key),
2320
- onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
2418
+ onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleSet, key, ctrl)),
2321
2419
  }),
2322
2420
  h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
2323
2421
  tipEntries
@@ -2332,8 +2430,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2332
2430
  showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
2333
2431
  showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
2334
2432
  showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
2335
- costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
2336
- ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
2433
+ prefsRef.current.costDisplay && hoverSlot.cost !== undefined
2434
+ ? [
2435
+ h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`),
2436
+ avgPrice ? h('div', { key: 'avg-price', className: 'ud-tip-row' }, `${t('avgPrice')}: ${avgPrice}`) : null,
2437
+ ]
2337
2438
  : null,
2338
2439
  ]
2339
2440
  : null))
@@ -2388,10 +2489,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2388
2489
  : element
2389
2490
  }
2390
2491
 
2391
- function HeatSection({ days, panelRef, t = defaultT }) {
2492
+ function HeatSection({ days, panelRef, costCurrency = '', t = defaultT }) {
2392
2493
  const wrapRef = useRef(null)
2393
2494
  const [width, setWidth] = useState(0)
2394
2495
  const [hover, setHover] = useState(null)
2496
+ // 费用行显隐随「费用显示」偏好,经 ref 读取:开关切换不触发本组件重渲染
2497
+ const prefsRef = useRef(statsLineState.get())
2498
+ useEffect(() => statsLineState.subscribe(() => { prefsRef.current = statsLineState.get() }), [])
2395
2499
  useEffect(() => {
2396
2500
  const element = wrapRef.current
2397
2501
  const observer = new ResizeObserver((entries) => {
@@ -2440,6 +2544,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2440
2544
  h('div', { key: 'tokens', className: 'ud-tip-row' }, `${t('tokens')}: ${formatTokens(hover.tokens)}`),
2441
2545
  h('div', { key: 'requests', className: 'ud-tip-row' }, `${t('requests')}: ${hover.requests}`),
2442
2546
  h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hover.cacheHit, hover.cacheMiss)}`),
2547
+ prefsRef.current.costDisplay && hover.cost > 0
2548
+ ? h('div', { key: 'cost', className: 'ud-tip-row' }, t('stats.cost', { cost: formatCost(hover.cost, costCurrency) }))
2549
+ : null,
2443
2550
  ]
2444
2551
  : null))
2445
2552
  }
@@ -2496,6 +2603,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2496
2603
  const speedText = modelSpeedText(item.speed)
2497
2604
  const ttftText = modelTtftText(item.ttft)
2498
2605
  const ioText = modelIoPercentText(item, t)
2606
+ const avgText = avgPriceText(item.cost, item.tokens, costCurrency)
2607
+ const tokensText = `${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`
2499
2608
  const metaParts = [
2500
2609
  item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
2501
2610
  ttftText,
@@ -2526,8 +2635,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2526
2635
  }, '›')
2527
2636
  : null,
2528
2637
  h('div', { className: 'ud-model-values' },
2529
- h('span', { className: 'ud-model-tokens' },
2530
- `${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
2638
+ h('span', { className: 'ud-model-tokens' }, avgText ? `${tokensText} · ${avgText}` : tokensText),
2531
2639
  ioText ? h('span', { className: 'ud-model-io' }, ioText) : null,
2532
2640
  h('span', { className: 'ud-model-meta' }, metaParts.join(' · ')))),
2533
2641
  isOther
@@ -2567,13 +2675,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2567
2675
  }
2568
2676
 
2569
2677
  // 规约形态开关:原生 checkbox 保语义并视觉隐藏,track/thumb 呈现选中态
2570
- function Switch({ checked, onChange, disabled, describedbyId }) {
2678
+ function Switch({ checked, onChange, disabled, describedbyId, labelledById }) {
2571
2679
  return h('label', { className: 'ud-switch' },
2572
2680
  h('input', {
2573
2681
  type: 'checkbox',
2574
2682
  checked,
2575
2683
  disabled,
2576
2684
  'aria-describedby': describedbyId,
2685
+ 'aria-labelledby': labelledById,
2577
2686
  onChange: (event) => onChange(event.target.checked),
2578
2687
  }),
2579
2688
  h('span', { className: 'ud-switch__track' },
@@ -2986,14 +3095,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2986
3095
  }, t('addGroup')))
2987
3096
  }
2988
3097
 
2989
- // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示
2990
- function AnomalyChip({ open, onToggle, t = defaultT }) {
3098
+ // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示;
3099
+ // 折叠且有待读信息时箭头带小点,展开后消失
3100
+ function AnomalyChip({ open, news, onToggle, t = defaultT }) {
2991
3101
  return h('button', {
2992
3102
  type: 'button', className: 'ud-status-fold', 'aria-expanded': open,
2993
3103
  'aria-label': t('anomalyLog'), title: t('anomalyLog'),
2994
3104
  onClick: onToggle,
2995
3105
  },
2996
- h('span', { className: 'ud-status-fold-caret', 'aria-hidden': 'true' }))
3106
+ h('span', { className: 'ud-status-fold-caret', 'aria-hidden': 'true' }),
3107
+ news && !open ? h('span', { className: 'ud-status-fold-dot', 'aria-hidden': 'true' }) : null)
2997
3108
  }
2998
3109
 
2999
3110
  // 回扫进度与采集错误是运行状态,常显;异常明细走日志块
@@ -3104,6 +3215,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3104
3215
 
3105
3216
  const viewLabel = (t, id) => (id === 'day' ? t('viewDay') : id === 'hour' ? t('viewHour') : t('viewMinute'))
3106
3217
  const trendTitle = (t, id) => (id === 'day' ? t('dailyTrend') : id === 'hour' ? t('hourTrend') : t('minuteTrend'))
3218
+ const trendCostTitle = (t, id) => (id === 'day' ? t('dailyCostTrend') : id === 'hour' ? t('hourCostTrend') : t('minuteCostTrend'))
3107
3219
  const trendLimitedText = (t, id, count) => (id === 'day'
3108
3220
  ? t('trendLimited', { n: count })
3109
3221
  : id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
@@ -3113,6 +3225,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3113
3225
 
3114
3226
  function UsageDashPanel({ t = defaultT }) {
3115
3227
  const [view, setView] = useState('day')
3228
+ // 趋势图金额口径开关:会话态,与视图/挡位同为面板内临时状态
3229
+ const [costView, setCostView] = useState(false)
3116
3230
  const [range, setRange] = useState(DEFAULT_RANGE)
3117
3231
  const [customFrom, setCustomFrom] = useState('')
3118
3232
  const [customTo, setCustomTo] = useState('')
@@ -3163,6 +3277,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3163
3277
  requests: slot ? slot.requests : 0,
3164
3278
  cacheHit: slot ? slot.cacheHit : 0,
3165
3279
  cacheMiss: slot ? slot.cacheMiss : 0,
3280
+ cost: slot ? slot.cost : 0,
3166
3281
  }
3167
3282
  }))
3168
3283
  })
@@ -3288,14 +3403,19 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3288
3403
  const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
3289
3404
  const pointView = activeQuery && pointStatsMatches(pointStats, view, activeQuery.key) ? pointStats.value : null
3290
3405
  const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
3291
- const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
3292
3406
 
3293
3407
  const pointActive = view !== 'day'
3294
3408
  const trendSource = pointActive
3295
3409
  ? (pointGrouped ? { slots: pointGrouped.daily, models: pointGrouped.models, value: pointView } : null)
3296
3410
  : (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
3411
+ // 聚合展示区(统计卡/模型用量/统计截至)与趋势图同源,随视图联动;点数据未回为 null 不渲染
3412
+ const cardsStats = cardsStatsOf(view, stats, pointView)
3413
+ const usageModels = usageModelsOf(view, grouped, pointView)
3414
+ // 模型配色取当期窗口模型排名,防跨窗口取色错位
3415
+ const colorFor = useMemo(() => colorForModel(cardsStats ? cardsStats.models : []), [cardsStats])
3297
3416
  const maxSlots = pointActive ? maxSlotsFor(view, rawPreset) : DAY_MAX_SLOTS
3298
3417
  const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
3418
+ const { costReady, money, chartSlots } = moneyViewOf(trimmedSlots, costView)
3299
3419
  const notes = []
3300
3420
  if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
3301
3421
  if (trendSource?.value?.truncated) notes.push(t('trendTruncated'))
@@ -3370,7 +3490,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3370
3490
  }))
3371
3491
  : null)),
3372
3492
  h('div', { className: 'ud-toolbar-side' },
3373
- h(AnomalyChip, { open: detailsOpen, onToggle: () => setDetailsOpen((v) => !v), t }),
3493
+ h(AnomalyChip, { open: detailsOpen, news: hasAnomalyNews(status), onToggle: () => setDetailsOpen((v) => !v), t }),
3374
3494
  h('button', {
3375
3495
  className: 'ud-btn ud-btn--text ud-icon-btn', 'aria-label': t('refresh'), title: t('refresh'),
3376
3496
  disabled: busy, onClick: refresh,
@@ -3385,15 +3505,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3385
3505
  : null,
3386
3506
  error ? h('div', { className: 'ud-error' }, error) : null,
3387
3507
  loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
3388
- stats ? h(StatCards, { key: 'cards', stats, costCurrency, t }) : null,
3508
+ cardsStats ? h(StatCards, { key: 'cards', stats: cardsStats, costCurrency, t }) : null,
3389
3509
  // 活跃热力图仅按天视图展示:热力图口径为日桶,时/分视图无对应语义
3390
- view === 'day' ? h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }) : null,
3510
+ view === 'day' ? h(HeatSection, { key: 'heat', days: heatDays, panelRef, costCurrency, t }) : null,
3391
3511
  trimmedSlots
3392
3512
  ? h(TrendChart, {
3393
3513
  key: 'trend',
3394
- title: trendTitle(t, view),
3514
+ title: money ? trendCostTitle(t, view) : trendTitle(t, view),
3395
3515
  notes,
3396
- slots: trimmedSlots,
3516
+ slots: chartSlots,
3517
+ tipSlots: trimmedSlots,
3397
3518
  modelOrder: trendSource.models.map((item) => item.model),
3398
3519
  colorFor,
3399
3520
  labelFor: tickLabelFor(view),
@@ -3403,12 +3524,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3403
3524
  legendModels: trendSource.models,
3404
3525
  panelRef,
3405
3526
  costCurrency,
3406
- costEnabled: view === 'day',
3527
+ money,
3528
+ costAvailable: costReady,
3529
+ onMetricToggle: setCostView,
3407
3530
  t,
3408
3531
  })
3409
3532
  : null,
3410
- grouped ? h(ModelUsage, { key: 'models', models: grouped.models, colorFor, panelRef, costCurrency, t }) : null,
3411
- stats?.to ? h('div', { className: 'ud-foot' }, `${t('asOf')} ${stats.to}`) : null,
3533
+ usageModels ? h(ModelUsage, { key: 'models', models: usageModels, colorFor, panelRef, costCurrency, t }) : null,
3534
+ cardsStats?.to ? h('div', { className: 'ud-foot' }, `${t('asOf')} ${slotLabelFor(view)(cardsStats.to)}`) : null,
3412
3535
  emptyVisible ? h('div', { className: 'ud-empty' }, t('empty')) : null,
3413
3536
  h(StatsLineOptions, { key: 'prefs', t }),
3414
3537
  h(PricingEditor, { key: 'pricing', t }))
package/src/collector.js CHANGED
@@ -12,6 +12,8 @@ const SEQ_UNKNOWN = -1
12
12
  const DEFAULT_BACKFILL_CONCURRENCY = 4
13
13
  const MARK_BATCH = 32
14
14
  const SCAN_LOG_MAX_ENTRIES = 200
15
+ // 单条日志 detail 上限:flush 底层行错误拼接的截断保护
16
+ const LOG_DETAIL_MAX_CHARS = 500
15
17
 
16
18
  // 不可读会话分类:宿主可见性/可读性边界的三代实测形态(见 README 存档兼容)
17
19
  const SKIP_KINDS = { descriptor: 'descriptor', corrupt: 'corrupt', legacy: 'legacy', other: 'other' }
@@ -31,6 +33,15 @@ function classifySkip(detail) {
31
33
  return SKIP_KINDS.other
32
34
  }
33
35
 
36
+ // flush 失败日志文本:包装文案在前,聚合的底层行错误逐条展开,超长截断
37
+ function flushErrorText(error) {
38
+ const head = error instanceof Error ? error.message : String(error)
39
+ if (!(error instanceof AggregateError) || error.errors.length === 0) return head
40
+ const detail = error.errors.map((err) => (err instanceof Error ? err.message : String(err))).join('; ')
41
+ const text = `${head}: ${detail}`
42
+ return text.length > LOG_DETAIL_MAX_CHARS ? `${text.slice(0, LOG_DETAIL_MAX_CHARS)}…` : text
43
+ }
44
+
34
45
  function emptySkipCounts() {
35
46
  return { [SKIP_KINDS.descriptor]: 0, [SKIP_KINDS.corrupt]: 0, [SKIP_KINDS.legacy]: 0, [SKIP_KINDS.other]: 0 }
36
47
  }
@@ -231,9 +242,10 @@ export class UsageCollector {
231
242
  constructor(ctx, store) {
232
243
  this.ctx = ctx
233
244
  this.store = store
234
- // store 写合并周期落盘的失败行接入扫描异常日志,面板可观测
245
+ // store 写合并周期落盘的失败行接入扫描异常日志,面板可观测;
246
+ // AggregateError 展开底层行错误,只记包装文案无法归因
235
247
  store.onFlushError = (error) => {
236
- this.pushLog('record', error instanceof Error ? error.message : String(error))
248
+ this.pushLog('record', flushErrorText(error))
237
249
  }
238
250
  this.folds = new Map()
239
251
  this.routes = new Map()
package/src/query.js CHANGED
@@ -1,4 +1,4 @@
1
- import { costOf, matchPrice } from './pricing.js'
1
+ import { costOf, matchPrice } from './pricing.js'
2
2
 
3
3
  export const MAX_SLOTS = 2000
4
4
 
@@ -14,7 +14,7 @@ const DAY_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
14
14
  const HOUR_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/
15
15
  const MINUTE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
16
16
 
17
- // 桶串前缀宽:D 段定位日桶,M 行截取父 H 桶
17
+ // 妗朵覆鍓嶇紑瀹?D 娈靛畾浣嶆棩妗?M 琛屾埅鍙栫埗 H 妗?
18
18
  const DAY_KEY_WIDTH = 'YYYY-MM-DD'.length
19
19
  const HOUR_KEY_WIDTH = 'YYYY-MM-DDTHH'.length
20
20
 
@@ -39,10 +39,10 @@ const nextMinute = (date) => {
39
39
  return next
40
40
  }
41
41
 
42
- // 分钟桶粒度:枚举与桶键共用同一步长,from 必须对齐桶边界
42
+ // 鍒嗛挓妗剁矑搴?鏋氫妇涓庢《閿叡鐢ㄥ悓涓€姝ラ暱,from 蹇呴』瀵归綈妗惰竟鐣?
43
43
  const MINUTE_STEP_MINUTES = 10
44
44
 
45
- // pattern 锚定桶串外形,suffix 补全为本地时区可解析日期串,format 回读校验分量合法性
45
+ // pattern 閿氬畾妗朵覆澶栧舰,suffix 琛ュ叏涓烘湰鍦版椂鍖哄彲瑙f瀽鏃ユ湡涓?format 鍥炶鏍¢獙鍒嗛噺鍚堟硶鎬?
46
46
  const BUCKET_FORMS = {
47
47
  [GRANULARITY_DAILY]: { pattern: DAY_KEY_PATTERN, suffix: 'T00:00:00', format: formatDate, step: nextDay },
48
48
  [GRANULARITY_HOURLY]: { pattern: HOUR_KEY_PATTERN, suffix: ':00:00', format: formatHour, step: nextHour },
@@ -95,8 +95,8 @@ const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * PERCENT_S
95
95
 
96
96
  const rowTokens = (row) => row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheWriteTokens
97
97
 
98
- // 速度配对分子:decode 口径取 decodeTokens;存量旧格式行(带时长无 decodeTokens)
99
- // 回落 outputTokens,聚合随新数据自然收敛
98
+ // 閫熷害閰嶅鍒嗗瓙:decode 鍙e緞鍙?decodeTokens;瀛橀噺鏃ф牸寮忚(甯︽椂闀挎棤 decodeTokens)
99
+ // 鍥炶惤 outputTokens,鑱氬悎闅忔柊鏁版嵁鑷劧鏀舵暃
100
100
  const speedTokensOf = (row) => (row.durationMs ? row.decodeTokens ?? row.outputTokens : 0)
101
101
 
102
102
  export function aggregateRange(rows, g, from, to) {
@@ -105,19 +105,20 @@ export function aggregateRange(rows, g, from, to) {
105
105
  const slotByKey = new Map(slots.map((slot) => [slot.day, slot]))
106
106
  const modelTotals = new Map()
107
107
  const providerTotals = new Map()
108
- const activeBuckets = new Set()
109
- // 槽级配对:桶串 → 速度对 {decodeTokens, durationMs} 与首字对 {ttftMs, ttftSteps},
108
+ // 活跃天 = 有 token 产出的自然日(桶键按日去重,跨粒度统一口径)
109
+ const activeDays = new Set()
110
+ // 妲界骇閰嶅:妗朵覆 鈫?閫熷害瀵?{decodeTokens, durationMs} 涓庨瀛楀 {ttftMs, ttftSteps},
110
111
  const slotSpeeds = new Map()
111
112
  const slotTtfts = new Map()
112
113
  for (const row of rows) {
113
114
  const slot = slotByKey.get(row.bucket)
114
- // 桶串未落在枚举序列(如改粒度前的历史残行)不可归属,跳过防崩
115
+ // 妗朵覆鏈惤鍦ㄦ灇涓惧簭鍒?濡傛敼绮掑害鍓嶇殑鍘嗗彶娈嬭)涓嶅彲褰掑睘,璺宠繃闃插穿
115
116
  if (!slot) continue
116
117
  const tokens = rowTokens(row)
117
118
  addRowToSlot(slot, row, tokens)
118
- // 纯 timing 行(零 token 桶 + decode 配对)不参与归属,但仍进配对聚合
119
+ // 绾?timing 琛?闆?token 妗?+ decode 閰嶅)涓嶅弬涓庡綊灞?浣嗕粛杩涢厤瀵硅仛鍚?
119
120
  if (tokens > 0) {
120
- activeBuckets.add(row.bucket)
121
+ activeDays.add(row.bucket.slice(0, DAY_KEY_WIDTH))
121
122
  slot.byModel[row.model] = (slot.byModel[row.model] ?? 0) + tokens
122
123
  slot.byProvider[row.provider] = (slot.byProvider[row.provider] ?? 0) + tokens
123
124
  }
@@ -174,9 +175,9 @@ export function aggregateRange(rows, g, from, to) {
174
175
  totals.cacheHit += slot.cacheHit
175
176
  totals.cacheMiss += slot.cacheMiss
176
177
  }
177
- // speed = decode 配对口径(decodeTokens ÷ 时长秒);ttft = 首 token 延迟
178
- // 加权平均(毫秒);仅配对数据存在的条目挂字段,无数据条目不挂
179
- // 纯 timing 行可能产生 0-token 条目,列表保持只含 token 行(存量契约)
178
+ // speed = decode 閰嶅鍙e緞(decodeTokens 梅 鏃堕暱绉?;ttft = 棣?token 寤惰繜
179
+ // 鍔犳潈骞冲潎(姣);浠呴厤瀵规暟鎹瓨鍦ㄧ殑鏉$洰鎸傚瓧娈?鏃犳暟鎹潯鐩笉鎸?
180
+ // 绾?timing 琛屽彲鑳戒骇鐢?0-token 鏉$洰,鍒楄〃淇濇寔鍙惈 token 琛?瀛橀噺濂戠害)
180
181
  const models = [...modelTotals.entries()]
181
182
  .filter(([, agg]) => agg.tokens > 0)
182
183
  .map(([model, agg]) => ({
@@ -207,7 +208,7 @@ export function aggregateRange(rows, g, from, to) {
207
208
  turns: totals.turns,
208
209
  cacheHit: totals.cacheHit,
209
210
  cacheMiss: totals.cacheMiss,
210
- activeDays: activeBuckets.size,
211
+ activeDays: activeDays.size,
211
212
  topModel: top?.model ?? '',
212
213
  topProvider: top?.provider ?? '',
213
214
  daily,
@@ -218,18 +219,20 @@ export function aggregateRange(rows, g, from, to) {
218
219
  return result
219
220
  }
220
221
 
221
- // 聚合计价的槽定位:D 折叠到日槽,H/M 即本槽,计价一律取行所属 H 槽起点
222
+ // 鑱氬悎璁′环鐨勬Ы瀹氫綅:D 鎶樺彔鍒版棩妲?H/M 鍗虫湰妲?璁′环涓€寰嬪彇琛屾墍灞?H 妲借捣鐐?
222
223
  const COST_SLOT_KEYS = {
223
224
  [GRANULARITY_DAILY]: (bucket) => bucket.slice(0, DAY_KEY_WIDTH),
224
225
  [GRANULARITY_HOURLY]: (bucket) => bucket,
225
226
  [GRANULARITY_MINUTE]: (bucket) => bucket,
226
227
  }
227
228
 
228
- // 聚合计价:以可见槽为唯一口径,cost 行按 H 槽起点匹配价格后累加;
229
+ // 鑱氬悎璁′环:浠ュ彲瑙佹Ы涓哄敮涓€鍙e緞,cost 琛屾寜 H 妲借捣鐐瑰尮閰嶄环鏍煎悗绱姞;
230
+ // 妲界骇閫愭ā鍨嬫媶鍒?costByModel 渚涢噾棰濇煴鐘跺浘鎸夋ā鍨嬪爢鍙?
229
231
  export function attachCosts(result, costRows, granularity, rules) {
230
232
  const slotKeyOf = COST_SLOT_KEYS[granularity]
231
233
  const slotOfDay = new Map(result.daily.map((slot) => [slot.day, slot]))
232
234
  const slotCosts = new Map()
235
+ const slotCostsByModel = new Map()
233
236
  const modelCosts = new Map(result.models.map((entry) => [entry.model, 0]))
234
237
  const unpricedHours = new Set()
235
238
  for (const row of costRows) {
@@ -245,9 +248,19 @@ export function attachCosts(result, costRows, granularity, rules) {
245
248
  }
246
249
  const cost = costOf(price, row)
247
250
  slotCosts.set(slot.day, (slotCosts.get(slot.day) ?? 0) + cost)
251
+ let perModel = slotCostsByModel.get(slot.day)
252
+ if (perModel === undefined) {
253
+ perModel = new Map()
254
+ slotCostsByModel.set(slot.day, perModel)
255
+ }
256
+ perModel.set(row.model, (perModel.get(row.model) ?? 0) + cost)
248
257
  if (modelCosts.has(row.model)) modelCosts.set(row.model, modelCosts.get(row.model) + cost)
249
258
  }
250
- const daily = result.daily.map((slot) => ({ ...slot, cost: slotCosts.get(slot.day) ?? 0 }))
259
+ const daily = result.daily.map((slot) => ({
260
+ ...slot,
261
+ cost: slotCosts.get(slot.day) ?? 0,
262
+ costByModel: Object.fromEntries(slotCostsByModel.get(slot.day) ?? []),
263
+ }))
251
264
  const cost = daily.reduce((sum, slot) => sum + slot.cost, 0)
252
265
  const models = result.models.map((entry) => ({ ...entry, cost: modelCosts.get(entry.model) }))
253
266
  return { ...result, daily, models, cost, unpriced: unpricedHours.size }
@@ -0,0 +1,26 @@
1
+ // client.js 注册 id 与样式标记守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败;
2
+ // 样式查重键 STYLE_ID 兼作 data-plugin 标记值,短名会使自身 rebuilt 删不到旧样式,重注被跳过。
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
11
+
12
+ test('client.js 注册 id 为完整包名', () => {
13
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
14
+ const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
15
+ assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
16
+ assert.equal(match[1], name)
17
+ })
18
+
19
+ test('client.js 样式注入自带 data-plugin 且值为注册包名', () => {
20
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
21
+ const injections = source.split("createElement('style')").length - 1
22
+ assert.ok(injections > 0, 'client.js 无样式注入点')
23
+ assert.ok(source.includes(`STYLE_ID = '${name}'`), 'STYLE_ID 必须等于注册包名(值=注册 id),短名会使自身 rebuilt 删不到旧样式')
24
+ const marked = source.split("setAttribute('data-plugin'").length - 1
25
+ assert.equal(marked, injections, '每个样式注入点都必须设置 data-plugin,防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
26
+ })
@@ -36,16 +36,20 @@ const {
36
36
  OTHER_MODEL,
37
37
  aggregateCurrencyOf,
38
38
  applyCurrencyToRules,
39
+ avgPriceText,
39
40
  cacheRateText,
41
+ cardsStatsOf,
40
42
  coerceConditions,
41
43
  coercePricingRules,
42
44
  costOf,
45
+ costSlotsOf,
43
46
  costTitleText,
44
47
  createTranslator,
45
48
  daysInRange,
46
49
  defaultCondition,
47
50
  defaultPricingRule,
48
51
  donutSegments,
52
+ formatCostCompact,
49
53
  pointStatsMatches,
50
54
  formatCompact,
51
55
  formatCost,
@@ -68,8 +72,12 @@ const {
68
72
  maxSlotsFor,
69
73
  minuteTickLabel,
70
74
  modelIoPercentText,
75
+ moneyViewOf,
71
76
  hasTipContent,
77
+ hasAnomalyNews,
72
78
  tipModelEntries,
79
+ tipAvgPriceText,
80
+ trendDefaultVisibleKeys,
73
81
  modelSegmentLabel,
74
82
  modelSpeedText,
75
83
  modelNameOf,
@@ -107,9 +115,13 @@ const {
107
115
  speedTipText,
108
116
  speedScaleMax,
109
117
  legendToggle,
118
+ LEGEND_KEY_RATE,
119
+ LEGEND_KEY_SPEED,
120
+ LEGEND_KEY_TTFT,
110
121
  leftAxisTicks,
111
122
  hourSlotLabel,
112
123
  minuteSlotLabel,
124
+ usageModelsOf,
113
125
  validatePricingRules,
114
126
  } = core
115
127
 
@@ -159,10 +171,12 @@ test('预设定义表覆盖三视图', () => {
159
171
  assert.deepEqual(MINUTE_PRESETS, ['3h', '24h', '3d', '7d'])
160
172
  })
161
173
 
162
- test('默认挡位均在其挡位列表内,分钟窗口可解析', () => {
174
+ test('默认挡位均在其挡位列表内,分钟默认挡为最短窗口 3 小时,分钟窗口可解析', () => {
163
175
  assert.ok(DAY_PRESETS.includes(DEFAULT_RANGE))
164
176
  assert.ok(HOUR_PRESETS.includes(DEFAULT_HOUR_PRESET))
165
177
  assert.ok(MINUTE_PRESETS.includes(DEFAULT_MINUTE_PRESET))
178
+ // Given 分钟视图初始挂载 When 取默认挡 Then 为 3 小时(分钟粒度下 24 小时过粗,近端细节优先)
179
+ assert.equal(DEFAULT_MINUTE_PRESET, '3h')
166
180
  assert.notEqual(resolveMinuteRange(DEFAULT_MINUTE_PRESET, NOW), null)
167
181
  })
168
182
 
@@ -450,6 +464,35 @@ test('groupPointSlots 窗口模型超上限折叠其他', () => {
450
464
  assert.deepEqual(grouped.daily[0].byModel, { m1: 1, m2: 1, m3: 1, m4: 1, m6: 1, [OTHER_MODEL]: 1 })
451
465
  })
452
466
 
467
+ test('cardsStatsOf 天视图用 range 聚合,时/分视图用点聚合,点未回为 null', () => {
468
+ const stats = { tokens: 1 }
469
+ const pointView = { tokens: 2 }
470
+ assert.equal(cardsStatsOf('day', stats, pointView), stats)
471
+ assert.equal(cardsStatsOf('hour', stats, pointView), pointView)
472
+ assert.equal(cardsStatsOf('minute', stats, null), null)
473
+ })
474
+
475
+ test('usageModelsOf 天视图用预折叠分组,时/分视图折叠点窗口模型,点未回为 null', () => {
476
+ const grouped = { models: [{ model: 'p/m1', tokens: 1 }] }
477
+ assert.equal(usageModelsOf('day', grouped, null), grouped.models)
478
+ assert.equal(usageModelsOf('day', null, null), null)
479
+ const ranked = [
480
+ { model: 'p/m1', tokens: 3 },
481
+ { model: 'p/m2', tokens: 2 },
482
+ { model: 'p/m3', tokens: 1 },
483
+ { model: 'p/m4', tokens: 1 },
484
+ { model: 'p/m5', tokens: 1 },
485
+ { model: 'p/m6', tokens: 1 },
486
+ ]
487
+ const folded = usageModelsOf('hour', null, { models: ranked })
488
+ assert.equal(folded.length, 6)
489
+ assert.equal(folded.at(-1).model, OTHER_MODEL)
490
+ assert.equal(folded.at(-1).tokens, 1)
491
+ // 空窗口契约:返回空数组渲染空环(与天视图空数据行为一致),非 null 隐藏
492
+ assert.deepEqual(usageModelsOf('minute', null, { models: [] }), [])
493
+ assert.equal(usageModelsOf('minute', null, null), null)
494
+ })
495
+
453
496
  test('niceTicks 产生 1/2/5 序列且零上限为空', () => {
454
497
  assert.deepEqual(niceTicks(0, 4), [])
455
498
  assert.deepEqual(niceTicks(50, 4), [20, 40])
@@ -694,7 +737,7 @@ test('speedTipText 无速度为占位符有速度为官方吞吐口径', () => {
694
737
  })
695
738
 
696
739
  test('legendToggle 普通点击单选,再点恢复全部,ctrl 多选且禁全藏', () => {
697
- // null 表示全部可见
740
+ // null 输入(仅 historic 契约面):普通点击得单键集
698
741
  assert.deepEqual(legendToggle(null, 'a', false), new Set(['a']))
699
742
  // 单选态再点同项恢复全部(null)
700
743
  assert.equal(legendToggle(new Set(['a']), 'a', false), null)
@@ -972,6 +1015,44 @@ test('formatCost 微观值四位小数', () => {
972
1015
  assert.equal(formatCost(0.009999, '$'), '$0.0100')
973
1016
  })
974
1017
 
1018
+ test('avgPriceText 费用折每百万 token 均价', () => {
1019
+ assert.equal(avgPriceText(3357.85, 6232901270, '¥'), '¥0.539/M')
1020
+ assert.equal(avgPriceText(2, 1000 * 1000, '$'), '$2.000/M')
1021
+ assert.equal(avgPriceText(0.5, 1000 * 1000, ''), '0.500/M')
1022
+ assert.equal(avgPriceText(0.0004, 1000 * 1000, '¥'), '¥0.00040/M')
1023
+ })
1024
+
1025
+ test('avgPriceText 无费用或零 token 不显示', () => {
1026
+ assert.equal(avgPriceText(undefined, 6232901270, '¥'), '')
1027
+ assert.equal(avgPriceText(0, 6232901270, '¥'), '')
1028
+ assert.equal(avgPriceText(3357.85, 0, '¥'), '')
1029
+ })
1030
+
1031
+ test('tipAvgPriceText 槽费用折每百万 token 均价,无费用或零槽为 null', () => {
1032
+ assert.equal(tipAvgPriceText({ total: 6232901270, cost: 3357.85 }, '¥'), '¥0.539/M')
1033
+ assert.equal(tipAvgPriceText({ total: 6232901270, cost: 0 }, '¥'), null)
1034
+ assert.equal(tipAvgPriceText({ total: 6232901270 }, '¥'), null)
1035
+ assert.equal(tipAvgPriceText({ total: 0, cost: 3357.85 }, '¥'), null)
1036
+ })
1037
+
1038
+ test('trendDefaultVisibleKeys 默认集含模型与速度线,不含缓存命中率与首 token 延迟', () => {
1039
+ const keys = trendDefaultVisibleKeys(['m1', 'm2'], true)
1040
+ assert.deepEqual([...keys], ['m1', 'm2', 'speed'])
1041
+ assert.deepEqual([...trendDefaultVisibleKeys(['m1'], false)], ['m1'])
1042
+ assert.deepEqual([...trendDefaultVisibleKeys([], false)], [])
1043
+ })
1044
+
1045
+ test('图例默认态点击以实际生效集为基线,ctrl 恢复隐藏线不坍缩', () => {
1046
+ const base = trendDefaultVisibleKeys(['m1', 'm2'], true)
1047
+ // ctrl 点击默认隐藏的缓存命中率:默认集其余项保持,仅追加该线;再点回默认集
1048
+ const withRate = legendToggle(base, LEGEND_KEY_RATE, true)
1049
+ assert.deepEqual([...withRate].sort(), ['m1', 'm2', LEGEND_KEY_SPEED, LEGEND_KEY_RATE].sort())
1050
+ assert.deepEqual([...legendToggle(withRate, LEGEND_KEY_RATE, true)].sort(), ['m1', 'm2', LEGEND_KEY_SPEED].sort())
1051
+ // 普通点击单选隐藏线,再点回 null 由调用方 ?? 默认集承接
1052
+ assert.deepEqual([...legendToggle(base, LEGEND_KEY_TTFT, false)], [LEGEND_KEY_TTFT])
1053
+ assert.equal(legendToggle(new Set([LEGEND_KEY_TTFT]), LEGEND_KEY_TTFT, false), null)
1054
+ })
1055
+
975
1056
  test('镜像函数基本行为:非法输入 null 与命中计价', () => {
976
1057
  assert.equal(matchPrice(null, 'x/m', NOW), null)
977
1058
  assert.equal(matchPrice([], 'x/m', NOW), null)
@@ -1268,3 +1349,104 @@ test('removeRulesAt 批量移除组内槽位', () => {
1268
1349
  const rest = removeRulesAt(rules, [0, 2])
1269
1350
  assert.deepEqual(rest.map((rule) => rule.model), ['b/1'])
1270
1351
  })
1352
+
1353
+ test('groupStats 折叠金额双映射:top 模型费用透传,其余归哨兵并保留明细', () => {
1354
+ // Given 逐槽 costByModel 含 top 与非 top 模型 When 折叠 Then byCostModel 归并哨兵,otherCostByModel 保留非 top 明细
1355
+ const stats = {
1356
+ models: [
1357
+ { model: 'p/m1', tokens: 500 },
1358
+ { model: 'p/m2', tokens: 400 },
1359
+ { model: 'p/m3', tokens: 300 },
1360
+ { model: 'p/m4', tokens: 200 },
1361
+ { model: 'p/m5', tokens: 100 },
1362
+ { model: 'p/m6', tokens: 50 },
1363
+ ],
1364
+ daily: [{
1365
+ day: 'd',
1366
+ total: 110,
1367
+ byModel: { 'p/m1': 50, 'p/m2': 40, 'p/m6': 20 },
1368
+ costByModel: { 'p/m1': 2, 'p/m2': 2, 'p/m6': 0.5 },
1369
+ }],
1370
+ }
1371
+ const grouped = groupStats(stats)
1372
+ assert.deepEqual(grouped.daily[0].byCostModel, { 'p/m1': 2, 'p/m2': 2, [OTHER_MODEL]: 0.5 })
1373
+ assert.deepEqual(grouped.daily[0].otherCostByModel, { 'p/m6': 0.5 })
1374
+ })
1375
+
1376
+ test('groupStats 无费用槽金额映射为空表', () => {
1377
+ // Given 槽无 costByModel When 折叠 Then byCostModel/otherCostByModel 恒在场为空表
1378
+ const stats = {
1379
+ models: [{ model: 'p/m1', tokens: 500 }],
1380
+ daily: [{ day: 'd', total: 10, byModel: { 'p/m1': 10 } }],
1381
+ }
1382
+ const grouped = groupStats(stats)
1383
+ assert.deepEqual(grouped.daily[0].byCostModel, {})
1384
+ assert.deepEqual(grouped.daily[0].otherCostByModel, {})
1385
+ })
1386
+
1387
+ test('costSlotsOf 槽投影切金额口径:total/byModel/otherByModel 取费用字段', () => {
1388
+ // Given 折叠后带费用字段的槽 When 投影 Then total=cost、byModel=byCostModel、otherByModel=otherCostByModel,其余字段保留
1389
+ const slots = [
1390
+ { day: 'd1', total: 100, cost: 2.5, byModel: { a: 60, b: 40 }, byCostModel: { a: 1.5, b: 1 }, otherByModel: {}, otherCostByModel: {} },
1391
+ { day: 'd2', total: 10, cost: 0, byModel: { a: 10 }, byCostModel: {}, otherByModel: {}, otherCostByModel: {} },
1392
+ ]
1393
+ const out = costSlotsOf(slots)
1394
+ assert.equal(out[0].total, 2.5)
1395
+ assert.deepEqual(out[0].byModel, { a: 1.5, b: 1 })
1396
+ assert.deepEqual(out[0].otherByModel, {})
1397
+ assert.equal(out[0].day, 'd1')
1398
+ assert.equal(out[1].total, 0)
1399
+ assert.deepEqual(out[1].byModel, {})
1400
+ })
1401
+
1402
+ test('moneyViewOf 门控:存在正计价槽才可用,关或不可用保持 token 口径', () => {
1403
+ // Given 槽含正计价与零计价 When 开关开 Then 可用且切金额;Given 全零计价/开关关/无槽 When 派生 Then 不可用且不投影
1404
+ const slots = [
1405
+ { day: 'd1', total: 10, cost: 0.5, byModel: { a: 10 }, byCostModel: { a: 0.5 }, otherByModel: {}, otherCostByModel: {} },
1406
+ { day: 'd2', total: 5, cost: 0, byModel: { a: 5 }, byCostModel: {}, otherByModel: {}, otherCostByModel: {} },
1407
+ ]
1408
+ const on = moneyViewOf(slots, true)
1409
+ assert.equal(on.costReady, true)
1410
+ assert.equal(on.money, true)
1411
+ assert.equal(on.chartSlots[0].total, 0.5)
1412
+ const zeroOnly = [slots[1]]
1413
+ const allZero = moneyViewOf(zeroOnly, true)
1414
+ assert.equal(allZero.costReady, false)
1415
+ assert.equal(allZero.money, false)
1416
+ assert.equal(allZero.chartSlots, zeroOnly)
1417
+ assert.equal(allZero.chartSlots[0].total, 5)
1418
+ const off = moneyViewOf(slots, false)
1419
+ assert.equal(off.costReady, true)
1420
+ assert.equal(off.money, false)
1421
+ assert.equal(off.chartSlots, slots)
1422
+ const none = moneyViewOf(null, true)
1423
+ assert.deepEqual(none, { costReady: false, money: false, chartSlots: null })
1424
+ })
1425
+
1426
+ test('formatCostCompact 金额轴紧凑读数:千以上 k/M 一位小数,分上两位内,分下四位', () => {
1427
+ assert.equal(formatCostCompact(1234567, '¥'), '¥1.2M')
1428
+ assert.equal(formatCostCompact(1234, '¥'), '¥1.2k')
1429
+ assert.equal(formatCostCompact(12.345, '$'), '$12.35')
1430
+ assert.equal(formatCostCompact(0.1, '¥'), '¥0.1')
1431
+ assert.equal(formatCostCompact(0.005, '¥'), '¥0.005')
1432
+ assert.equal(formatCostCompact(0, ''), '0')
1433
+ })
1434
+
1435
+ test('leftAxisTicks 金额格式化:柱刻度走传入格式化,速度刻度仍紧凑 token', () => {
1436
+ // Given 金额格式化器 When 柱刻度标定 Then 标签走金额格式;Given 速度刻度接管 Then 标签仍 token 紧凑
1437
+ const money = leftAxisTicks([50, 100], 100, [], 0, (value) => `¥${value}`)
1438
+ assert.deepEqual(money.map((tick) => tick.label), ['¥50', '¥100'])
1439
+ const speed = leftAxisTicks([], 100, [5, 10], 10, (value) => `¥${value}`)
1440
+ assert.deepEqual(speed.map((tick) => tick.label), ['5', '10'])
1441
+ })
1442
+
1443
+ test('hasAnomalyNews 折叠提醒判定:有异常日志或写入失败即提醒', () => {
1444
+ // Given 日志有条目 When 判定 Then true;Given 仅写入失败计数 When 判定 Then true
1445
+ assert.equal(hasAnomalyNews({ log: [{ time: 0, kind: 'record', detail: 'x' }] }), true)
1446
+ assert.equal(hasAnomalyNews({ log: [], recordFailures: 1 }), true)
1447
+ assert.equal(hasAnomalyNews({ log: [{ time: 0, kind: 'skipped', detail: 'y' }], recordFailures: 0 }), true)
1448
+ // Given 全空/字段缺席/null When 判定 Then false
1449
+ assert.equal(hasAnomalyNews({ log: [], recordFailures: 0 }), false)
1450
+ assert.equal(hasAnomalyNews({}), false)
1451
+ assert.equal(hasAnomalyNews(null), false)
1452
+ })
@@ -766,6 +766,30 @@ test('采集侧 store 失败计入 recordFailures 且不抛断采集', async ()
766
766
  assert.equal(store.samples.length, 0)
767
767
  })
768
768
 
769
+ test('flush 聚合失败日志展开底层行错误,不只剩包装文案', async () => {
770
+ const { store, collector } = liveCollector()
771
+ // Given store 上抛含两行底层失败的 AggregateError When 接入日志 Then detail 展开各原因
772
+ store.onFlushError(new AggregateError(
773
+ [new Error('boom A'), new Error('boom B')],
774
+ 'usage store flush failed',
775
+ ))
776
+ const entry = collector.status().log.at(-1)
777
+ assert.equal(entry.kind, 'record')
778
+ assert.equal(entry.detail, 'usage store flush failed: boom A; boom B')
779
+ assert.equal(collector.status().recordFailures, 1)
780
+ })
781
+
782
+ test('flush 聚合失败日志截断保护:超长底层错误不撑爆日志行', async () => {
783
+ const { store, collector } = liveCollector()
784
+ store.onFlushError(new AggregateError(
785
+ [new Error('x'.repeat(2000))],
786
+ 'usage store flush failed',
787
+ ))
788
+ const entry = collector.status().log.at(-1)
789
+ assert.ok(entry.detail.length <= 600)
790
+ assert.ok(entry.detail.startsWith('usage store flush failed'))
791
+ })
792
+
769
793
  test('session/disposed 后该会话去重状态重置', async () => {
770
794
  const { ctx, store } = liveCollector()
771
795
  const t = local(2026, 8, 2, 10, 0)
@@ -183,6 +183,23 @@ test('H 粒度零值槽全枚举含无数据槽', () => {
183
183
  assert.equal(out.activeDays, 1)
184
184
  })
185
185
 
186
+ test('H/M 粒度活跃天数为有用量自然日数,同日多桶计 1', () => {
187
+ const hourRows = [
188
+ makeRow({ bucket: '2020-01-01T01', model: 'm1', provider: 'p1', inputTokens: 5 }),
189
+ makeRow({ bucket: '2020-01-01T05', model: 'm1', provider: 'p1', inputTokens: 5 }),
190
+ makeRow({ bucket: '2020-01-02T01', model: 'm1', provider: 'p1', inputTokens: 5 }),
191
+ ]
192
+ const hourOut = aggregateRange(hourRows, 'H', '2020-01-01T00', '2020-01-02T23')
193
+ assert.equal(hourOut.activeDays, 2)
194
+ const minuteRows = [
195
+ makeRow({ bucket: '2020-01-01T00:00', model: 'm1', provider: 'p1', inputTokens: 5 }),
196
+ makeRow({ bucket: '2020-01-01T00:10', model: 'm1', provider: 'p1', inputTokens: 5 }),
197
+ makeRow({ bucket: '2020-01-01T00:20', model: 'm1', provider: 'p1', inputTokens: 5 }),
198
+ ]
199
+ const minuteOut = aggregateRange(minuteRows, 'M', '2020-01-01T00:00', '2020-01-01T23:50')
200
+ assert.equal(minuteOut.activeDays, 1)
201
+ })
202
+
186
203
  test('M 粒度纯计数行不计入模型与活跃桶,未对齐残行跳过', () => {
187
204
  const rows = [
188
205
  makeRow({ bucket: '2020-01-01T00:00', model: '(turns)', provider: 'default', turns: 1 }),
@@ -574,3 +591,53 @@ test('attachCosts 计价时间恒为 H 桶起点(非分钟/日起点)', () => {
574
591
  const dayOut = attachCosts(dayResult, hourRows, 'D', [dayWindow, ruleOf({ price: inputPrice(3) })])
575
592
  assert.deepEqual(dayOut.daily.map((slot) => slot.cost), [1, 3])
576
593
  })
594
+
595
+ test('attachCosts 槽级 costByModel 按模型拆分费用', () => {
596
+ // Given 同桶两模型不同单价 When H 槽计价 Then 槽 costByModel 为逐模型费用,合计等于槽 cost
597
+ const rows = [
598
+ makeRow({ bucket: '2020-01-01T01', model: 'm1', inputTokens: 1000000 }),
599
+ makeRow({ bucket: '2020-01-01T01', model: 'm2', inputTokens: 1000000 }),
600
+ ]
601
+ const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T01')
602
+ const out = attachCosts(result, rows, 'H', [ruleOf({ price: inputPrice(1) })])
603
+ assert.deepEqual(out.daily[1].costByModel, { m1: 1, m2: 1 })
604
+ assert.equal(out.daily[1].cost, 2)
605
+ // 纯函数:入参 result 不被修改
606
+ assert.equal('costByModel' in result.daily[1], false)
607
+ })
608
+
609
+ test('attachCosts D 折叠按日归集 costByModel', () => {
610
+ // Given 同模型 H 行落同日多桶 When D 计价 Then 日槽 costByModel 为该模型桶累加
611
+ const costRows = [
612
+ makeRow({ bucket: '2020-01-01T22', model: 'm1', inputTokens: 1000000 }),
613
+ makeRow({ bucket: '2020-01-01T23', model: 'm1', inputTokens: 500000 }),
614
+ ]
615
+ const aggregateRows = [makeRow({ bucket: '2020-01-01', model: 'm1', inputTokens: 1500000 })]
616
+ const result = aggregateRange(aggregateRows, 'D', '2020-01-01', '2020-01-01')
617
+ const out = attachCosts(result, costRows, 'D', [ruleOf({ price: inputPrice(2) })])
618
+ assert.deepEqual(out.daily[0].costByModel, { m1: 3 })
619
+ })
620
+
621
+ test('attachCosts 无规则或无费用槽 costByModel 为空表', () => {
622
+ // Given 无命中规则 When 计价 Then 全部槽挂空表,unpriced 照常计数
623
+ const rows = [
624
+ makeRow({ bucket: '2020-01-01T01', model: 'm1', inputTokens: 5 }),
625
+ makeRow({ bucket: '2020-01-01T02', model: 'm1', inputTokens: 5 }),
626
+ ]
627
+ const result = aggregateRange(rows, 'H', '2020-01-01T00', '2020-01-01T02')
628
+ const out = attachCosts(result, rows, 'H', [])
629
+ assert.deepEqual(out.daily.map((slot) => slot.costByModel), [{}, {}, {}])
630
+ assert.equal(out.unpriced, 2)
631
+ })
632
+
633
+ test('attachCosts M 槽 costByModel 按模型拆分', () => {
634
+ // Given 同分钟桶两模型 When M 计价 Then 分钟槽 costByModel 为逐模型费用
635
+ const rows = [
636
+ makeRow({ bucket: '2020-01-01T01:10', model: 'm1', inputTokens: 500000 }),
637
+ makeRow({ bucket: '2020-01-01T01:10', model: 'm2', inputTokens: 500000 }),
638
+ ]
639
+ const result = aggregateRange(rows, 'M', '2020-01-01T01:10', '2020-01-01T01:10')
640
+ const out = attachCosts(result, rows, 'M', [ruleOf({ price: inputPrice(1) })])
641
+ assert.deepEqual(out.daily[0].costByModel, { m1: 0.5, m2: 0.5 })
642
+ assert.equal(out.daily[0].cost, 1)
643
+ })