@mzzsfy/dsh-usage-dash 0.1.0 → 0.2.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,13 +1,15 @@
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'
@@ -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: '会话数量',
@@ -133,22 +139,31 @@ const MESSAGES_ZH = {
133
139
  percent: '占比',
134
140
  asOf: '统计截至',
135
141
  empty: '当前时间范围内暂无用量数据。Token 用量从本面板启用后开始累计,并会一次性回扫已有的历史会话。',
136
- viewDay: '按天',
137
- viewHour: '按小时',
138
- viewMinute: '按分钟',
142
+ viewDay: '',
143
+ viewHour: '小时',
144
+ viewMinute: '分钟',
139
145
  viewGroup: '统计粒度',
140
146
  'status.running': '回扫中 {done}/{total}',
141
147
  rebuild: '重建',
142
148
  rebuildConfirm: '确认重建',
143
149
  hourTrend: '按小时 Token 趋势',
144
150
  minuteTrend: '按分钟 Token 趋势',
145
- hourPreset: '最近 {n} 小时',
146
- minutePreset: '最近 {n} 分钟',
151
+ 'hourPreset.24h': '24 小时',
152
+ 'hourPreset.3d': '3 ',
153
+ 'hourPreset.7d': '7 天',
154
+ 'hourPreset.15d': '15 天',
155
+ 'minutePreset.3h': '3 小时',
156
+ 'minutePreset.24h': '24 小时',
157
+ 'minutePreset.3d': '3 天',
158
+ 'minutePreset.7d': '7 天',
147
159
  trendLimitedHour: '数据量过大,仅显示最近 {n} 小时',
148
160
  trendLimitedMinute: '数据量过大,仅显示最近 {n} 分钟',
149
161
  trendTruncated: '数据量过大,仅显示最近部分',
150
162
  recordFailures: '{n} 条记录写入失败',
151
163
  skippedSessions: '跳过 {n} 个无法读取的会话',
164
+ anomalyLog: '扫描异常日志',
165
+ logKindSkipped: '跳过会话',
166
+ logKindRecord: '写入失败',
152
167
  'stats.counts': '{turns} 轮 · {steps} 步',
153
168
  'stats.llm': 'LLM {duration}',
154
169
  'stats.toolCall': '工具调用 {duration}',
@@ -173,7 +188,7 @@ const MESSAGES_ZH = {
173
188
  pricing: '定价规则',
174
189
  pricingUnavailable: '定价规则不可用',
175
190
  pricingModel: '模型',
176
- pricingModelPlaceholder: 'provider/model 或 *',
191
+ pricingModelPlaceholder: 'provider/model,段可通配如 */*',
177
192
  pricingCurrency: '货币',
178
193
  pricingUnit: '每百万 token 定价',
179
194
  priceInput: '输入',
@@ -181,13 +196,34 @@ const MESSAGES_ZH = {
181
196
  priceCacheRead: '缓存读',
182
197
  priceCacheWrite: '缓存写',
183
198
  noCondition: '无条件 = 恒生效',
184
- conditionsPreserved: '已有 {n} 条条件,本编辑器暂不支持修改,保存时原样保留',
199
+ addCondition: '添加条件',
200
+ deleteCondition: '删除条件',
201
+ condKind: '条件类型',
202
+ condDailyWindow: '每日时段',
203
+ condWeekdays: '星期几',
204
+ condMonthDays: '每月号段',
205
+ condDateRange: '日期段',
206
+ condFrom: '从',
207
+ condTo: '至',
208
+ 'weekday.0': '日',
209
+ 'weekday.1': '一',
210
+ 'weekday.2': '二',
211
+ 'weekday.3': '三',
212
+ 'weekday.4': '四',
213
+ 'weekday.5': '五',
214
+ 'weekday.6': '六',
215
+ condTime: '需 HH:MM',
216
+ condWeekday: '需 0-6 整数',
217
+ condMonthDay: '需 1-31 整数',
218
+ condDate: '需 YYYY-MM-DD',
219
+ condRange: '起始不得晚于结束',
185
220
  deleteRule: '删除规则',
186
221
  addRule: '添加规则',
187
222
  save: '保存',
188
223
  saved: '已保存',
189
224
  required: '必填',
190
225
  priceInvalid: '不能为负',
226
+ modelFormat: '需两段 provider/model,段可通配',
191
227
  'duration.compactSeconds': '{seconds}秒',
192
228
  'duration.compactMinutes': '{minutes}分{seconds}秒',
193
229
  'number.thousand': '{value}K',
@@ -198,15 +234,14 @@ const MESSAGES_ZH = {
198
234
  const MESSAGES_EN = {
199
235
  nav: 'Usage',
200
236
  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',
237
+ 'rangePreset.7': '7 days',
238
+ 'rangePreset.30': '30 days',
239
+ 'rangePreset.90': '90 days',
205
240
  rangeCustom: 'Custom',
206
241
  from: 'From',
207
242
  to: 'To',
208
243
  refresh: 'Refresh',
209
- loading: 'Loading',
244
+ 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
245
  tokens: 'Token usage',
211
246
  tokensHint: 'Provider total: uncached input + output + cache-read tokens',
212
247
  sessions: 'Sessions',
@@ -229,22 +264,31 @@ const MESSAGES_EN = {
229
264
  percent: 'Share',
230
265
  asOf: 'Stats as of',
231
266
  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',
267
+ viewDay: 'Day',
268
+ viewHour: 'Hour',
269
+ viewMinute: 'Minute',
235
270
  viewGroup: 'Granularity',
236
271
  'status.running': 'Rescanning {done}/{total}',
237
272
  rebuild: 'Rebuild',
238
273
  rebuildConfirm: 'Confirm rebuild',
239
274
  hourTrend: 'Hourly token trend',
240
275
  minuteTrend: 'Per-minute token trend',
241
- hourPreset: 'Last {n} hours',
242
- minutePreset: 'Last {n} minutes',
276
+ 'hourPreset.24h': '24 hours',
277
+ 'hourPreset.3d': '3 days',
278
+ 'hourPreset.7d': '7 days',
279
+ 'hourPreset.15d': '15 days',
280
+ 'minutePreset.3h': '3 hours',
281
+ 'minutePreset.24h': '24 hours',
282
+ 'minutePreset.3d': '3 days',
283
+ 'minutePreset.7d': '7 days',
243
284
  trendLimitedHour: 'Too much data, showing only the last {n} hours',
244
285
  trendLimitedMinute: 'Too much data, showing only the last {n} minutes',
245
286
  trendTruncated: 'Too much data, showing only the latest part',
246
287
  recordFailures: '{n} records failed to write',
247
288
  skippedSessions: '{n} unreadable sessions skipped',
289
+ anomalyLog: 'Scan anomaly log',
290
+ logKindSkipped: 'skipped',
291
+ logKindRecord: 'write failed',
248
292
  'stats.counts': '{turns} turns · {steps} steps',
249
293
  'stats.llm': 'LLM {duration}',
250
294
  'stats.toolCall': 'Tool call {duration}',
@@ -269,7 +313,7 @@ const MESSAGES_EN = {
269
313
  pricing: 'Pricing rules',
270
314
  pricingUnavailable: 'Pricing rules unavailable',
271
315
  pricingModel: 'Model',
272
- pricingModelPlaceholder: 'provider/model or *',
316
+ pricingModelPlaceholder: 'provider/model, segments may be */*',
273
317
  pricingCurrency: 'Currency',
274
318
  pricingUnit: 'per million tokens pricing',
275
319
  priceInput: 'Input',
@@ -277,13 +321,34 @@ const MESSAGES_EN = {
277
321
  priceCacheRead: 'Cache read',
278
322
  priceCacheWrite: 'Cache write',
279
323
  noCondition: 'No condition = always applies',
280
- conditionsPreserved: '{n} existing conditions are kept as-is; editing them is not supported yet',
324
+ addCondition: 'Add condition',
325
+ deleteCondition: 'Remove condition',
326
+ condKind: 'Condition kind',
327
+ condDailyWindow: 'Daily window',
328
+ condWeekdays: 'Weekdays',
329
+ condMonthDays: 'Month days',
330
+ condDateRange: 'Date range',
331
+ condFrom: 'From',
332
+ condTo: 'To',
333
+ 'weekday.0': 'Su',
334
+ 'weekday.1': 'Mo',
335
+ 'weekday.2': 'Tu',
336
+ 'weekday.3': 'We',
337
+ 'weekday.4': 'Th',
338
+ 'weekday.5': 'Fr',
339
+ 'weekday.6': 'Sa',
340
+ condTime: 'Requires HH:MM',
341
+ condWeekday: 'Requires integer 0-6',
342
+ condMonthDay: 'Requires integer 1-31',
343
+ condDate: 'Requires YYYY-MM-DD',
344
+ condRange: 'From must not be after to',
281
345
  deleteRule: 'Remove rule',
282
346
  addRule: 'Add rule',
283
347
  save: 'Save',
284
348
  saved: 'Saved',
285
349
  required: 'Required',
286
350
  priceInvalid: 'Must not be negative',
351
+ modelFormat: 'Requires two segments provider/model; segments may be wildcards',
287
352
  'duration.compactSeconds': '{seconds}s',
288
353
  'duration.compactMinutes': '{minutes}m{seconds}s',
289
354
  'number.thousand': '{value}K',
@@ -327,14 +392,12 @@ function cacheRateText(hit, miss) {
327
392
  return rate === null ? '—' : formatPercent(rate)
328
393
  }
329
394
 
330
- const REF_SPLIT_LIMIT = 2
395
+ // 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
331
396
  function modelNameOf(ref) {
332
- const parts = ref.split('/')
333
- return parts.length < REF_SPLIT_LIMIT ? ref : parts.slice(1).join('/')
397
+ return splitRequestSegments(ref)[1]
334
398
  }
335
399
  function providerOf(ref) {
336
- const parts = ref.split('/')
337
- return parts.length < REF_SPLIT_LIMIT ? 'default' : parts[0]
400
+ return splitRequestSegments(ref)[0]
338
401
  }
339
402
 
340
403
  function shortDay(day) {
@@ -358,13 +421,11 @@ function isEmptyRange(value) {
358
421
  return value.tokens === 0 && value.cacheHit === 0 && value.requests === 0 && value.turns === 0
359
422
  }
360
423
 
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
424
+ // 日志条目时刻仅显示当日时分秒
425
+ function logTimeOf(ms) {
426
+ const date = new Date(ms)
427
+ const part = (value) => String(value).padStart(2, '0')
428
+ return `${part(date.getHours())}:${part(date.getMinutes())}:${part(date.getSeconds())}`
368
429
  }
369
430
 
370
431
  const toRankedModels = (totals) =>
@@ -552,6 +613,12 @@ function modelSegmentLabel(name, tokens, percent) {
552
613
  return `${name}: ${formatTokens(tokens)} (${formatPercent(percent)})`
553
614
  }
554
615
 
616
+ // 模型平均生成速度文本:官方吞吐口径格式化 + 单位(语言中立);无速度(无时长数据)为空串
617
+ function modelSpeedText(speed) {
618
+ if (speed === undefined) return ''
619
+ return `${formatTokensPerSecond(speed)} tok/s`
620
+ }
621
+
555
622
  // 热力图:窗口固定 26 周,与所选范围无关
556
623
  const HEAT_WEEKS = 26
557
624
  const HEAT_ROW_COUNT = 7
@@ -892,6 +959,11 @@ const CONDITION_KINDS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
892
959
  const TOKENS_PER_MILLION = 1000 * 1000
893
960
 
894
961
  const MODEL_WILDCARD = '*'
962
+ const SEGMENT_SEPARATOR = '/'
963
+ const PROVIDER_UNSET = 'default'
964
+ // 档位权重:vendor 段通配 1 档、model 段通配 2 档,和越小越优先(模型名精确档恒优于供应商精确档)
965
+ const VENDOR_WILDCARD_TIER = 1
966
+ const MODEL_WILDCARD_TIER = 2
895
967
  const MINUTES_PER_HOUR = 60
896
968
 
897
969
  const minutesOfDay = (date) => date.getHours() * MINUTES_PER_HOUR + date.getMinutes()
@@ -961,12 +1033,31 @@ function isRuleShaped(rule) {
961
1033
  && Array.isArray(rule.conditions)
962
1034
  }
963
1035
 
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
1036
+ // 规则模型键必须两段式:首个 / 前 vendor 段、后模型段(允许含 /),首位斜杠或无斜杠均非法
1037
+ function splitRuleSegments(pattern) {
1038
+ const slash = pattern.indexOf(SEGMENT_SEPARATOR)
1039
+ return slash > 0 ? [pattern.slice(0, slash), pattern.slice(slash + SEGMENT_SEPARATOR.length)] : null
1040
+ }
1041
+
1042
+ // 段须非空且不含空白:含空白的模型键永不匹配真实请求
1043
+ function isTwoSegmentModel(pattern) {
1044
+ const segments = splitRuleSegments(pattern)
1045
+ return segments !== null && segments.every((segment) => /^\S+$/.test(segment))
1046
+ }
1047
+
1048
+ // 请求侧模型键按同构规则分段,无 / 或首位斜杠时 vendor 段缺省归 default(与存储行口径一致)
1049
+ function splitRequestSegments(model) {
1050
+ return splitRuleSegments(model) ?? [PROVIDER_UNSET, model]
1051
+ }
1052
+
1053
+ // 段级比对:规则段为通配或与请求段相等;两段全过才成立,通配段按各自档位计权
1054
+ function matchTier(ruleSegments, requestSegments) {
1055
+ const [ruleVendor, ruleModel] = ruleSegments
1056
+ const [requestVendor, requestModel] = requestSegments
1057
+ if (ruleVendor !== MODEL_WILDCARD && ruleVendor !== requestVendor) return null
1058
+ if (ruleModel !== MODEL_WILDCARD && ruleModel !== requestModel) return null
1059
+ return (ruleVendor === MODEL_WILDCARD ? VENDOR_WILDCARD_TIER : 0)
1060
+ + (ruleModel === MODEL_WILDCARD ? MODEL_WILDCARD_TIER : 0)
970
1061
  }
971
1062
 
972
1063
  const toLocalDate = (timestamp) => {
@@ -974,12 +1065,25 @@ const toLocalDate = (timestamp) => {
974
1065
  return Number.isNaN(date.getTime()) ? null : date
975
1066
  }
976
1067
 
977
- // 精确子集按数组序取首个命中;无精确子集或全不命中回落 '*' 子集;仍无命中为 null
1068
+ // 匹配链:全名 > 模型名(vendor 通配)> 供应商(model 通配)> '*/*' 全通;
1069
+ // 档位最小者胜,同档按数组序取首个;高档条件不满足自然落低档;无命中为 null(调用方计 unpriced)
978
1070
  function matchPrice(rules, model, timestamp) {
979
1071
  const date = toLocalDate(timestamp)
980
1072
  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)
1073
+ const requestSegments = splitRequestSegments(model)
1074
+ let bestTier = Infinity
1075
+ let bestPrice = null
1076
+ for (const rule of rules) {
1077
+ if (!isRuleShaped(rule)) continue
1078
+ const ruleSegments = splitRuleSegments(rule.model)
1079
+ if (!ruleSegments) continue
1080
+ const tier = matchTier(ruleSegments, requestSegments)
1081
+ if (tier === null || tier >= bestTier) continue
1082
+ if (!rule.conditions.every((condition) => conditionMatches(condition, date))) continue
1083
+ bestTier = tier
1084
+ bestPrice = rule.price
1085
+ }
1086
+ return bestPrice
983
1087
  }
984
1088
 
985
1089
  const BUCKET_PRICE_KEYS = [
@@ -1030,11 +1134,14 @@ const pricingBucketsOf = (usage) => ({
1030
1134
  cacheWriteTokens: usage.cacheWriteTokens,
1031
1135
  })
1032
1136
 
1137
+ // routes 缺席占位:双段通配模型键,仅被 '*/*' 全通配规则命中(供应商与模型全未知)
1138
+ const MODEL_UNROUTED = '*/*'
1139
+
1033
1140
  // 注入点A 费用组装配:开关关/无用量/价格未加载不渲染;规则已载无命中价显示占位符;
1034
- // routes 缺席时 model 取通配,只匹配通配规则;时间条件按当前时刻评估(估算口径)
1141
+ // routes 缺席时 model 取全通配键;时间条件按当前时刻评估(估算口径)
1035
1142
  function buildCostItem(usage, rules, prefs, t, now = new Date()) {
1036
1143
  if (!prefs?.costDisplay || !usage || !Array.isArray(rules)) return null
1037
- const model = usage.routes?.[0]?.model ?? MODEL_WILDCARD
1144
+ const model = usage.routes?.[0]?.model ?? MODEL_UNROUTED
1038
1145
  const price = matchPrice(rules, model, now)
1039
1146
  if (!price) return COST_PLACEHOLDER
1040
1147
  return t('stats.cost', { cost: formatCost(costOf(price, pricingBucketsOf(usage)), aggregateCurrencyOf(rules)) })
@@ -1060,8 +1167,8 @@ function selectTurnTokenUsage(owner) {
1060
1167
  : null
1061
1168
  }
1062
1169
 
1063
- // 计价模型键:routes 首个 route.model,缺席回退通配(与注入点A 同款)
1064
- const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_WILDCARD
1170
+ // 计价模型键:routes 首个 route.model,缺席回退全通配键(与注入点A 同款)
1171
+ const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ?? MODEL_UNROUTED
1065
1172
 
1066
1173
  // 可选桶(cacheRead/cacheWrite)仅部分 provider 上报,缺失按 0 计入摘要与费用
1067
1174
  const turnReportedBucket = (value) => (value ?? 0)
@@ -1097,28 +1204,109 @@ function turnCostTitleText(t, tokenUsage) {
1097
1204
  // ===== 定价编辑器纯函数(校验/规整/默认值) =====
1098
1205
  const PRICE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite']
1099
1206
  const HHMM_PATTERN = /^\d{1,2}:\d{2}$/
1207
+ const ISO_DAY_PATTERN_CLIENT = /^\d{4}-\d{2}-\d{2}$/
1208
+ // 时刻分量界:小时含头不含尾,分钟双闭
1209
+ const HOUR_MAX = 24
1210
+ const MINUTE_MAX = 59
1211
+ const WEEKDAY_MIN = 0
1212
+ const WEEKDAY_MAX = 6
1213
+ const MONTH_DAY_MIN = 1
1214
+ const MONTH_DAY_MAX = 31
1215
+ const CONDITION_KIND_OPTIONS = ['dailyWindow', 'weekdays', 'monthDays', 'dateRange']
1216
+ const CONDITION_KIND_LABEL_KEYS = {
1217
+ dailyWindow: 'condDailyWindow',
1218
+ weekdays: 'condWeekdays',
1219
+ monthDays: 'condMonthDays',
1220
+ dateRange: 'condDateRange',
1221
+ }
1222
+ const WEEKDAY_COUNT = 7
1223
+
1224
+ const parseHHMM = (value) => {
1225
+ if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) return null
1226
+ const [hours, minutes] = value.split(':').map(Number)
1227
+ return hours >= 0 && hours < HOUR_MAX && minutes >= 0 && minutes <= MINUTE_MAX ? value : null
1228
+ }
1229
+
1230
+ const isValidMonthDay = (value) => Number.isInteger(value) && value >= MONTH_DAY_MIN && value <= MONTH_DAY_MAX
1231
+
1232
+ const isValidWeekday = (value) => Number.isInteger(value) && value >= WEEKDAY_MIN && value <= WEEKDAY_MAX
1233
+
1234
+ // 各条件类型合法默认:时段全天(from===to)、周几空、号段全月(短月高位号自然不触发)、日期段当天
1235
+ const defaultCondition = (kind, now = new Date()) => {
1236
+ const today = formatDate(now)
1237
+ const defaults = {
1238
+ dailyWindow: { kind: 'dailyWindow', from: '00:00', to: '00:00' },
1239
+ weekdays: { kind: 'weekdays', days: [] },
1240
+ monthDays: { kind: 'monthDays', from: MONTH_DAY_MIN, to: MONTH_DAY_MAX },
1241
+ dateRange: { kind: 'dateRange', from: today, to: today },
1242
+ }
1243
+ return defaults[kind] ? { ...defaults[kind] } : null
1244
+ }
1100
1245
 
1101
- // 就地校验:字段路径文案键;仅覆盖编辑器可编辑字段(模型与四桶价格)
1246
+ // 条件字段级校验:路径前缀 + 键 错误键;倒序仅 dateRange 非法(时段/号段倒序为跨午夜/跨月语义)
1247
+ const validateCondition = (condition, path, errors) => {
1248
+ if (!condition || typeof condition !== 'object') {
1249
+ errors.set(path, 'required')
1250
+ return
1251
+ }
1252
+ if (condition.kind === 'dailyWindow') {
1253
+ if (parseHHMM(condition.from) === null) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condTime')
1254
+ if (parseHHMM(condition.to) === null) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condTime')
1255
+ return
1256
+ }
1257
+ if (condition.kind === 'weekdays') {
1258
+ if (!Array.isArray(condition.days) || !condition.days.every(isValidWeekday)) errors.set(`${path}.days`, 'condWeekday')
1259
+ return
1260
+ }
1261
+ if (condition.kind === 'monthDays') {
1262
+ if (!isValidMonthDay(condition.from)) errors.set(`${path}.from`, 'condMonthDay')
1263
+ if (!isValidMonthDay(condition.to)) errors.set(`${path}.to`, 'condMonthDay')
1264
+ return
1265
+ }
1266
+ if (condition.kind === 'dateRange') {
1267
+ if (typeof condition.from !== 'string' || !ISO_DAY_PATTERN_CLIENT.test(condition.from)) errors.set(`${path}.from`, condition.from === '' || condition.from == null ? 'required' : 'condDate')
1268
+ if (typeof condition.to !== 'string' || !ISO_DAY_PATTERN_CLIENT.test(condition.to)) errors.set(`${path}.to`, condition.to === '' || condition.to == null ? 'required' : 'condDate')
1269
+ if (errors.has(`${path}.from`) || errors.has(`${path}.to`)) return
1270
+ if (condition.from > condition.to) errors.set(path, 'condRange')
1271
+ }
1272
+ }
1273
+
1274
+ // POST 前条件规整:输入框字符串值转数字字段;时段/日期段为字符串原样
1275
+ function coerceConditions(conditions) {
1276
+ return conditions.map((condition) => {
1277
+ if (condition.kind === 'weekdays') return { ...condition, days: condition.days.map(Number) }
1278
+ if (condition.kind === 'monthDays') return { ...condition, from: Number(condition.from), to: Number(condition.to) }
1279
+ return condition
1280
+ })
1281
+ }
1282
+
1283
+ // 就地校验:字段路径 → 文案键;仅覆盖编辑器可编辑字段(模型/四桶价格/时间条件);模型须两段式
1102
1284
  function validatePricingRules(rules) {
1103
1285
  const errors = new Map()
1104
1286
  if (!Array.isArray(rules)) return errors
1105
1287
  rules.forEach((rule, ruleIndex) => {
1106
1288
  if (typeof rule.model !== 'string' || rule.model.trim().length === 0) errors.set(`${ruleIndex}.model`, 'required')
1289
+ else if (!isTwoSegmentModel(rule.model)) errors.set(`${ruleIndex}.model`, 'modelFormat')
1107
1290
  PRICE_KEYS.forEach((key) => {
1108
1291
  const value = rule.price?.[key]
1109
1292
  const path = `${ruleIndex}.price.${key}`
1110
1293
  if (value === '' || value === null || value === undefined) errors.set(path, 'required')
1111
1294
  else if (!Number.isFinite(Number(value)) || Number(value) < 0) errors.set(path, 'priceInvalid')
1112
1295
  })
1296
+ const conditions = Array.isArray(rule.conditions) ? rule.conditions : []
1297
+ conditions.forEach((condition, conditionIndex) => {
1298
+ validateCondition(condition, `${ruleIndex}.conditions.${conditionIndex}`, errors)
1299
+ })
1113
1300
  })
1114
1301
  return errors
1115
1302
  }
1116
1303
 
1117
- // POST 前规整:输入框字符串值转数值;模型原样(校验已确保非空)
1304
+ // POST 前规整:输入框字符串值转数值;模型已校验确保两段式且无空白,原样提交
1118
1305
  function coercePricingRules(rules) {
1119
1306
  return rules.map((rule) => ({
1120
1307
  ...rule,
1121
1308
  price: PRICE_KEYS.reduce((price, key) => ({ ...price, [key]: Number(rule.price[key]) }), {}),
1309
+ conditions: coerceConditions(rule.conditions ?? []),
1122
1310
  }))
1123
1311
  }
1124
1312
 
@@ -1136,7 +1324,7 @@ const defaultPricingRule = (currency = CURRENCIES[0]) => ({
1136
1324
  conditions: [],
1137
1325
  })
1138
1326
 
1139
- const updateRuleAt = (rules, index, patch) => rules.map((rule, i) => (i === index ? { ...rule, ...patch } : rule))
1327
+ const patchItemAt = (array, index, patch) => array.map((item, i) => (i === index ? { ...item, ...patch } : item))
1140
1328
 
1141
1329
 
1142
1330
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
@@ -1205,6 +1393,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1205
1393
  const STATUS_POLL_FAST_MS = 1000
1206
1394
  const STATUS_POLL_SLOW_MS = 5000
1207
1395
  const REBUILD_CONFIRM_MS = 3000
1396
+ // Material Symbols 风格刷新图标路径,currentColor 继承按钮色
1397
+ 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
1398
  const STATUS_REFRESH_DEBOUNCE_MS = 800
1209
1399
  const FIT_MAX_SIZE = 22
1210
1400
  const FIT_MIN_SIZE = 11
@@ -1286,7 +1476,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1286
1476
  --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}
1287
1477
  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
1478
  --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}
1479
+ .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1480
+ .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1290
1481
  .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
1482
  .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
1483
  .ud-seg-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
@@ -1297,16 +1488,32 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1297
1488
  .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
1489
  .ud-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1299
1490
  .ud-btn:disabled{opacity:.5;cursor:default}
1300
- .ud-refresh{margin-left:auto}
1491
+ .ud-toolbar-side{display:flex;align-items:center;gap:8px;flex:none;margin-left:auto;align-self:stretch}
1492
+ .ud-toolbar-side .ud-btn--text{display:inline-flex;align-items:center;height:100%}
1493
+ .ud-icon-btn{padding:0 12px}
1494
+ .ud-icon{display:inline-flex;align-items:center;justify-content:center}
1301
1495
  .ud-btn--text{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);padding:2px 4px}
1302
1496
  .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
1497
  .ud-loading{color:var(--dsw-alias-label-tertiary);text-align:center;padding:32px 0}
1304
1498
  .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
1499
  .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)}
1500
+ .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)}
1501
+ .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}
1502
+ .ud-status-fold:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
1503
+ .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}
1504
+ .ud-status-fold[aria-expanded="true"] .ud-status-fold-caret{transform:rotate(180deg)}
1307
1505
  .ud-status-track{display:inline-block;width:120px;height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden}
1308
1506
  .ud-status-fill{display:block;height:100%;background:var(--dsw-alias-state-business-primary)}
1309
1507
  .ud-status-err{color:var(--dsw-alias-state-error-primary)}
1508
+ .ud-detail{width:100%;margin-top:4px;display:flex;flex-direction:column;gap:6px}
1509
+ .ud-detail-foot{display:flex;justify-content:flex-end;align-items:center}
1510
+ .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}
1511
+ .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)}
1512
+ .ud-log-line{display:flex;gap:8px;align-items:baseline;min-width:0}
1513
+ .ud-log-time{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);opacity:.7}
1514
+ .ud-log-kind{flex:none}
1515
+ .ud-log-err{color:var(--dsw-alias-state-error-primary)}
1516
+ .ud-log-detail{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1310
1517
  .ud-cards{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:10px}
1311
1518
  @media (max-width:560px){.ud-cards{grid-template-columns:1fr 1fr}}
1312
1519
  @media (max-width:380px){.ud-cards{grid-template-columns:1fr}}
@@ -1396,6 +1603,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1396
1603
  .ud-rule-head{display:flex;align-items:flex-end;gap:8px}
1397
1604
  .ud-rule-head .ud-field{flex:1}
1398
1605
  .ud-rule-cond{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1606
+ .ud-rule-conds{display:flex;flex-direction:column;gap:6px}
1607
+ .ud-cond{display:flex;align-items:flex-start;gap:6px;flex-wrap:wrap}
1608
+ .ud-cond-kind{width:auto;min-width:88px}
1609
+ .ud-cond-fields{display:flex;align-items:flex-end;gap:6px;flex-wrap:wrap;flex:1;min-width:0}
1610
+ .ud-cond-fields .ud-field{flex:0 1 auto}
1611
+ .ud-cond-fields .ud-input{width:auto}
1612
+ .ud-cond-add{display:flex;gap:6px;flex-wrap:wrap}
1399
1613
  .ud-field{display:flex;flex-direction:column;gap:3px;min-width:0}
1400
1614
  .ud-field-label{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1401
1615
  .ud-field-error{font-size:11px;color:var(--dsw-alias-state-error-primary)}
@@ -1747,6 +1961,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1747
1961
  h('div', { className: 'ud-models' },
1748
1962
  models.map((item) => {
1749
1963
  const isOther = item.model === OTHER_MODEL
1964
+ const speedText = modelSpeedText(item.speed)
1750
1965
  return h(React.Fragment, { key: item.model },
1751
1966
  h('div', {
1752
1967
  className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
@@ -1771,7 +1986,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1771
1986
  h('span', { className: 'ud-model-tokens' }, formatTokens(item.tokens)),
1772
1987
  h('span', { className: 'ud-model-pct' },
1773
1988
  item.cost !== undefined ? h('span', { className: 'ud-model-cost' }, `≈ ${formatCost(item.cost, costCurrency)} · `) : null,
1774
- formatPercent((item.tokens / total) * PERCENT_SCALE)))),
1989
+ formatPercent((item.tokens / total) * PERCENT_SCALE),
1990
+ speedText ? ` · ${speedText}` : null))),
1775
1991
  isOther
1776
1992
  ? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
1777
1993
  h('div', { className: 'ud-model-other-list' },
@@ -1919,10 +2135,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1919
2135
  }
1920
2136
 
1921
2137
  // 定价规则编辑器:货币为编辑器级全局设置(标题右侧切换,整表统一,不逐模型设置);
1922
- // 条件行不支持编辑,已有条件整条保留原样,空条件即恒生效;打开面板时经 fetchPricing 初始化,未保存离开即弃
2138
+ // 每规则可挂多条时间条件(时段/周几/号段/日期段),全部条件命中才生效;打开面板时经 fetchPricing 初始化,未保存离开即弃
1923
2139
  const PRICING_STATE_READY = 'ready'
1924
2140
  const PRICING_STATE_UNAVAILABLE = 'unavailable'
1925
2141
 
2142
+ // 条件行:类型下拉 + 按类型字段区 + 删除;类型切换重置为该类型默认值(字段结构互不相通)
1926
2143
  function PricingRuleCard({ rule, currency, errors, pathPrefix, t, onPatch, onRemove }) {
1927
2144
  const errorTextOf = (path) => {
1928
2145
  const key = errors.get(path)
@@ -1938,6 +2155,58 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1938
2155
  onChange: (event) => onPatch({ price: { ...rule.price, [key]: event.target.value } }),
1939
2156
  })),
1940
2157
  errorTextOf(`${pathPrefix}price.${key}`))
2158
+ const patchConditions = (conditions) => onPatch({ conditions })
2159
+ const patchConditionAt = (conditionIndex, patch) => patchConditions(patchItemAt(rule.conditions, conditionIndex, patch))
2160
+ const removeConditionAt = (conditionIndex) => patchConditions(rule.conditions.filter((_, i) => i !== conditionIndex))
2161
+ const conditionRow = (condition, conditionIndex) => {
2162
+ const condPath = `${pathPrefix}conditions.${conditionIndex}`
2163
+ const patchCondition = (patch) => patchConditionAt(conditionIndex, patch)
2164
+ const textInput = (field, type, labelKey) => h('label', { key: field, className: 'ud-field' },
2165
+ h('span', { className: 'ud-field-label' }, t(labelKey)),
2166
+ h('input', {
2167
+ type, className: 'ud-input', value: condition[field] ?? '',
2168
+ onChange: (event) => patchCondition({ [field]: event.target.value }),
2169
+ }),
2170
+ errorTextOf(`${condPath}.${field}`))
2171
+ const numberInput = (field, labelKey) => h('label', { key: field, className: 'ud-field' },
2172
+ h('span', { className: 'ud-field-label' }, t(labelKey)),
2173
+ h('input', {
2174
+ type: 'number', className: 'ud-input', min: MONTH_DAY_MIN, max: MONTH_DAY_MAX, step: 1,
2175
+ value: condition[field] ?? '',
2176
+ onChange: (event) => patchCondition({ [field]: event.target.value }),
2177
+ }),
2178
+ errorTextOf(`${condPath}.${field}`))
2179
+ // 周几 pill 仅 weekdays 条件才构造:其余类型无 days 字段,提前求值会使渲染崩溃白屏
2180
+ const weekdaysPills = () => h('div', { className: 'ud-group', role: 'group', 'aria-label': t('condWeekdays') },
2181
+ Array.from({ length: WEEKDAY_COUNT }, (_, day) => {
2182
+ const active = (condition.days ?? []).includes(day)
2183
+ return h('button', {
2184
+ key: day, type: 'button',
2185
+ className: cx('ud-seg-item', active && 'ud-seg-item--on'),
2186
+ 'aria-pressed': active,
2187
+ onClick: () => patchCondition({
2188
+ days: active ? condition.days.filter((value) => value !== day) : [...(condition.days ?? []), day],
2189
+ }),
2190
+ }, t(`weekday.${day}`))
2191
+ }))
2192
+ const fields = {
2193
+ dailyWindow: [textInput('from', 'time', 'condFrom'), textInput('to', 'time', 'condTo')],
2194
+ weekdays: [weekdaysPills()],
2195
+ monthDays: [numberInput('from', 'condFrom'), numberInput('to', 'condTo')],
2196
+ dateRange: [textInput('from', 'date', 'condFrom'), textInput('to', 'date', 'condTo')],
2197
+ }
2198
+ return h('div', { key: conditionIndex, className: 'ud-cond' },
2199
+ h('select', {
2200
+ className: 'ud-input ud-cond-kind', value: condition.kind, 'aria-label': t('condKind'),
2201
+ onChange: (event) => patchCondition(defaultCondition(event.target.value)),
2202
+ }, CONDITION_KIND_OPTIONS.map((kind) => h('option', { key: kind, value: kind }, t(CONDITION_KIND_LABEL_KEYS[kind])))),
2203
+ h('div', { className: 'ud-cond-fields' }, ...(fields[condition.kind] ?? [])),
2204
+ errorTextOf(condPath),
2205
+ h('button', {
2206
+ className: 'ud-btn ud-btn--text', type: 'button', onClick: () => removeConditionAt(conditionIndex),
2207
+ 'aria-label': t('deleteCondition'), title: t('deleteCondition'),
2208
+ }, '×'))
2209
+ }
1941
2210
  return h('div', { className: 'ud-rule' },
1942
2211
  h('div', { className: 'ud-rule-head' },
1943
2212
  h('label', { className: 'ud-field' },
@@ -1957,10 +2226,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1957
2226
  priceField('output', 'priceOutput'),
1958
2227
  priceField('cacheRead', 'priceCacheRead'),
1959
2228
  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 })))
2229
+ h('div', { className: 'ud-rule-conds' },
2230
+ (rule.conditions ?? []).length === 0 ? h('span', { className: 'ud-rule-cond' }, t('noCondition')) : null,
2231
+ (rule.conditions ?? []).map((condition, conditionIndex) => conditionRow(condition, conditionIndex)),
2232
+ h('div', { className: 'ud-cond-add' },
2233
+ CONDITION_KIND_OPTIONS.map((kind) => h('button', {
2234
+ key: kind, type: 'button', className: 'ud-btn ud-btn--text',
2235
+ onClick: () => patchConditions([...(rule.conditions ?? []), defaultCondition(kind)]),
2236
+ }, `+${t(CONDITION_KIND_LABEL_KEYS[kind])}`)))))
1964
2237
  }
