@mzzsfy/dsh-usage-dash 0.1.0 → 0.3.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/src/client.js CHANGED
@@ -1,17 +1,19 @@
1
+ (() => {
1
2
  // 用量统计面板 client 半区:设置页 settings.section 注入与底部信息栏接管,en/zh 双语。
2
- // 无构建:createElement + 一次性样式注入;协议与渲染形态见 docs/feat-usage-dash/client-design.md。
3
- // 单文件自包含:client-modules bundle 以非模块 script 求值,禁止 import/export(整捆语法共担);纯函数区经测试整源求值收集。
3
+ // 无构建:createElement + 一次性样式注入。
4
+ // 单文件自包含:client-modules bundle 以经典 script 整源求值,禁止 import/export;整文件 IIFE 书挡,
5
+ // 顶层零词法声明(经典 script 全局词法环境跨 bundle 共享,顶层同名即整脚本拒载);测试经书挡剥壳求值纯函数区。
4
6
 
5
- const DAY_PRESETS = ['7', '14', '30', '90']
6
- const HOUR_PRESETS = ['24h', '48h', '72h']
7
- const MINUTE_PRESETS = ['60m', '180m', '360m']
7
+ const DAY_PRESETS = ['7', '30', '90']
8
+ const HOUR_PRESETS = ['24h', '3d', '7d', '15d']
9
+ const MINUTE_PRESETS = ['3h', '24h', '3d', '7d']
8
10
 
9
- const HOUR_PRESET_HOURS = { '24h': 24, '48h': 48, '72h': 72 }
10
- const MINUTE_PRESET_MINUTES = { '60m': 60, '180m': 180, '360m': 360 }
11
+ const HOUR_PRESET_HOURS = { '24h': 24, '3d': 3 * 24, '7d': 7 * 24, '15d': 15 * 24 }
12
+ const MINUTE_PRESET_MINUTES = { '3h': 3 * 60, '24h': 24 * 60, '3d': 3 * 24 * 60, '7d': 7 * 24 * 60 }
11
13
 
12
14
  const DEFAULT_RANGE = '30'
13
15
  const DEFAULT_HOUR_PRESET = '24h'
14
- const DEFAULT_MINUTE_PRESET = '60m'
16
+ const DEFAULT_MINUTE_PRESET = '24h'
15
17
 
16
18
  // 天视图渲染上限;时/分上限 = 闭区间桶数(hour N+1 槽,minute N/10+1 槽)
17
19
  const DAY_MAX_SLOTS = 180
@@ -85,6 +87,11 @@ function trimSlots(slots, max) {
85
87
  return slots.length > max ? slots.slice(-max) : slots
86
88
  }
87
89
 
90
+ // 时/分挡位表存在同 id(如 '24h'),点数据缓存命中须视图与挡位双匹配,防跨视图误用他端点数据
91
+ function pointStatsMatches(cached, view, presetId) {
92
+ return !!cached && cached.view === view && cached.preset === presetId
93
+ }
94
+
88
95
  const DEFAULT_ERROR_CODE = 'error'
89
96
  const DEFAULT_ERROR_MESSAGE = 'usage api error'
90
97
  const envelopeFailure = (code, message) => ({ ok: false, code, message })
@@ -102,15 +109,14 @@ function parseEnvelope(json) {
102
109
  const MESSAGES_ZH = {
103
110
  nav: '使用统计',
104
111
  range: '时间范围',
105
- 'rangePreset.7': '最近 7 天',
106
- 'rangePreset.14': '最近 14 天',
107
- 'rangePreset.30': '最近 30 天',
108
- 'rangePreset.90': '最近 90 天',
112
+ 'rangePreset.7': '7 天',
113
+ 'rangePreset.30': '30 天',
114
+ 'rangePreset.90': '90 天',
109
115
  rangeCustom: '自定义',
110
116
  from: '开始日期',
111
117
  to: '结束日期',
112
118
  refresh: '刷新',
113
- loading: '加载中',
119
+ loading: '正在扫描历史会话。安装插件后首次会全量回扫,数据量大时耗时较久,期间尽量减少操作以免服务变卡',
114
120
  tokens: 'Tokens 用量',
115
121
  tokensHint: '服务商总口径:未缓存输入 + 输出 + 缓存命中 token',
116
122
  sessions: '会话数量',
@@ -120,6 +126,8 @@ const MESSAGES_ZH = {
120
126
  cacheRateHint: '时间段内缓存命中 token 占输入 token 的比例',
121
127
  cacheHitRate: '缓存命中率',
122
128
  hitRateLegend: '缓存命中率',
129
+ avgSpeed: '平均生成速度',
130
+ speedLegend: '平均生成速度',
123
131
  topModel: '最常用模型',
124
132
  topModelHint: '按 token 用量排序,非调用次数',
125
133
  heatmap: '活跃热力图',
@@ -133,22 +141,31 @@ const MESSAGES_ZH = {
133
141
  percent: '占比',
134
142
  asOf: '统计截至',
135
143
  empty: '当前时间范围内暂无用量数据。Token 用量从本面板启用后开始累计,并会一次性回扫已有的历史会话。',
136
- viewDay: '按天',
137
- viewHour: '按小时',
138
- viewMinute: '按分钟',
144
+ viewDay: '',
145
+ viewHour: '小时',
146
+ viewMinute: '分钟',
139
147
  viewGroup: '统计粒度',
140
148
  'status.running': '回扫中 {done}/{total}',
141
149
  rebuild: '重建',
142
150
  rebuildConfirm: '确认重建',
143
151
  hourTrend: '按小时 Token 趋势',
144
152
  minuteTrend: '按分钟 Token 趋势',
145
- hourPreset: '最近 {n} 小时',
146
- minutePreset: '最近 {n} 分钟',
153
+ 'hourPreset.24h': '24 小时',
154
+ 'hourPreset.3d': '3 ',
155
+ 'hourPreset.7d': '7 天',
156
+ 'hourPreset.15d': '15 天',
157
+ 'minutePreset.3h': '3 小时',
158
+ 'minutePreset.24h': '24 小时',
159
+ 'minutePreset.3d': '3 天',
160
+ 'minutePreset.7d': '7 天',
147
161
  trendLimitedHour: '数据量过大,仅显示最近 {n} 小时',
148
162
  trendLimitedMinute: '数据量过大,仅显示最近 {n} 分钟',
149
163
  trendTruncated: '数据量过大,仅显示最近部分',
150
164
  recordFailures: '{n} 条记录写入失败',
151
165
  skippedSessions: '跳过 {n} 个无法读取的会话',
166
+ anomalyLog: '扫描异常日志',
167
+ logKindSkipped: '跳过会话',
168
+ logKindRecord: '写入失败',
152
169
  'stats.counts': '{turns} 轮 · {steps} 步',
153
170
  'stats.llm': 'LLM {duration}',
154
171
  'stats.toolCall': '工具调用 {duration}',
@@ -173,7 +190,7 @@ const MESSAGES_ZH = {
173
190
  pricing: '定价规则',
174
191
  pricingUnavailable: '定价规则不可用',
175
192
  pricingModel: '模型',
176
- pricingModelPlaceholder: 'provider/model 或 *',
193
+ pricingModelPlaceholder: 'provider/model,段可通配如 */*',
177
194
  pricingCurrency: '货币',
178
195
  pricingUnit: '每百万 token 定价',
179
196
  priceInput: '输入',
@@ -181,13 +198,34 @@ const MESSAGES_ZH = {
181
198
  priceCacheRead: '缓存读',
182
199
  priceCacheWrite: '缓存写',
183
200
  noCondition: '无条件 = 恒生效',
184
- conditionsPreserved: '已有 {n} 条条件,本编辑器暂不支持修改,保存时原样保留',
201
+ addCondition: '添加条件',
202
+ deleteCondition: '删除条件',
203
+ condKind: '条件类型',
204
+ condDailyWindow: '每日时段',
205
+ condWeekdays: '星期几',
206
+ condMonthDays: '每月号段',
207
+ condDateRange: '日期段',
208
+ condFrom: '从',
209
+ condTo: '至',
210
+ 'weekday.0': '日',
211
+ 'weekday.1': '一',
212
+ 'weekday.2': '二',
213
+ 'weekday.3': '三',
214
+ 'weekday.4': '四',
215
+ 'weekday.5': '五',
216
+ 'weekday.6': '六',
217
+ condTime: '需 HH:MM',
218
+ condWeekday: '需 0-6 整数',
219
+ condMonthDay: '需 1-31 整数',
220
+ condDate: '需 YYYY-MM-DD',
221
+ condRange: '起始不得晚于结束',
185
222
  deleteRule: '删除规则',
186
223
  addRule: '添加规则',
187
224
  save: '保存',
188
225
  saved: '已保存',
189
226
  required: '必填',
190
227
  priceInvalid: '不能为负',
228
+ modelFormat: '需两段 provider/model,段可通配',
191
229
  'duration.compactSeconds': '{seconds}秒',
192
230
  'duration.compactMinutes': '{minutes}分{seconds}秒',
193
231
  'number.thousand': '{value}K',
@@ -198,15 +236,14 @@ const MESSAGES_ZH = {
198
236
  const MESSAGES_EN = {
199
237
  nav: 'Usage',
200
238
  range: 'Time range',
201
- 'rangePreset.7': 'Last 7 days',
202
- 'rangePreset.14': 'Last 14 days',
203
- 'rangePreset.30': 'Last 30 days',
204
- 'rangePreset.90': 'Last 90 days',
239
+ 'rangePreset.7': '7 days',
240
+ 'rangePreset.30': '30 days',
241
+ 'rangePreset.90': '90 days',
205
242
  rangeCustom: 'Custom',
206
243
  from: 'From',
207
244
  to: 'To',
208
245
  refresh: 'Refresh',
209
- loading: 'Loading',
246
+ loading: 'Scanning past sessions. The first scan after installing runs a full backfill and can take a while on large histories; avoid heavy activity meanwhile to keep the service responsive',
210
247
  tokens: 'Token usage',
211
248
  tokensHint: 'Provider total: uncached input + output + cache-read tokens',
212
249
  sessions: 'Sessions',
@@ -216,6 +253,8 @@ const MESSAGES_EN = {
216
253
  cacheRateHint: 'Cache-hit tokens as a share of input tokens within the range',
217
254
  cacheHitRate: 'Cache-hit rate',
218
255
  hitRateLegend: 'Cache-hit rate',
256
+ avgSpeed: 'Avg speed',
257
+ speedLegend: 'Avg speed',
219
258
  topModel: 'Top model',
220
259
  topModelHint: 'Ranked by token usage, not call count',
221
260
  heatmap: 'Activity heatmap',
@@ -229,22 +268,31 @@ const MESSAGES_EN = {
229
268
  percent: 'Share',
230
269
  asOf: 'Stats as of',
231
270
  empty: 'No usage data in this range yet. Token usage accumulates from when this panel is enabled, and existing sessions are scanned once.',
232
- viewDay: 'Daily',
233
- viewHour: 'Hourly',
234
- viewMinute: 'Per-minute',
271
+ viewDay: 'Day',
272
+ viewHour: 'Hour',
273
+ viewMinute: 'Minute',
235
274
  viewGroup: 'Granularity',
236
275
  'status.running': 'Rescanning {done}/{total}',
237
276
  rebuild: 'Rebuild',
238
277
  rebuildConfirm: 'Confirm rebuild',
239
278
  hourTrend: 'Hourly token trend',
240
279
  minuteTrend: 'Per-minute token trend',
241
- hourPreset: 'Last {n} hours',
242
- minutePreset: 'Last {n} minutes',
280
+ 'hourPreset.24h': '24 hours',
281
+ 'hourPreset.3d': '3 days',
282
+ 'hourPreset.7d': '7 days',
283
+ 'hourPreset.15d': '15 days',
284
+ 'minutePreset.3h': '3 hours',
285
+ 'minutePreset.24h': '24 hours',
286
+ 'minutePreset.3d': '3 days',
287
+ 'minutePreset.7d': '7 days',
243
288
  trendLimitedHour: 'Too much data, showing only the last {n} hours',
244
289
  trendLimitedMinute: 'Too much data, showing only the last {n} minutes',
245
290
  trendTruncated: 'Too much data, showing only the latest part',
246
291
  recordFailures: '{n} records failed to write',
247
292
  skippedSessions: '{n} unreadable sessions skipped',
293
+ anomalyLog: 'Scan anomaly log',
294
+ logKindSkipped: 'skipped',
295
+ logKindRecord: 'write failed',
248
296
  'stats.counts': '{turns} turns · {steps} steps',
249
297
  'stats.llm': 'LLM {duration}',
250
298
  'stats.toolCall': 'Tool call {duration}',
@@ -269,7 +317,7 @@ const MESSAGES_EN = {
269
317
  pricing: 'Pricing rules',
270
318
  pricingUnavailable: 'Pricing rules unavailable',
271
319
  pricingModel: 'Model',
272
- pricingModelPlaceholder: 'provider/model or *',
320
+ pricingModelPlaceholder: 'provider/model, segments may be */*',
273
321
  pricingCurrency: 'Currency',
274
322
  pricingUnit: 'per million tokens pricing',
275
323
  priceInput: 'Input',
@@ -277,13 +325,34 @@ const MESSAGES_EN = {
277
325
  priceCacheRead: 'Cache read',
278
326
  priceCacheWrite: 'Cache write',
279
327
  noCondition: 'No condition = always applies',
280
- conditionsPreserved: '{n} existing conditions are kept as-is; editing them is not supported yet',
328
+ addCondition: 'Add condition',
329
+ deleteCondition: 'Remove condition',
330
+ condKind: 'Condition kind',
331
+ condDailyWindow: 'Daily window',
332
+ condWeekdays: 'Weekdays',
333
+ condMonthDays: 'Month days',
334
+ condDateRange: 'Date range',
335
+ condFrom: 'From',
336
+ condTo: 'To',
337
+ 'weekday.0': 'Su',
338
+ 'weekday.1': 'Mo',
339
+ 'weekday.2': 'Tu',
340
+ 'weekday.3': 'We',
341
+ 'weekday.4': 'Th',
342
+ 'weekday.5': 'Fr',
343
+ 'weekday.6': 'Sa',
344
+ condTime: 'Requires HH:MM',
345
+ condWeekday: 'Requires integer 0-6',
346
+ condMonthDay: 'Requires integer 1-31',
347
+ condDate: 'Requires YYYY-MM-DD',
348
+ condRange: 'From must not be after to',
281
349
  deleteRule: 'Remove rule',
282
350
  addRule: 'Add rule',
283
351
  save: 'Save',
284
352
  saved: 'Saved',
285
353
  required: 'Required',
286
354
  priceInvalid: 'Must not be negative',
355
+ modelFormat: 'Requires two segments provider/model; segments may be wildcards',
287
356
  'duration.compactSeconds': '{seconds}s',
288
357
  'duration.compactMinutes': '{minutes}m{seconds}s',
289
358
  'number.thousand': '{value}K',
@@ -318,23 +387,23 @@ function formatCompact(value) {
318
387
  function formatPercent(value) {
319
388
  return (Math.round(value * 10) / 10).toFixed(1) + '%'
320
389
  }
390
+ // tooltip 数值缺席占位
391
+ const TOOLTIP_MISSING = '—'
321
392
  function cacheRate(hit, miss) {
322
393
  const total = hit + miss
323
394
  return total <= 0 ? null : (hit / total) * 100
324
395
  }
325
396
  function cacheRateText(hit, miss) {
326
397
  const rate = cacheRate(hit, miss)
327
- return rate === null ? '—' : formatPercent(rate)
398
+ return rate === null ? TOOLTIP_MISSING : formatPercent(rate)
328
399
  }
329
400
 
330
- const REF_SPLIT_LIMIT = 2
401
+ // 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
331
402
  function modelNameOf(ref) {
332
- const parts = ref.split('/')
333
- return parts.length < REF_SPLIT_LIMIT ? ref : parts.slice(1).join('/')
403
+ return splitRequestSegments(ref)[1]
334
404
  }
335
405
  function providerOf(ref) {
336
- const parts = ref.split('/')
337
- return parts.length < REF_SPLIT_LIMIT ? 'default' : parts[0]
406
+ return splitRequestSegments(ref)[0]
338
407
  }
339
408
 
340
409
  function shortDay(day) {
@@ -354,17 +423,24 @@ function minuteTickLabel(key) {
354
423
  return time === MIDNIGHT_TIME ? `${shortDay(key.slice(0, DAY_KEY_LENGTH))} ${time}` : time
355
424
  }
356
425
 
426
+ // 悬浮窗槽标签:完整日期去 T 分隔,小时以 h 后缀标定(避免 i18n 单位问题),分钟保留 HH:MM
427
+ function hourSlotLabel(key) {
428
+ return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}h`
429
+ }
430
+
431
+ function minuteSlotLabel(key) {
432
+ return `${key.slice(0, DAY_KEY_LENGTH)} ${key.slice(DAY_KEY_LENGTH + 1)}`
433
+ }
434
+
357
435
  function isEmptyRange(value) {
358
436
  return value.tokens === 0 && value.cacheHit === 0 && value.requests === 0 && value.turns === 0
359
437
  }
360
438
 
361
- // 状态行仅在回扫进行或异常存在时可见,空闲干净态不占版面
362
- function statusLineActive(status) {
363
- if (!status) return false
364
- return Boolean(status.running)
365
- || Boolean(status.error)
366
- || (status.skippedSessions ?? 0) > 0
367
- || (status.recordFailures ?? 0) > 0
439
+ // 日志条目时刻仅显示当日时分秒
440
+ function logTimeOf(ms) {
441
+ const date = new Date(ms)
442
+ const part = (value) => String(value).padStart(2, '0')
443
+ return `${part(date.getHours())}:${part(date.getMinutes())}:${part(date.getSeconds())}`
368
444
  }
369
445
 
370
446
  const toRankedModels = (totals) =>
@@ -427,6 +503,13 @@ const BAR_WIDTH_RATIO = 0.62
427
503
  const BAR_MIN_WIDTH = 3
428
504
  const BAR_MAX_WIDTH = 30
429
505
  const AXIS_TICK_COUNT = 4
506
+ // 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
507
+ const SPEED_SCALE_FLOOR = 1
508
+ // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
509
+ const AXIS_SCALE_HEADROOM = 1.1
510
+ // 图例键:折线项与模型项共处同一显隐集合
511
+ const LEGEND_KEY_RATE = 'rate'
512
+ const LEGEND_KEY_SPEED = 'speed'
430
513
 
431
514
  function niceTicks(max, count) {
432
515
  if (max <= 0 || count <= 0) return []
@@ -439,14 +522,43 @@ function niceTicks(max, count) {
439
522
  return ticks
440
523
  }
441
524
 
442
- // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度
525
+ // 图例显隐切换:current 为 null 表示全部可见;普通点击单选/再点恢复,
526
+ // ctrl 单项切换,隐藏最后一项为无操作(返回原引用)
527
+ function legendToggle(current, key, ctrl) {
528
+ if (ctrl) {
529
+ const next = new Set(current ?? [])
530
+ if (next.has(key)) next.delete(key)
531
+ else next.add(key)
532
+ return next.size === 0 ? current : next
533
+ }
534
+ const solo = current !== null && current.size === 1 && current.has(key)
535
+ return solo ? null : new Set([key])
536
+ }
537
+
538
+ // 左轴刻度:速度刻度存在时左轴标定速度(tok/s),否则标定 token;
539
+ // 刻度值统一换算为绘图区高度占比
540
+ function leftAxisTicks(tokenTicks, tokenMax, speedTicks, speedMax) {
541
+ const useSpeed = speedTicks.length > 0
542
+ return (useSpeed ? speedTicks : tokenTicks).map((tick) => ({
543
+ label: formatCompact(tick),
544
+ ratio: tick / (useSpeed ? speedMax : tokenMax),
545
+ }))
546
+ }
547
+
548
+ // 堆叠柱几何:模型序即堆叠序(哨兵最后画柱顶),输出槽分段与左轴刻度;
549
+ // maxTotal 按可见模型求和,单选模型时刻度跟随归一
443
550
  function trendLayout(slots, modelOrder, avail, labelMinPitch) {
444
551
  const plotHeight = CHART_HEIGHT - CHART_PAD.top - CHART_PAD.bottom
445
552
  const innerWidth = Math.max(1, avail - CHART_PAD.left - CHART_PAD.right)
446
553
  const count = slots.length
447
554
  const step = count > 1 ? innerWidth / (count - 1) : innerWidth
448
555
  const barWidth = Math.max(BAR_MIN_WIDTH, Math.min(BAR_MAX_WIDTH, step * BAR_WIDTH_RATIO))
449
- const maxTotal = Math.max(1, ...slots.map((slot) => slot.total))
556
+ const slotVisibleTotal = (slot) => modelOrder.reduce((sum, model) => sum + (slot.byModel[model] ?? 0), 0)
557
+ const maxTotal = Math.max(1, ...slots.map(slotVisibleTotal))
558
+ // 轴上限 = 数据峰 × 留白系数,柱高分母与左轴刻度分母同源
559
+ const scaleMax = maxTotal * AXIS_SCALE_HEADROOM
560
+ // 可见模型无任何数据时左轴无标定对象,空刻度防钳底值漏成假刻度
561
+ const visibleTotal = slots.reduce((sum, slot) => sum + slotVisibleTotal(slot), 0)
450
562
  const bars = slots.map((slot, index) => {
451
563
  const centerX = CHART_PAD.left + barWidth / 2 + index * step
452
564
  let yBottom = CHART_PAD.top + plotHeight
@@ -454,7 +566,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
454
566
  for (const model of modelOrder) {
455
567
  const tokens = slot.byModel[model] ?? 0
456
568
  if (tokens === 0) continue
457
- const height = (tokens / maxTotal) * plotHeight
569
+ const height = (tokens / scaleMax) * plotHeight
458
570
  yBottom -= height
459
571
  segments.push({ model, y: yBottom, height })
460
572
  }
@@ -467,7 +579,8 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
467
579
  step,
468
580
  barWidth,
469
581
  maxTotal,
470
- ticks: niceTicks(maxTotal, AXIS_TICK_COUNT),
582
+ scaleMax,
583
+ ticks: visibleTotal === 0 ? [] : niceTicks(maxTotal, AXIS_TICK_COUNT),
471
584
  labelEvery: Math.max(1, Math.ceil(labelMinPitch / step)),
472
585
  bars,
473
586
  }
@@ -499,6 +612,20 @@ function trendRatePoints(slots, bars, plotHeight) {
499
612
  return points
500
613
  }
501
614
 
615
+ // 速度曲线点:仅带速度槽产出,高度按刻度上限归一
616
+ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
617
+ const points = []
618
+ slots.forEach((slot, index) => {
619
+ if (slot.speed === undefined) return
620
+ points.push({
621
+ day: slot.day,
622
+ x: bars[index].x,
623
+ y: CHART_PAD.top + plotHeight - (slot.speed / scaleMax) * plotHeight,
624
+ })
625
+ })
626
+ return points
627
+ }
628
+
502
629
  // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
503
630
  function smoothPath(points) {
504
631
  if (points.length === 0) return ''
@@ -552,6 +679,22 @@ function modelSegmentLabel(name, tokens, percent) {
552
679
  return `${name}: ${formatTokens(tokens)} (${formatPercent(percent)})`
553
680
  }
554
681
 
682
+ // 模型平均生成速度文本:官方吞吐口径格式化 + 单位(语言中立);无速度(无时长数据)为空串
683
+ function modelSpeedText(speed) {
684
+ if (speed === undefined) return ''
685
+ return `${formatTokensPerSecond(speed)} tok/s`
686
+ }
687
+
688
+ // tooltip 速度行文本:无速度与命中率同款占位符
689
+ function speedTipText(speed) {
690
+ return speed === undefined ? TOOLTIP_MISSING : modelSpeedText(speed)
691
+ }
692
+
693
+ // 速度刻度上限:全零或无速度钳底,防除零
694
+ function speedScaleMax(slots) {
695
+ return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
696
+ }
697
+
555
698
  // 热力图:窗口固定 26 周,与所选范围无关
556
699
  const HEAT_WEEKS = 26
557
700
  const HEAT_ROW_COUNT = 7
@@ -892,6 +1035,11 @@ const CONDITION_KINDS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
892
1035
  const TOKENS_PER_MILLION = 1000 * 1000
893
1036
 
894
1037
  const MODEL_WILDCARD = '*'
1038
+ const SEGMENT_SEPARATOR = '/'
1039
+ const PROVIDER_UNSET = 'default'
1040
+ // 档位权重:vendor 段通配 1 档、model 段通配 2 档,和越小越优先(模型名精确档恒优于供应商精确档)
1041
+ const VENDOR_WILDCARD_TIER = 1
1042
+ const MODEL_WILDCARD_TIER = 2
895
1043
  const MINUTES_PER_HOUR = 60
896
1044
 
897
1045
  const minutesOfDay = (date) => date.getHours() * MINUTES_PER_HOUR + date.getMinutes()
@@ -961,12 +1109,31 @@ function isRuleShaped(rule) {
961
1109
  && Array.isArray(rule.conditions)
962
1110
  }
963
1111
 
964
- const firstMatchingPrice = (rules, date, modelFilter) => {
965
- for (const rule of rules) {
966
- if (!isRuleShaped(rule) || !modelFilter(rule)) continue
967
- if (rule.conditions.every((condition) => conditionMatches(condition, date))) return rule.price
968
- }
969
- return null
1112
+ // 规则模型键必须两段式:首个 / 前 vendor 段、后模型段(允许含 /),首位斜杠或无斜杠均非法
1113
+ function splitRuleSegments(pattern) {
1114
+ const slash = pattern.indexOf(SEGMENT_SEPARATOR)
1115
+ return slash > 0 ? [pattern.slice(0, slash), pattern.slice(slash + SEGMENT_SEPARATOR.length)] : null
1116
+ }
1117
+
1118
+ // 段须非空且不含空白:含空白的模型键永不匹配真实请求
1119
+ function isTwoSegmentModel(pattern) {
1120
+ const segments = splitRuleSegments(pattern)
1121
+ return segments !== null && segments.every((segment) => /^\S+$/.test(segment))
1122
+ }
1123
+
1124
+ // 请求侧模型键按同构规则分段,无 / 或首位斜杠时 vendor 段缺省归 default(与存储行口径一致)
1125
+ function splitRequestSegments(model) {
1126
+ return splitRuleSegments(model) ?? [PROVIDER_UNSET, model]
1127
+ }
1128
+
1129
+ // 段级比对:规则段为通配或与请求段相等;两段全过才成立,通配段按各自档位计权
1130
+ function matchTier(ruleSegments, requestSegments) {
1131
+ const [ruleVendor, ruleModel] = ruleSegments
1132
+ const [requestVendor, requestModel] = requestSegments
1133
+ if (ruleVendor !== MODEL_WILDCARD && ruleVendor !== requestVendor) return null
1134
+ if (ruleModel !== MODEL_WILDCARD && ruleModel !== requestModel) return null
1135
+ return (ruleVendor === MODEL_WILDCARD ? VENDOR_WILDCARD_TIER : 0)
1136
+ + (ruleModel === MODEL_WILDCARD ? MODEL_WILDCARD_TIER : 0)
970
1137
  }
971
1138
 
972
1139
  const toLocalDate = (timestamp) => {
@@ -974,12 +1141,25 @@ const toLocalDate = (timestamp) => {
974
1141
  return Number.isNaN(date.getTime()) ? null : date
975
1142
  }
976
1143
 
977
- // 精确子集按数组序取首个命中;无精确子集或全不命中回落 '*' 子集;仍无命中为 null
1144
+ // 匹配链:全名 > 模型名(vendor 通配)> 供应商(model 通配)> '*/*' 全通;
1145
+ // 档位最小者胜,同档按数组序取首个;高档条件不满足自然落低档;无命中为 null(调用方计 unpriced)
978
1146
  function matchPrice(rules, model, timestamp) {
979
1147
  const date = toLocalDate(timestamp)
980
1148
  if (!Array.isArray(rules) || !date || typeof model !== 'string') return null
981
- return firstMatchingPrice(rules, date, (rule) => rule.model === model)
982
- ?? firstMatchingPrice(rules, date, (rule) => rule.model === MODEL_WILDCARD)
1149
+ const requestSegments = splitRequestSegments(model)
1150
+ let bestTier = Infinity
1151
+ let bestPrice = null
1152
+ for (const rule of rules) {
1153
+ if (!isRuleShaped(rule)) continue
1154
+ const ruleSegments = splitRuleSegments(rule.model)
1155
+ if (!ruleSegments) continue
1156
+ const tier = matchTier(ruleSegments, requestSegments)
1157
+ if (tier === null || tier >= bestTier) continue
1158
+ if (!rule.conditions.every((condition) => conditionMatches(condition, date))) continue
1159
+ bestTier = tier
1160
+ bestPrice = rule.price
1161
+ }
1162
+ return bestPrice
983
1163
  }
984
1164
 
985
1165
  const BUCKET_PRICE_KEYS = [
@@ -1030,11 +1210,14 @@ const pricingBucketsOf = (usage) => ({
1030
1210
  cacheWriteTokens: usage.cacheWriteTokens,
1031
1211
  })
1032
1212
 
1213
+ // routes 缺席占位:双段通配模型键,仅被 '*/*' 全通配规则命中(供应商与模型全未知)
1214
+ const MODEL_UNROUTED = '*/*'
1215
+
1033
1216
  // 注入点A 费用组装配:开关关/无用量/价格未加载不渲染;规则已载无命中价显示占位符;
1034
- // routes 缺席时 model 取通配,只匹配通配规则;时间条件按当前时刻评估(估算口径)
1217
+ // routes 缺席时 model 取全通配键;时间条件按当前时刻评估(估算口径)
1035
1218
  function buildCostItem(usage, rules, prefs, t, now = new Date()) {
1036
1219
  if (!prefs?.costDisplay || !usage || !Array.isArray(rules)) return null
1037
- const model = usage.routes?.[0]?.model ?? MODEL_WILDCARD
1220
+ const model = usage.routes?.[0]?.model ?? MODEL_UNROUTED
1038
1221
  const price = matchPrice(rules, model, now)
1039
1222
  if (!price) return COST_PLACEHOLDER
1040
1223
  return t('stats.cost', { cost: formatCost(costOf(price, pricingBucketsOf(usage)), aggregateCurrencyOf(rules)) })
@@ -1060,8 +1243,8 @@ function selectTurnTokenUsage(owner) {
1060
1243
  : null
1061
1244
  }
1062
1245
 
1063
- // 计价模型键:routes 首个 route.model,缺席回退通配(与注入点A 同款)
1064
- const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_WILDCARD
1246
+ // 计价模型键:routes 首个 route.model,缺席回退全通配键(与注入点A 同款)
1247
+ const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_UNROUTED
1065
1248
 
1066
1249
  // 可选桶(cacheRead/cacheWrite)仅部分 provider 上报,缺失按 0 计入摘要与费用
1067
1250
  const turnReportedBucket = (value) => (value ?? 0)
@@ -1097,28 +1280,109 @@ function turnCostTitleText(t, tokenUsage) {
1097
1280
  // ===== 定价编辑器纯函数(校验/规整/默认值) =====
1098
1281
  const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
1099
1282
  const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
1283
+ const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
1284
+ // 时刻分量界:小时含头不含尾,分钟双闭
1285
+ const HOUR_MAX = 24
1286
+ const MINUTE_MAX = 59
1287
+ const WEEKDAY_MIN = 0
1288
+ const WEEKDAY_MAX = 6
1289
+ const MONTH_DAY_MIN = 1
1290
+ const MONTH_DAY_MAX = 31
1291
+ const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
1292
+ const CONDITION_KIND_LABEL_KEYS = {
1293
+ dailyWindow: 'condDailyWindow',
1294
+ weekdays: 'condWeekdays',
1295
+ monthDays: 'condMonthDays',
1296
+ dateRange: 'condDateRange',
1297
+ }
1298
+ const WEEKDAY_COUNT = 7
1299
+
1300
+ const parseHHMM = (value) => {
1301
+ if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) return null
1302
+ const [hours, minutes] = value.split(':').map(Number)
1303
+ return hours >= 0 && hours < HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
1304
+ }
1305
+
1306
+ const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
1307
+
1308
+ const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
1309
+
1310
+ // 各条件类型合法默认:时段全天(from===to)、周几空、号段全月(短月高位号自然不触发)、日期段当天
1311
+ const defaultCondition = (kind, now = new Date()) => {
1312
+ const today = formatDate(now)
1313
+ const defaults = {
1314
+ dailyWindow: { kind: 'dailyWindow', from: '00:00', to: '00:00' },
1315
+ weekdays: { kind: 'weekdays', days: [] },
1316
+ monthDays: { kind: 'monthDays', from: MONTH_DAY_MIN, to: MONTH_DAY_MAX },
1317
+ dateRange: { kind: 'dateRange', from: today, to: today },
1318
+ }
1319
+ return defaults[kind] ? { ...defaults[kind] } : null
1320
+ }
1321
+
1322
+ // 条件字段级校验:路径前缀 + 键 → 错误键;倒序仅 dateRange 非法(时段/号段倒序为跨午夜/跨月语义)
1323
+ const validateCondition = (condition, path, errors) => {
1324
+ if (!condition || typeof condition !== 'object') {
1325
+ errors.set(path, 'required')
1326
+ return
1327
+ }
1328
+ if (condition.kind === 'dailyWindow') {
1329
+ if (parseHHMM(condition.from) === null) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condTime')
1330
+ if (parseHHMM(condition.to) === null) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condTime')
1331
+ return
1332
+ }
1333
+ if (condition.kind === 'weekdays') {
1334
+ if (!Array.isArray(condition.days) || !condition.days.every(isValidWeekday)) errors.set(`${path}.days`, 'condWeekday')
1335
+ return
1336
+ }
1337
+ if (condition.kind === 'monthDays') {
1338
+ if (!isValidMonthDay(condition.from)) errors.set(`${path}.from`, 'condMonthDay')
1339
+ if (!isValidMonthDay(condition.to)) errors.set(`${path}.to`, 'condMonthDay')
1340
+ return
1341
+ }
1342
+ if (condition.kind === 'dateRange') {
1343
+ if (typeof condition.from !== 'string' || !ISO_DAY_PATTERN_CLIENT.test(condition.from)) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condDate')
1344
+ if (typeof condition.to !== 'string' || !ISO_DAY_PATTERN_CLIENT.test(condition.to)) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condDate')
1345
+ if (errors.has(`${path}.from`) || errors.has(`${path}.to`)) return
1346
+ if (condition.from > condition.to) errors.set(path, 'condRange')
1347
+ }
1348
+ }
1100
1349
 
1101
- // 就地校验:字段路径 → 文案键;仅覆盖编辑器可编辑字段(模型与四桶价格)
1350
+ // POST 前条件规整:输入框字符串值转数字字段;时段/日期段为字符串原样
1351
+ function coerceConditions(conditions) {
1352
+ return conditions.map((condition) => {
1353
+ if (condition.kind === 'weekdays') return { ...condition, days: condition.days.map(Number) }
1354
+ if (condition.kind === 'monthDays') return { ...condition, from: Number(condition.from), to: Number(condition.to) }
1355
+ return condition
1356
+ })
1357
+ }
1358
+
1359
+ // 就地校验:字段路径 → 文案键;仅覆盖编辑器可编辑字段(模型/四桶价格/时间条件);模型须两段式
1102
1360
  function validatePricingRules(rules) {
1103
1361
  const errors = new Map()
1104
1362
  if (!Array.isArray(rules)) return errors
1105
1363
  rules.forEach((rule, ruleIndex) => {
1106
1364
  if (typeof rule.model !== 'string' || rule.model.trim().length === 0) errors.set(`${ruleIndex}.model`, 'required')
1365
+ else if (!isTwoSegmentModel(rule.model)) errors.set(`${ruleIndex}.model`, 'modelFormat')
1107
1366
  PRICE_KEYS.forEach((key) => {
1108
1367
  const value = rule.price?.[key]
1109
1368
  const path = `${ruleIndex}.price.${key}`
1110
1369
  if (value === '' || value === null || value === undefined) errors.set(path, 'required')
1111
1370
  else if (!Number.isFinite(Number(value)) || Number(value) < 0) errors.set(path, 'priceInvalid')
1112
1371
  })
1372
+ const conditions = Array.isArray(rule.conditions) ? rule.conditions : []
1373
+ conditions.forEach((condition, conditionIndex) => {
1374
+ validateCondition(condition, `${ruleIndex}.conditions.${conditionIndex}`, errors)
1375
+ })
1113
1376
  })
1114
1377
  return errors
1115
1378
  }
1116
1379
 
1117
- // POST 前规整:输入框字符串值转数值;模型原样(校验已确保非空)
1380
+ // POST 前规整:输入框字符串值转数值;模型已校验确保两段式且无空白,原样提交
1118
1381
  function coercePricingRules(rules) {
1119
1382
  return rules.map((rule) => ({
1120
1383
  ...rule,
1121
1384
  price: PRICE_KEYS.reduce((price, key) => ({ ...price, [key]: Number(rule.price[key]) }), {}),
1385
+ conditions: coerceConditions(rule.conditions ?? []),
1122
1386
  }))
1123
1387
  }
1124
1388
 
@@ -1136,7 +1400,7 @@ const defaultPricingRule = (currency = CURRENCIES[0]) => ({
1136
1400
  conditions: [],
1137
1401
  })
1138
1402
 
1139
- const updateRuleAt = (rules, index, patch) => rules.map((rule, i) => (i === index ? { ...rule, ...patch } : rule))
1403
+ const patchItemAt = (array, index, patch) => array.map((item, i) => (i === index ? { ...item, ...patch } : item))
1140
1404
 
1141
1405
 
1142
1406
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
@@ -1205,6 +1469,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1205
1469
  const STATUS_POLL_FAST_MS = 1000
1206
1470
  const STATUS_POLL_SLOW_MS = 5000
1207
1471
  const REBUILD_CONFIRM_MS = 3000
1472
+ // Material Symbols 风格刷新图标路径,currentColor 继承按钮色
1473
+ const REFRESH_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true"><path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>'
1208
1474
  const STATUS_REFRESH_DEBOUNCE_MS = 800
1209
1475
  const FIT_MAX_SIZE = 22
1210
1476
  const FIT_MIN_SIZE = 11
@@ -1283,10 +1549,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1283
1549
  const STYLE_CSS = `
1284
1550
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1285
1551
  --ud-chart-1:color-mix(in srgb,#0576ff 70%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 70%,white);--ud-chart-3:color-mix(in srgb,#c46212 70%,white);--ud-chart-4:color-mix(in srgb,#975bf1 70%,white);--ud-chart-5:color-mix(in srgb,#d34591 70%,white);--ud-chart-other:color-mix(in srgb,#576270 70%,white);
1286
- --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff}
1552
+ --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678}
1287
1553
  body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 65%,white);--ud-chart-3:color-mix(in srgb,#c46212 65%,white);--ud-chart-4:color-mix(in srgb,#975bf1 65%,white);--ud-chart-5:color-mix(in srgb,#d34591 65%,white);--ud-chart-other:color-mix(in srgb,#576270 65%,white);
1288
- --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe}
1289
- .ud-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1554
+ --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f}
1555
+ .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1556
+ .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1290
1557
  .ud-group{display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
1291
1558
  .ud-seg-item{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1;padding:5px 10px;border-radius:6px;cursor:pointer;white-space:nowrap}
1292
1559
  .ud-seg-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
@@ -1297,16 +1564,32 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1297
1564
  .ud-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:5px 14px;font-size:12px;line-height:1;cursor:pointer}
1298
1565
  .ud-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1299
1566
  .ud-btn:disabled{opacity:.5;cursor:default}
1300
- .ud-refresh{margin-left:auto}
1567
+ .ud-toolbar-side{display:flex;align-items:center;gap:8px;flex:none;margin-left:auto;align-self:stretch}
1568
+ .ud-toolbar-side .ud-btn--text{display:inline-flex;align-items:center;height:100%}
1569
+ .ud-icon-btn{padding:0 12px}
1570
+ .ud-icon{display:inline-flex;align-items:center;justify-content:center}
1301
1571
  .ud-btn--text{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);padding:2px 4px}
1302
1572
  .ud-error{border:1px solid var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label);border-radius:8px;padding:8px 12px;font-size:12px}
1303
1573
  .ud-loading{color:var(--dsw-alias-label-tertiary);text-align:center;padding:32px 0}
1304
1574
  .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}
1305
1575
  .ud-foot{color:var(--dsw-alias-label-tertiary);font-size:11px}
1306
- .ud-status{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary)}
1576
+ .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)}
1577
+ .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}
1578
+ .ud-status-fold:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
1579
+ .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}
1580
+ .ud-status-fold[aria-expanded="true"] .ud-status-fold-caret{transform:rotate(180deg)}
1307
1581
  .ud-status-track{display:inline-block;width:120px;height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden}
1308
1582
  .ud-status-fill{display:block;height:100%;background:var(--dsw-alias-state-business-primary)}
1309
1583
  .ud-status-err{color:var(--dsw-alias-state-error-primary)}
1584
+ .ud-detail{width:100%;margin-top:4px;display:flex;flex-direction:column;gap:6px}
1585
+ .ud-detail-foot{display:flex;justify-content:flex-end;align-items:center}
1586
+ .ud-log{width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);padding:8px 10px;display:flex;flex-direction:column;gap:3px;max-height:200px;overflow-y:auto;font-size:11px;line-height:1.5}
1587
+ .ud-log-summary{display:flex;gap:12px;flex-wrap:wrap;color:var(--dsw-alias-label-tertiary);font-size:12px;padding-bottom:4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
1588
+ .ud-log-line{display:flex;gap:8px;align-items:baseline;min-width:0}
1589
+ .ud-log-time{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);opacity:.7}
1590
+ .ud-log-kind{flex:none}
1591
+ .ud-log-err{color:var(--dsw-alias-state-error-primary)}
1592
+ .ud-log-detail{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1310
1593
  .ud-cards{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:10px}
1311
1594
  @media (max-width:560px){.ud-cards{grid-template-columns:1fr 1fr}}
1312
1595
  @media (max-width:380px){.ud-cards{grid-template-columns:1fr}}
@@ -1328,7 +1611,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1328
1611
  .ud-grid{stroke:var(--dsw-alias-border-l1);stroke-width:1}
1329
1612
  .ud-axis{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
1330
1613
  .ud-legend{display:flex;flex-wrap:wrap;gap:4px 12px}
1331
- .ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0}
1614
+ .ud-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0;user-select:none;cursor:pointer}
1615
+ .ud-legend-item--off{opacity:.35}
1332
1616
  .ud-legend-item span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1333
1617
  .ud-legend-swatch{width:8px;height:8px;border-radius:2px;flex:none}
1334
1618
  .ud-heat-wrap{width:100%;min-width:0;overflow:hidden}
@@ -1351,8 +1635,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1351
1635
  .ud-bar{transform-box:fill-box;transform-origin:center}
1352
1636
  .ud-bar-hit{fill:transparent;pointer-events:all}
1353
1637
  .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1638
+ .ud-trend--speed{stroke:var(--ud-trend-speed)}
1354
1639
  .ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
1640
+ .ud-trend-dot--speed{fill:var(--ud-trend-speed)}
1355
1641
  .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1642
+ .ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
1356
1643
  .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1357
1644
  .ud-donut-wrap{flex:0 0 auto}
1358
1645
  .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
@@ -1396,6 +1683,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1396
1683
  .ud-rule-head{display:flex;align-items:flex-end;gap:8px}
1397
1684
  .ud-rule-head .ud-field{flex:1}
1398
1685
  .ud-rule-cond{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1686
+ .ud-rule-conds{display:flex;flex-direction:column;gap:6px}
1687
+ .ud-cond{display:flex;align-items:flex-start;gap:6px;flex-wrap:wrap}
1688
+ .ud-cond-kind{width:auto;min-width:88px}
1689
+ .ud-cond-fields{display:flex;align-items:flex-end;gap:6px;flex-wrap:wrap;flex:1;min-width:0}
1690
+ .ud-cond-fields .ud-field{flex:0 1 auto}
1691
+ .ud-cond-fields .ud-input{width:auto}
1692
+ .ud-cond-add{display:flex;gap:6px;flex-wrap:wrap}
1399
1693
  .ud-field{display:flex;flex-direction:column;gap:3px;min-width:0}
1400
1694
  .ud-field-label{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1401
1695
  .ud-field-error{font-size:11px;color:var(--dsw-alias-state-error-primary)}
@@ -1487,14 +1781,22 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1487
1781
  h(FitText, null, String(stats.activeDays))))
1488
1782
  }
1489
1783
 
1490
- function Legend({ models, colorFor, t = defaultT }) {
1784
+ function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
1785
+ const itemProps = (key) => ({
1786
+ className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
1787
+ onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
1788
+ role: onItem ? 'button' : undefined,
1789
+ })
1491
1790
  return h('div', { className: 'ud-legend' },
1492
- models.map((item) => h('span', { key: item.model, className: 'ud-legend-item', title: item.model === OTHER_MODEL ? t('other') : item.model },
1791
+ models.map((item) => h('span', { key: item.model, title: item.model === OTHER_MODEL ? t('other') : item.model, ...itemProps(item.model) },
1493
1792
  h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1494
1793
  h('span', null, item.model === OTHER_MODEL ? t('other') : item.model))),
1495
- h('span', { key: 'hit-rate', className: 'ud-legend-item', title: t('hitRateLegend') },
1794
+ h('span', { key: 'hit-rate', title: t('hitRateLegend'), ...itemProps(LEGEND_KEY_RATE) },
1496
1795
  h('i', { className: 'ud-legend-swatch ud-legend-swatch--line' }),
1497
- h('span', null, t('hitRateLegend'))))
1796
+ h('span', null, t('hitRateLegend'))),
1797
+ speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
1798
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
1799
+ h('span', null, t('speedLegend'))) : null)
1498
1800
  }
1499
1801
 
1500
1802
  const colorForModel = (models) => (model) => {
@@ -1504,10 +1806,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1504
1806
  return `var(--ud-chart-${rank})`
1505
1807
  }
1506
1808
 
1507
- function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1809
+ function TrendChart({ title, notes, slots, modelOrder, colorFor, labelFor, slotLabelFor, labelMinPitch, busy, legendModels, panelRef, costCurrency = '', costEnabled = false, t = defaultT }) {
1508
1810
  const wrapRef = useRef(null)
1509
1811
  const [avail, setAvail] = useState(CHART_NOMINAL_WIDTH)
1510
1812
  const [hover, setHover] = useState(null)
1813
+ // 图例显隐:null = 全部可见;键集 = 模型项与折线项(rate/speed)
1814
+ const [visibleKeys, setVisibleKeys] = useState(null)
1511
1815
  // prefs 仅用于 hover tooltip 的费用显隐,经 ref 读取:开关切换不触发本组件重渲染,
1512
1816
  // 避免宿主设置弹窗滚动锚定被重渲染扰动而跳变
1513
1817
  const prefsRef = useRef(statsLineState.get())
@@ -1521,11 +1825,23 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1521
1825
  observer.observe(element)
1522
1826
  return () => observer.disconnect()
1523
1827
  }, [])
1524
- const layout = trendLayout(slots, modelOrder, avail, labelMinPitch)
1828
+ const hasSpeed = slots.some((slot) => slot.speed !== undefined)
1829
+ const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
1830
+ const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
1831
+ const showRate = visibleSet.has(LEGEND_KEY_RATE)
1832
+ const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
1833
+ const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
1525
1834
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1526
1835
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1836
+ const speedMax = speedScaleMax(slots)
1837
+ const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
1838
+ const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
1839
+ // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
1840
+ const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
1841
+ const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
1527
1842
  const hoverSlot = hover ? slots[hover.index] : null
1528
- const hoverRatePoint = hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1843
+ const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1844
+ const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
1529
1845
  const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
1530
1846
  const clear = () => setHover(null)
1531
1847
  const otherEntries = hoverSlot
@@ -1540,13 +1856,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1540
1856
  className: 'ud-chart', viewBox: `0 0 ${avail} ${CHART_HEIGHT}`, width: '100%', role: 'img',
1541
1857
  'aria-label': title, onMouseLeave: clear,
1542
1858
  },
1543
- layout.ticks.map((tick) => {
1544
- const y = CHART_PAD.top + layout.plotHeight - (tick / layout.maxTotal) * layout.plotHeight
1545
- return h('g', { key: tick },
1859
+ yTicks.map((tick) => {
1860
+ const y = CHART_PAD.top + layout.plotHeight - tick.ratio * layout.plotHeight
1861
+ return h('g', { key: tick.label },
1546
1862
  h('line', { className: 'ud-grid', x1: CHART_PAD.left, x2: plotRight, y1: y, y2: y }),
1547
- h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, formatCompact(tick)))
1863
+ h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: y + AXIS_LABEL_BASELINE, textAnchor: 'end' }, tick.label))
1548
1864
  }),
