@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/README.md +11 -9
- package/package.json +1 -1
- package/src/client.js +440 -131
- package/src/collector.js +46 -12
- package/src/index.js +2 -2
- package/src/pricing.js +47 -9
- package/src/query.js +22 -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 +145 -37
- 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 +33 -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,13 +1,15 @@
|
|
|
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'
|
|
@@ -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: '会话数量',
|
|
@@ -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: '
|
|
146
|
-
|
|
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
|
-
|
|
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': '
|
|
202
|
-
'rangePreset.
|
|
203
|
-
'rangePreset.
|
|
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: '
|
|
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: '
|
|
233
|
-
viewHour: '
|
|
234
|
-
viewMinute: '
|
|
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: '
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
395
|
+
// 模型键展示分段与定价/存储口径同源:首个 / 前 vendor 段,余为模型段
|
|
331
396
|
function modelNameOf(ref) {
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
982
|
-
|
|
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 ??
|
|
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
|
|
1064
|
-
const turnModelOf = (tokenUsage) => tokenUsage?.routes?.[0]?.model ??
|
|
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
|
|
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:
|
|
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-
|
|
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
|
-
//
|
|
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('
|
|
1961
|
-
(rule.conditions ?? []).length === 0
|
|
1962
|
-
|
|
1963
|
-
|
|
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) =>
|
|
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
|
-
|
|
2063
|
-
|
|
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
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
|
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
|
|
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-
|
|
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', {
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})()
|