1965
2238
 
1966
2239
  function PricingEditor({ t = defaultT }) {
@@ -2050,7 +2323,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2050
2323
  errors,
2051
2324
  pathPrefix: `${index}.`,
2052
2325
  t,
2053
- onPatch: (part) => setRules((prev) => updateRuleAt(prev, index, part)),
2326
+ onPatch: (part) => setRules((prev) => patchItemAt(prev, index, part)),
2054
2327
  onRemove: () => setRules((prev) => prev.filter((_, i) => i !== index)),
2055
2328
  })),
2056
2329
  h('button', {
@@ -2059,9 +2332,21 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2059
2332
  }, t('addRule')))
2060
2333
  }
2061
2334
 
2062
- function StatusLine({ status, t = defaultT }) {
2063
- if (!statusLineActive(status)) return null
2335
+ // 扫描异常日志入口:仅箭头标识,明细在展开的日志块中展示
2336
+ function AnomalyChip({ open, onToggle, t = defaultT }) {
2337
+ return h('button', {
2338
+ type: 'button', className: 'ud-status-fold', 'aria-expanded': open,
2339
+ 'aria-label': t('anomalyLog'), title: t('anomalyLog'),
2340
+ onClick: onToggle,
2341
+ },
2342
+ h('span', { className: 'ud-status-fold-caret', 'aria-hidden': 'true' }))
2343
+ }
2344
+
2345
+ // 回扫进度与采集错误是运行状态,常显;异常明细走日志块
2346
+ function StatusRow({ status, t = defaultT }) {
2347
+ if (!status) return null
2064
2348
  const running = status.running === true
2349
+ if (!running && !status.error) return null
2065
2350
  const progress = running && status.total > 0
2066
2351
  ? Math.min(PROGRESS_FULL_PERCENT, (status.done / status.total) * PROGRESS_FULL_PERCENT)
2067
2352
  : 0
@@ -2072,14 +2357,26 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2072
2357
  h('span', { className: 'ud-status-track' },
2073
2358
  h('span', { className: 'ud-status-fill', style: { width: `${progress}%` } })))
2074
2359
  : 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)