1549
- rateAxisTicks().map((tick) => {
1865
+ speedAxisTicks.length > 0
1866
+ ? h('text', { className: 'ud-axis', x: CHART_PAD.left - AXIS_LABEL_GAP, y: CHART_PAD.top + AXIS_LABEL_BASELINE, textAnchor: 'end' }, 'tok/s')
1867
+ : null,
1868
+ (showRate ? rateAxisTicks() : []).map((tick) => {
1550
1869
  const y = CHART_PAD.top + layout.plotHeight - (tick / PERCENT_SCALE) * layout.plotHeight
1551
1870
  return h('text', {
1552
1871
  key: `rate-${tick}`, className: 'ud-axis-rate',
@@ -1564,30 +1883,42 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1564
1883
  slots.map((slot, index) => (index % layout.labelEvery === 0 || index === slots.length - 1)
1565
1884
  ? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
1566
1885
  : null),
1567
- h('path', {
1886
+ showRate ? h('path', {
1568
1887
  className: 'ud-trend', d: smoothPath(ratePoints),
1569
1888
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1570
- }),
1889
+ }) : null,
1890
+ showSpeed ? h('path', {
1891
+ className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
1892
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
1893
+ }) : null,
1571
1894
  hoverRatePoint
1572
1895
  ? h('circle', { className: 'ud-trend-dot', cx: hoverRatePoint.x, cy: hoverRatePoint.y, r: TREND_DOT_RADIUS })
1573
1896
  : null,
1897
+ hoverSpeedPoint
1898
+ ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
1899
+ : null,
1574
1900
  slots.map((slot, index) => h('rect', {
1575
1901
  key: `hit-${slot.day}`, className: 'ud-bar-hit',
1576
1902
  x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
1577
1903
  onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
1578
1904
  })))),
1579
- h(Legend, { models: legendModels, colorFor }),
1905
+ h(Legend, {
1906
+ models: legendModels, colorFor, speedEnabled: hasSpeed,
1907
+ isVisible: (key) => visibleSet.has(key),
1908
+ onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
1909
+ }),
1580
1910
  h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
1581
1911
  hoverSlot
1582
1912
  ? [
1583
- h('div', { key: 'title', className: 'ud-tip-title' }, hoverSlot.day),
1913
+ h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
1584
1914
  h('div', { key: 'total', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(hoverSlot.total)}`),
1585
- ...legendModels.map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
1915
+ ...legendModels.filter((item) => visibleSet.has(item.model)).map((item) => h('div', { key: `m-${item.model}`, className: 'ud-tip-row' },
1586
1916
  h('i', { className: 'ud-legend-swatch', style: { background: colorFor(item.model) } }),
1587
1917
  `${item.model === OTHER_MODEL ? t('other') : item.model}: ${formatTokens(hoverSlot.byModel[item.model] ?? 0)}`)),
1588
- ...otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
1589
- `${model}: ${formatTokens(tokens)}`)),
1590
- h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`),
1918
+ ...(visibleSet.has(OTHER_MODEL) ? otherEntries.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
1919
+ `${model}: ${formatTokens(tokens)}`)) : []),
1920
+ showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
1921
+ showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
1591
1922
  costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
1592
1923
  ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
1593
1924
  : null,
@@ -1747,6 +2078,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1747
2078
  h('div', { className: 'ud-models' },
1748
2079
  models.map((item) => {
1749
2080
  const isOther = item.model === OTHER_MODEL
2081
+ const speedText = modelSpeedText(item.speed)
1750
2082
  return h(React.Fragment, { key: item.model },
1751
2083
  h('div', {
1752
2084
  className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
@@ -1771,7 +2103,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1771
2103
  h('span', { className: 'ud-model-tokens' }, formatTokens(item.tokens)),
1772
2104
  h('span', { className: 'ud-model-pct' },
1773
2105
  item.cost !== undefined ? h('span', { className: 'ud-model-cost' }, `≈ ${formatCost(item.cost, costCurrency)} · `) : null,
1774
- formatPercent((item.tokens / total) * PERCENT_SCALE)))),
2106
+ formatPercent((item.tokens / total) * PERCENT_SCALE),
2107
+ speedText ? ` · ${speedText}` : null))),
1775
2108
  isOther
1776
2109
  ? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
1777
2110
  h('div', { className: 'ud-model-other-list' },
@@ -1919,10 +2252,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1919
2252
  }
1920
2253
 
1921
2254
  // 定价规则编辑器:货币为编辑器级全局设置(标题右侧切换,整表统一,不逐模型设置);
1922
- // 条件行不支持编辑,已有条件整条保留原样,空条件即恒生效;打开面板时经 fetchPricing 初始化,未保存离开即弃
2255
+ // 每规则可挂多条时间条件(时段/周几/号段/日期段),全部条件命中才生效;打开面板时经 fetchPricing 初始化,未保存离开即弃
1923
2256
  const PRICING_STATE_READY = 'ready'
1924
2257
  const PRICING_STATE_UNAVAILABLE = 'unavailable'
1925
2258
 
2259
+ // 条件行:类型下拉 + 按类型字段区 + 删除;类型切换重置为该类型默认值(字段结构互不相通)
1926
2260
  function PricingRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove }) {
1927
2261
  const errorTextOf = (path) => {
1928
2262
  const key = errors.get(path)
@@ -1938,6 +2272,58 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1938
2272
  onChange: (event) => onPatch({ price: { ...rule.price, [key]: event.target.value } }),
1939
2273
  })),
1940
2274
  errorTextOf(`${pathPrefix}price.${key}`))
