@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/README.md +13 -11
- package/package.json +1 -1
- package/src/client.js +588 -160
- package/src/collector.js +46 -12
- package/src/index.js +2 -2
- package/src/pricing.js +47 -9
- package/src/query.js +35 -3
- package/src/routes.js +3 -2
- package/src/store.js +123 -43
- package/test/client-eval.mjs +23 -0
- package/test/client-scope.test.mjs +60 -0
- package/test/client.test.mjs +253 -43
- package/test/collector.test.mjs +93 -0
- package/test/pricing-parity.test.mjs +99 -36
- package/test/pricing.test.mjs +112 -10
- package/test/query.test.mjs +53 -3
- package/test/routes.test.mjs +11 -7
- package/test/stats-line.test.mjs +8 -16
- package/test/store.test.mjs +145 -22
- package/test/turn-tail.test.mjs +12 -20
package/src/client.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
(() => {
|
|
1
2
|
// 用量统计面板 client 半区:设置页 settings.section 注入与底部信息栏接管,en/zh 双语。
|
|
2
|
-
// 无构建:createElement +
|
|
3
|
-
// 单文件自包含:client-modules bundle
|
|
3
|
+
// 无构建:createElement + 一次性样式注入。
|
|
4
|
+
// 单文件自包含:client-modules bundle 以经典 script 整源求值,禁止 import/export;整文件 IIFE 书挡,
|
|
5
|
+
// 顶层零词法声明(经典 script 全局词法环境跨 bundle 共享,顶层同名即整脚本拒载);测试经书挡剥壳求值纯函数区。
|
|
4
6
|
|
|
5
|
-
const DAY_PRESETS = ['7', '
|
|
6
|
-
const HOUR_PRESETS = ['24h', '
|
|
7
|
-
const MINUTE_PRESETS = ['
|
|
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, '
|
|
10
|
-
const MINUTE_PRESET_MINUTES = { '
|
|
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 = '
|
|
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': '
|
|
106
|
-
'rangePreset.
|
|
107
|
-
'rangePreset.
|
|
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: '
|
|
146
|
-
|
|
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
|
-
|
|
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': '
|
|
202
|
-
'rangePreset.
|
|
203
|
-
'rangePreset.
|
|
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: '
|
|
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: '
|
|
233
|
-
viewHour: '
|
|
234
|
-
viewMinute: '
|
|
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: '
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
398
|
+
return rate === null ? TOOLTIP_MISSING : formatPercent(rate)
|
|
328
399
|
}
|
|
329
400
|
|
|
330
|
-
|
|
401
|
+
// 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
|
|
331
402
|
function modelNameOf(ref) {
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
982
|
-
|
|
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 ??
|
|
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
|
|
1064
|
-
const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ??
|
|
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
|
|
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:
|
|
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-
|
|
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,
|
|
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',
|
|
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
|
|
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
|
-
|
|
1544
|
-
const y = CHART_PAD.top + 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' },
|
|
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
|
-
|
|
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, {
|
|
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
|
-
//
|
|
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('
|
|
1961
|
-
(rule.conditions ?? []).length === 0
|
|
1962
|
-
|
|
1963
|
-
|
|
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) =>
|
|
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
|
-
|
|
2063
|
-
|
|
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
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
|
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
|
|
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-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
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',
|
|
2324
|
-
'aria-pressed':
|
|
2325
|
-
onClick: () =>
|
|
2326
|
-
}, t
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
-
|
|
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
|
+
})()
|