2360
+ status.error ? h('span', { className: 'ud-status-err' }, status.error) : null)
2361
+ }
2362
+
2363
+ // 展开后的异常日志块:汇总计数即明细条数(单一事实源),逐条展示(时间/类型/内容)
2364
+ function AnomalyLog({ status, t = defaultT }) {
2365
+ const lines = status.log ?? []
2366
+ if (lines.length === 0) return null
2367
+ return h('div', { className: 'ud-log' },
2368
+ h('div', { className: 'ud-log-summary' },
2369
+ (status.skippedSessions ?? 0) > 0
2370
+ ? h('span', null, t('skippedSessions', { n: status.skippedSessions }))
2371
+ : null,
2372
+ (status.recordFailures ?? 0) > 0
2373
+ ? h('span', { className: 'ud-status-err' }, t('recordFailures', { n: status.recordFailures }))
2374
+ : null),
2375
+ lines.map((entry, index) => h('div', { className: 'ud-log-line', key: index },
2376
+ h('span', { className: 'ud-log-time' }, logTimeOf(entry.time)),
2377
+ h('span', { className: cx('ud-log-kind', entry.kind === 'record' && 'ud-log-err') },
2378
+ entry.kind === 'record' ? t('logKindRecord') : t('logKindSkipped')),
2379
+ h('span', { className: 'ud-log-detail', title: entry.detail }, entry.detail))))
2083
2380
  }