2275
+ const patchConditions = (conditions) => onPatch({ conditions })
2276
+ const patchConditionAt = (conditionIndex, patch) => patchConditions(patchItemAt(rule.conditions, conditionIndex, patch))
2277
+ const removeConditionAt = (conditionIndex) => patchConditions(rule.conditions.filter((_, i) => i !== conditionIndex))
2278
+ const conditionRow = (condition, conditionIndex) => {
2279
+ const condPath = `${pathPrefix}conditions.${conditionIndex}`
2280
+ const patchCondition = (patch) => patchConditionAt(conditionIndex, patch)
2281
+ const textInput = (field, type, labelKey) => h('label', { key: field, className: 'ud-field' },
2282
+ h('span', { className: 'ud-field-label' }, t(labelKey)),
2283
+ h('input', {
2284
+ type, className: 'ud-input', value: condition[field] ?? '',
2285
+ onChange: (event) => patchCondition({ [field]: event.target.value }),
2286
+ }),
2287
+ errorTextOf(`${condPath}.${field}`))
2288
+ const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
2289
+ h('span', { className: 'ud-field-label' }, t(labelKey)),
2290
+ h('input', {
2291
+ type: 'number', className: 'ud-input', min: MONTH_DAY_MIN, max: MONTH_DAY_MAX, step: 1,
2292
+ value: condition[field] ?? '',
2293
+ onChange: (event) => patchCondition({ [field]: event.target.value }),
2294
+ }),
2295
+ errorTextOf(`${condPath}.${field}`))
2296
+ // 周几 pill 仅 weekdays 条件才构造:其余类型无 days 字段,提前求值会使渲染崩溃白屏
2297
+ const weekdaysPills = () => h('div', { className: 'ud-group', role: 'group', 'aria-label': t('condWeekdays') },
2298
+ Array.from({ length: WEEKDAY_COUNT }, (_, day) => {
2299
+ const active = (condition.days ?? []).includes(day)
2300
+ return h('button', {
2301
+ key: day, type: 'button',
2302
+ className: cx('ud-seg-item', active && 'ud-seg-item--on'),
2303
+ 'aria-pressed': active,
2304
+ onClick: () => patchCondition({
2305
+ days: active ? condition.days.filter((value) => value !== day) : [...(condition.days ?? []), day],
2306
+ }),
2307
+ }, t(`weekday.${day}`))
2308
+ }))
2309
+ const fields = {
2310
+ dailyWindow: [textInput('from', 'time', 'condFrom'), textInput('to', 'time', 'condTo')],
2311
+ weekdays: [weekdaysPills()],
2312
+ monthDays: [numberInput('from', 'condFrom'), numberInput('to', 'condTo')],
2313
+ dateRange: [textInput('from', 'date', 'condFrom'), textInput('to', 'date', 'condTo')],
2314
+ }
2315
+ return h('div', { key: conditionIndex, className: 'ud-cond' },
2316
+ h('select', {
2317
+ className: 'ud-input ud-cond-kind', value: condition.kind, 'aria-label': t('condKind'),
2318
+ onChange: (event) => patchCondition(defaultCondition(event.target.value)),
2319
+ }, CONDITION_KIND_OPTIONS.map((kind) => h('option', { key: kind, value: kind }, t(CONDITION_KIND_LABEL_KEYS[kind])))),
2320
+ h('div', { className: 'ud-cond-fields' }, ...(fields[condition.kind] ?? [])),
2321
+ errorTextOf(condPath),
2322
+ h('button', {
2323
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: () => removeConditionAt(conditionIndex),
2324
+ 'aria-label': t('deleteCondition'), title: t('deleteCondition'),
2325
+ }, '×'))
2326
+ }
1941
2327
  return h('div', { className: 'ud-rule' },
1942
2328
  h('div', { className: 'ud-rule-head' },
1943
2329
  h('label', { className: 'ud-field' },
@@ -1957,10 +2343,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1957
2343
  priceField('output', 'priceOutput'),
1958
2344
  priceField('cacheRead', 'priceCacheRead'),
1959
2345
  priceField('cacheWrite', 'priceCacheWrite')),
1960
- h('span', { className: 'ud-rule-cond' },
1961
- (rule.conditions ?? []).length === 0
1962
- ? t('noCondition')
1963
- : t('conditionsPreserved', { n: rule.conditions.length })))
2346
+ h('div', { className: 'ud-rule-conds' },
2347
+ (rule.conditions ?? []).length === 0 ? h('span', { className: 'ud-rule-cond' }, t('noCondition')) : null,
2348
+ (rule.conditions ?? []).map((condition, conditionIndex) => conditionRow(condition, conditionIndex)),
2349
+ h('div', { className: 'ud-cond-add' },
2350
+ CONDITION_KIND_OPTIONS.map((kind) => h('button', {
2351
+ key: kind, type: 'button', className: 'ud-btn ud-btn--text',
2352
+ onClick: () => patchConditions([...(rule.conditions ?? []), defaultCondition(kind)]),
2353
+ }, `+${t(CONDITION_KIND_LABEL_KEYS[kind])}`)))))
1964
2354
  }
1965
2355
 
1966
2356
  function PricingEditor({ t = defaultT }) {
@@ -2050,7 +2440,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2050
2440
  errors,
2051
2441
  pathPrefix: `${index}.`,
2052
2442
  t,
2053
- onPatch: (part) => setRules((prev) => updateRuleAt(prev, index, part)),
2443
+ onPatch: (part) => setRules((prev) => patchItemAt(prev, index, part)),
2054
2444
  onRemove: () => setRules((prev) => prev.filter((_, i) => i !== index)),
2055
2445
  })),
2056
2446
  h('button', {
@@ -2059,9 +2449,21 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2059
2449
  }, t('addRule')))
2060
2450
  }
2061
2451
 
2062
- function StatusLine({ status, t = defaultT }) {
2063
- if (!statusLineActive(status)) return null
2452
+ // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示
2453
+ function AnomalyChip({ open, onToggle, t = defaultT }) {
2454
+ return h('button', {
2455
+ type: 'button', className: 'ud-status-fold', 'aria-expanded': open,
2456
+ 'aria-label': t('anomalyLog'), title: t('anomalyLog'),
2457
+ onClick: onToggle,
2458
+ },
2459
+ h('span', { className: 'ud-status-fold-caret', 'aria-hidden': 'true' }))
2460
+ }
2461
+
2462
+ // 回扫进度与采集错误是运行状态,常显;异常明细走日志块
2463
+ function StatusRow({ status, t = defaultT }) {
2464
+ if (!status) return null
2064
2465
  const running = status.running === true
2466
+ if (!running && !status.error) return null
2065
2467
  const progress = running && status.total > 0
2066
2468
  ? Math.min(PROGRESS_FULL_PERCENT, (status.done / status.total) * PROGRESS_FULL_PERCENT)
2067
2469
  : 0
@@ -2072,14 +2474,26 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2072
2474
  h('span', { className: 'ud-status-track' },
2073
2475
  h('span', { className: 'ud-status-fill', style: { width: `${progress}%` } })))
2074
2476
  : null,
2075
- status.error ? h('span', { className: 'ud-status-err' }, status.error) : null,
2076
- // 无法读取是日志固有损伤,常态提示用中性色,不算错误
2077
- (status.skippedSessions ?? 0) > 0
2078
- ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2079
- : null,
2080
- (status.recordFailures ?? 0) > 0
2081
- ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
2082
- : null)
2477
+ status.error ? h('span', { className: 'ud-status-err' }, status.error) : null)
2478
+ }
2479
+
2480
+ // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容)
2481
+ function AnomalyLog({ status, t = defaultT }) {
2482
+ const lines = status.log ?? []
2483
+ if (lines.length === 0) return null
2484
+ return h('div', { className: 'ud-log' },
2485
+ h('div', { className: 'ud-log-summary' },
2486
+ (status.skippedSessions ?? 0) > 0
2487
+ ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2488
+ : null,
2489
+ (status.recordFailures ?? 0) > 0
2490
+ ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
2491
+ : null),
2492
+ lines.map((entry, index) => h('div', { className: 'ud-log-line', key: index },
2493
+ h('span', { className: 'ud-log-time' }, logTimeOf(entry.time)),
2494
+ h('span', { className: cx('ud-log-kind', entry.kind === 'record' && 'ud-log-err') },
2495
+ entry.kind === 'record' ? t('logKindRecord') : t('logKindSkipped')),
2496
+ h('span', { className: 'ud-log-detail', title: entry.detail }, entry.detail))))
2083
2497
  }