2084
2381
 
2085
2382
  function RebuildButton({ machineRef, busy, onError, t = defaultT }) {
@@ -2114,9 +2411,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2114
2411
  const trendLimitedText = (t, id, count) => (id === 'day'
2115
2412
  ? t('trendLimited', { n: count })
2116
2413
  : 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) }))
2414
+ const presetLabel = (t, view, id) => t(`${view === 'hour' ? 'hourPreset' : 'minutePreset'}.${id}`)
2120
2415
  const tickLabelFor = (view) => (view === 'day' ? shortDay : view === 'hour' ? hourTickLabel : minuteTickLabel)
2121
2416
 
2122
2417
  function UsageDashPanel({ t = defaultT }) {
@@ -2133,6 +2428,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2133
2428
  const [pointStatus, setPointStatus] = useState('idle')
2134
2429
  const [fetchTick, setFetchTick] = useState(0)
2135
2430
  const [status, setStatus] = useState(null)
2431
+ const [detailsOpen, setDetailsOpen] = useState(false)
2136
2432
  const statusMachineRef = useRef(null)
2137
2433
  const generationRef = useRef(0)
2138
2434
  const pointGenerationRef = useRef(0)
@@ -2198,7 +2494,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2198
2494
 
2199
2495
  useEffect(() => {
2200
2496
  if (view === 'day') return
2201
- if (pointStats && pointStats.preset === presetId) return
2497
+ if (pointStatsMatches(pointStats, view, presetId)) return
2202
2498
  const request = view === 'hour'
2203
2499
  ? resolveHourRange(presetId, new Date())
2204
2500
  : resolveMinuteRange(presetId, new Date())
@@ -2213,7 +2509,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2213
2509
  }
2214
2510
  setError('')
2215
2511
  setPointStatus('ok')
2216
- setPointStats({ preset: presetId, value: result.value })
2512
+ setPointStats({ view, preset: presetId, value: result.value })
2217
2513
  })