2084
2498
 
2085
2499
  function RebuildButton({ machineRef, busy, onError, t = defaultT }) {
@@ -2114,10 +2528,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2114
2528
  const trendLimitedText = (t, id, count) => (id === 'day'
2115
2529
  ? t('trendLimited', { n: count })
2116
2530
  : id === 'hour' ? t('trendLimitedHour', { n: count }) : t('trendLimitedMinute', { n: count }))
2117
- const presetLabel = (t, view, id) => (view === 'hour'
2118
- ? t('hourPreset', { n: parseInt(id, 10) })
2119
- : t('minutePreset', { n: parseInt(id, 10) }))
2531
+ const presetLabel = (t, view, id) => t(`${view === 'hour' ? 'hourPreset' : 'minutePreset'}.${id}`)
2120
2532
  const tickLabelFor = (view) => (view === 'day' ? shortDay : view === 'hour' ? hourTickLabel : minuteTickLabel)
2533
+ const slotLabelFor = (view) => (view === 'hour' ? hourSlotLabel : view === 'minute' ? minuteSlotLabel : (day) => day)
2121
2534
 
2122
2535
  function UsageDashPanel({ t = defaultT }) {
2123
2536
  const [view, setView] = useState('day')
@@ -2133,6 +2546,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2133
2546
  const [pointStatus, setPointStatus] = useState('idle')
2134
2547
  const [fetchTick, setFetchTick] = useState(0)
2135
2548
  const [status, setStatus] = useState(null)
2549
+ const [detailsOpen, setDetailsOpen] = useState(false)
2136
2550
  const statusMachineRef = useRef(null)
2137
2551
  const generationRef = useRef(0)
2138
2552
  const pointGenerationRef = useRef(0)
@@ -2198,7 +2612,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2198
2612
 
2199
2613
  useEffect(() => {
2200
2614
  if (view === 'day') return
2201
- if (pointStats && pointStats.preset === presetId) return
2615
+ if (pointStatsMatches(pointStats, view, presetId)) return
2202
2616
  const request = view === 'hour'
2203
2617
  ? resolveHourRange(presetId, new Date())
2204
2618
  : resolveMinuteRange(presetId, new Date())
@@ -2213,7 +2627,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2213
2627
  }
2214
2628
  setError('')
2215
2629
  setPointStatus('ok')
2216
- setPointStats({ preset: presetId, value: result.value })
2630
+ setPointStats({ view, preset: presetId, value: result.value })
2217
2631
  })
2218
2632
  }, [view, presetId, fetchTick])
2219
2633
 
@@ -2281,7 +2695,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2281
2695
  }, [])
2282
2696
 
2283
2697
  const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
2284
- const pointView = pointStats && pointStats.preset === presetId ? pointStats.value : null
2698
+ const pointView = pointStatsMatches(pointStats, view, presetId) ? pointStats.value : null
2285
2699
  const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
2286
2700
  const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
2287
2701
 
@@ -2301,55 +2715,67 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2301
2715
 
2302
2716
  return h('div', { className: 'ud-panel', ref: panelRef },
2303
2717
  h('div', { className: 'ud-toolbar' },
2304
- h('div', { className: 'ud-group', role: 'group', 'aria-label': t('viewGroup') },
2305
- VIEW_TABS.map((tab) => h('button', {
2306
- key: tab.id,
2307
- className: cx('ud-seg-item', view === tab.id && 'ud-seg-item--on'),
2308
- 'aria-pressed': view === tab.id,
2309
- onClick: () => setView(tab.id),
2310
- }, viewLabel(t, tab.id)))),
2311
- pointActive
2312
- ? h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2313
- (view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
2314
- key: id,
2315
- className: cx('ud-seg-item', presetId === id && 'ud-seg-item--on'),
2316
- 'aria-pressed': presetId === id,
2317
- onClick: () => (view === 'hour' ? setHourPreset(id) : setMinutePreset(id)),
2318
- }, presetLabel(t, view, id))))
2319
- : h(React.Fragment, null,
2320
- h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2321
- DAY_PRESETS.map((id) => h('button', {
2718
+ h('div', { className: 'ud-toolbar-main' },
2719
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('viewGroup') },
2720
+ VIEW_TABS.map((tab) => h('button', {
2721
+ key: tab.id,
2722
+ className: cx('ud-seg-item', view === tab.id && 'ud-seg-item--on'),
2723
+ 'aria-pressed': view === tab.id,
2724
+ onClick: () => setView(tab.id),
2725
+ }, viewLabel(t, tab.id)))),
2726
+ pointActive
2727
+ ? h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2728
+ (view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
2322
2729
  key: id,
2323
- className: cx('ud-seg-item', range === id && 'ud-seg-item--on'),
2324
- 'aria-pressed': range === id,
2325
- onClick: () => setRange(id),
2326
- }, t(`rangePreset.${id}`))),
2327
- h('button', {
2328
- className: cx('ud-seg-item', range === 'custom' && 'ud-seg-item--on'),
2329
- 'aria-pressed': range === 'custom',
2330
- onClick: () => setRange('custom'),
2331
- }, t('rangeCustom'))),
2332
- range === 'custom'
2333
- ? h('div', { className: 'ud-custom-range' },
2334
- h('input', {
2335
- type: 'date', className: 'ud-date-input', 'aria-label': t('from'),
2336
- value: customFrom, max: customTo || undefined,
2337
- onChange: (event) => setCustomFrom(event.target.value),
2338
- }),
2339
- h('span', { className: 'ud-custom-sep' }, ''),
2340
- h('input', {
2341
- type: 'date', className: 'ud-date-input', 'aria-label': t('to'),
2342
- value: customTo, min: customFrom || undefined, max: dayBucket(new Date()),
2343
- onChange: (event) => setCustomTo(event.target.value),
2344
- }))
2345
- : null),
2346
- h(StatusLine, { status, t }),
2347
- h('button', { className: 'ud-btn ud-btn--text ud-refresh', disabled: busy, onClick: refresh }, t('refresh')),
2348
- h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t })),
2730
+ className: cx('ud-seg-item', presetId === id && 'ud-seg-item--on'),
2731
+ 'aria-pressed': presetId === id,
2732
+ onClick: () => (view === 'hour' ? setHourPreset(id) : setMinutePreset(id)),
2733
+ }, presetLabel(t, view, id))))
2734
+ : h(React.Fragment, null,
2735
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2736
+ DAY_PRESETS.map((id) => h('button', {
2737
+ key: id,
2738
+ className: cx('ud-seg-item', range === id && 'ud-seg-item--on'),
2739
+ 'aria-pressed': range === id,
2740
+ onClick: () => setRange(id),
2741
+ }, t(`rangePreset.${id}`))),
2742
+ h('button', {
2743
+ className: cx('ud-seg-item', range === 'custom' && 'ud-seg-item--on'),
2744
+ 'aria-pressed': range === 'custom',
2745
+ onClick: () => setRange('custom'),
2746
+ }, t('rangeCustom'))),
2747
+ range === 'custom'
2748
+ ? h('div', { className: 'ud-custom-range' },
2749
+ h('input', {
2750
+ type: 'date', className: 'ud-date-input', 'aria-label': t('from'),
2751
+ value: customFrom, max: customTo || undefined,
2752
+ onChange: (event) => setCustomFrom(event.target.value),
2753
+ }),
2754
+ h('span', { className: 'ud-custom-sep' }, ''),
2755
+ h('input', {
2756
+ type: 'date', className: 'ud-date-input', 'aria-label': t('to'),
2757
+ value: customTo, min: customFrom || undefined, max: dayBucket(new Date()),
2758
+ onChange: (event) => setCustomTo(event.target.value),
2759
+ }))
2760
+ : null)),
2761
+ h('div', { className: 'ud-toolbar-side' },
2762
+ h(AnomalyChip, { open: detailsOpen, onToggle: () => setDetailsOpen((v) => !v), t }),
2763
+ h('button', {
2764
+ className: 'ud-btn ud-btn--text ud-icon-btn', 'aria-label': t('refresh'), title: t('refresh'),
2765
+ disabled: busy, onClick: refresh,
2766
+ }, h('span', { className: 'ud-icon', dangerouslySetInnerHTML: { __html: REFRESH_ICON_SVG } })))),
2767
+ h(StatusRow, { status, t }),
2768
+ detailsOpen
2769
+ ? h('div', { className: 'ud-detail' },
2770
+ h(AnomalyLog, { status, t }),
2771
+ h('div', { className: 'ud-detail-foot' },
2772
+ h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t })))
2773
+ : null,
2349
2774
  error ? h('div', { className: 'ud-error' }, error) : null,
2350
2775
  loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
2351
2776
  stats ? h(StatCards, { key: 'cards', stats, costCurrency, t }) : null,
2352
- h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }),
2777
+ // 活跃热力图仅按天视图展示:热力图口径为日桶,时/分视图无对应语义
2778
+ view === 'day' ? h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }) : null,
2353
2779
  trimmedSlots
2354
2780
  ? h(TrendChart, {
2355
2781
  key: 'trend',
@@ -2359,6 +2785,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2359
2785
  modelOrder: trendSource.models.map((item) => item.model),
2360
2786
  colorFor,
2361
2787
  labelFor: tickLabelFor(view),
2788
+ slotLabelFor: slotLabelFor(view),
2362
2789
  labelMinPitch: pointActive ? LABEL_PITCH_TIME : LABEL_PITCH_DAY,
2363
2790
  busy,
2364
2791
  legendModels: trendSource.models,
@@ -2413,4 +2840,5 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2413
2840
  },
2414
2841
  }
2415
2842
  }
2416
- }
2843
+ }
2844
+ })()