2218
2514
  }, [view, presetId, fetchTick])
2219
2515
 
@@ -2281,7 +2577,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2281
2577
  }, [])
2282
2578
 
2283
2579
  const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
2284
- const pointView = pointStats && pointStats.preset === presetId ? pointStats.value : null
2580
+ const pointView = pointStatsMatches(pointStats, view, presetId) ? pointStats.value : null
2285
2581
  const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
2286
2582
  const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
2287
2583
 
@@ -2301,55 +2597,67 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2301
2597
 
2302
2598
  return h('div', { className: 'ud-panel', ref: panelRef },
2303
2599
  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', {
2600
+ h('div', { className: 'ud-toolbar-main' },
2601
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('viewGroup') },
2602
+ VIEW_TABS.map((tab) => h('button', {
2603
+ key: tab.id,
2604
+ className: cx('ud-seg-item', view === tab.id && 'ud-seg-item--on'),
2605
+ 'aria-pressed': view === tab.id,
2606
+ onClick: () => setView(tab.id),
2607
+ }, viewLabel(t, tab.id)))),
2608
+ pointActive
2609
+ ? h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2610
+ (view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
2322
2611
  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 })),
2612
+ className: cx('ud-seg-item', presetId === id && 'ud-seg-item--on'),
2613
+ 'aria-pressed': presetId === id,
2614
+ onClick: () => (view === 'hour' ? setHourPreset(id) : setMinutePreset(id)),
2615
+ }, presetLabel(t, view, id))))
2616
+ : h(React.Fragment, null,
2617
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
2618
+ DAY_PRESETS.map((id) => h('button', {
2619
+ key: id,
2620
+ className: cx('ud-seg-item', range === id && 'ud-seg-item--on'),
2621
+ 'aria-pressed': range === id,
2622
+ onClick: () => setRange(id),
2623
+ }, t(`rangePreset.${id}`))),
2624
+ h('button', {
2625
+ className: cx('ud-seg-item', range === 'custom' && 'ud-seg-item--on'),
2626
+ 'aria-pressed': range === 'custom',
2627
+ onClick: () => setRange('custom'),
2628
+ }, t('rangeCustom'))),
2629
+ range === 'custom'
2630
+ ? h('div', { className: 'ud-custom-range' },
2631
+ h('input', {
2632
+ type: 'date', className: 'ud-date-input', 'aria-label': t('from'),
2633
+ value: customFrom, max: customTo || undefined,
2634
+ onChange: (event) => setCustomFrom(event.target.value),
2635
+ }),
2636
+ h('span', { className: 'ud-custom-sep' }, ''),
2637
+ h('input', {
2638
+ type: 'date', className: 'ud-date-input', 'aria-label': t('to'),
2639
+ value: customTo, min: customFrom || undefined, max: dayBucket(new Date()),
2640
+ onChange: (event) => setCustomTo(event.target.value),
2641
+ }))
2642
+ : null)),
2643
+ h('div', { className: 'ud-toolbar-side' },
2644
+ h(AnomalyChip, { open: detailsOpen, onToggle: () => setDetailsOpen((v) => !v), t }),
2645
+ h('button', {
2646
+ className: 'ud-btn ud-btn--text ud-icon-btn', 'aria-label': t('refresh'), title: t('refresh'),
2647
+ disabled: busy, onClick: refresh,
2648
+ }, h('span', { className: 'ud-icon', dangerouslySetInnerHTML: { __html: REFRESH_ICON_SVG } })))),
2649
+ h(StatusRow, { status, t }),
2650
+ detailsOpen
2651
+ ? h('div', { className: 'ud-detail' },
2652
+ h(AnomalyLog, { status, t }),
2653
+ h('div', { className: 'ud-detail-foot' },
2654
+ h(RebuildButton, { machineRef: statusMachineRef, busy: status?.running === true, onError: setError, t })))
2655
+ : null,
2349
2656
  error ? h('div', { className: 'ud-error' }, error) : null,
2350
2657
  loadingVisible ? h('div', { className: 'ud-loading' }, `${t('loading')}…`) : null,
2351
2658
  stats ? h(StatCards, { key: 'cards', stats, costCurrency, t }) : null,
2352
- h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }),
2659
+ // 活跃热力图仅按天视图展示:热力图口径为日桶,时/分视图无对应语义
2660
+ view === 'day' ? h(HeatSection, { key: 'heat', days: heatDays, panelRef, t }) : null,
2353
2661
  trimmedSlots
2354
2662
  ? h(TrendChart, {
2355
2663
  key: 'trend',
@@ -2413,4 +2721,5 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2413
2721
  },
2414
2722
  }
2415
2723
  }
2416
- }
2724
+ }
2725
+ })()