@mzzsfy/dsh-usage-dash 0.4.0 → 0.6.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 +4 -4
- package/package.json +1 -1
- package/src/client.js +305 -79
- package/src/collector.js +117 -17
- package/src/query.js +38 -14
- package/src/store.js +16 -2
- package/test/client.test.mjs +201 -25
- package/test/collector.test.mjs +306 -11
- package/test/query.test.mjs +48 -0
- package/test/store.test.mjs +58 -0
- package/test/stream-parity.test.mjs +75 -0
- package/test/turn-tail.test.mjs +66 -0
package/src/client.js
CHANGED
|
@@ -77,9 +77,76 @@ function resolveMinuteRange(presetId, now = new Date()) {
|
|
|
77
77
|
return { from, to: minuteBucket(now) }
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// 自定义挡 id 与时/分自定义跨度上限(与数据保留期一致:小时桶固定 15 天,分钟桶上限 7 天)
|
|
81
|
+
const CUSTOM_RANGE_ID = 'custom'
|
|
82
|
+
const HOUR_CUSTOM_MAX_HOURS = 15 * 24
|
|
83
|
+
const MINUTE_CUSTOM_MAX_MINUTES = 7 * 24 * 60
|
|
84
|
+
// 选中自定义挡且无历史输入时的预填窗口
|
|
85
|
+
const POINT_CUSTOM_DEFAULT_HOURS = 24
|
|
86
|
+
|
|
87
|
+
const DATETIME_INPUT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/
|
|
88
|
+
|
|
89
|
+
// datetime-local 输入值解析为本地时刻,形态或日历非法(13 月/2 月 30 日/回卷)返回 null
|
|
90
|
+
function parseLocalDateTime(value) {
|
|
91
|
+
if (typeof value !== 'string') return null
|
|
92
|
+
const match = DATETIME_INPUT_PATTERN.exec(value)
|
|
93
|
+
if (!match) return null
|
|
94
|
+
const [year, month, day, hour, minute, second] = match.slice(1)
|
|
95
|
+
const parts = [Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second ?? 0)]
|
|
96
|
+
const date = new Date(...parts)
|
|
97
|
+
const components = [date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes()]
|
|
98
|
+
// 数字分量构造对超界值静默回卷,逐分量回读比对拦截
|
|
99
|
+
const legal = parts.slice(0, 5).every((part, index) => components[index] === part)
|
|
100
|
+
return legal ? date : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const formatDateTimeInput = (date) => `${formatDate(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
104
|
+
|
|
105
|
+
// 小时自定义范围:两端归一到所在小时桶(闭区间),跨度超保留期钳起点
|
|
106
|
+
function resolveHourCustomRange(fromValue, toValue) {
|
|
107
|
+
const from = parseLocalDateTime(fromValue)
|
|
108
|
+
const to = parseLocalDateTime(toValue)
|
|
109
|
+
if (!from || !to || from > to) return null
|
|
110
|
+
const clampedFrom = new Date(Math.max(from.getTime(), to.getTime() - HOUR_CUSTOM_MAX_HOURS * MS_PER_HOUR))
|
|
111
|
+
return { from: hourBucket(clampedFrom), to: hourBucket(to) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 分钟自定义范围:from 对齐 10 分钟桶边界,to 保持原始分钟(闭区间上界),跨度超保留期钳起点
|
|
115
|
+
function resolveMinuteCustomRange(fromValue, toValue) {
|
|
116
|
+
const from = parseLocalDateTime(fromValue)
|
|
117
|
+
const to = parseLocalDateTime(toValue)
|
|
118
|
+
if (!from || !to || from > to) return null
|
|
119
|
+
const clampedFrom = Math.max(
|
|
120
|
+
minuteBucketFloor(from.getTime()),
|
|
121
|
+
minuteBucketFloor(to.getTime()) - MINUTE_CUSTOM_MAX_MINUTES * MS_PER_MINUTE,
|
|
122
|
+
)
|
|
123
|
+
return { from: minuteBucket(new Date(clampedFrom)), to: minuteBucket(to) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 时/分查询请求与缓存键:预设挡键为挡位 id,自定义挡键含归一后桶串(范围变化即换键防误命中)
|
|
127
|
+
function resolvePointQuery(view, preset, customFrom, customTo, now = new Date()) {
|
|
128
|
+
if (view === 'hour') {
|
|
129
|
+
if (preset === CUSTOM_RANGE_ID) {
|
|
130
|
+
const range = resolveHourCustomRange(customFrom, customTo)
|
|
131
|
+
return range ? { request: range, key: `${CUSTOM_RANGE_ID}:${range.from}:${range.to}` } : null
|
|
132
|
+
}
|
|
133
|
+
return { request: resolveHourRange(preset, now), key: preset }
|
|
134
|
+
}
|
|
135
|
+
if (view === 'minute') {
|
|
136
|
+
if (preset === CUSTOM_RANGE_ID) {
|
|
137
|
+
const range = resolveMinuteCustomRange(customFrom, customTo)
|
|
138
|
+
return range ? { request: range, key: `${CUSTOM_RANGE_ID}:${range.from}:${range.to}` } : null
|
|
139
|
+
}
|
|
140
|
+
return { request: resolveMinuteRange(preset, now), key: preset }
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
80
145
|
function maxSlotsFor(view, presetId) {
|
|
81
|
-
if (view === 'hour') return hourValueOf(presetId) + 1
|
|
82
|
-
if (view === 'minute')
|
|
146
|
+
if (view === 'hour') return (presetId === CUSTOM_RANGE_ID ? HOUR_CUSTOM_MAX_HOURS : hourValueOf(presetId)) + 1
|
|
147
|
+
if (view === 'minute') {
|
|
148
|
+
return (presetId === CUSTOM_RANGE_ID ? MINUTE_CUSTOM_MAX_MINUTES : minuteValueOf(presetId)) / MINUTE_BUCKET_SPAN_MINUTES + 1
|
|
149
|
+
}
|
|
83
150
|
return DAY_MAX_SLOTS
|
|
84
151
|
}
|
|
85
152
|
|
|
@@ -113,8 +180,8 @@ const MESSAGES_ZH = {
|
|
|
113
180
|
'rangePreset.30': '30 天',
|
|
114
181
|
'rangePreset.90': '90 天',
|
|
115
182
|
rangeCustom: '自定义',
|
|
116
|
-
from: '
|
|
117
|
-
to: '
|
|
183
|
+
from: '开始',
|
|
184
|
+
to: '结束',
|
|
118
185
|
refresh: '刷新',
|
|
119
186
|
loading: '正在扫描历史会话。安装插件后首次会全量回扫,数据量大时耗时较久,期间尽量减少操作以免服务变卡',
|
|
120
187
|
tokens: 'Tokens 用量',
|
|
@@ -128,6 +195,7 @@ const MESSAGES_ZH = {
|
|
|
128
195
|
hitRateLegend: '缓存命中率',
|
|
129
196
|
avgSpeed: '平均生成速度',
|
|
130
197
|
speedLegend: '平均生成速度',
|
|
198
|
+
ttftLegend: '首 token 延迟',
|
|
131
199
|
topModel: '最常用模型',
|
|
132
200
|
topModelHint: '按 token 用量排序,非调用次数',
|
|
133
201
|
heatmap: '活跃热力图',
|
|
@@ -267,6 +335,7 @@ const MESSAGES_EN = {
|
|
|
267
335
|
hitRateLegend: 'Cache-hit rate',
|
|
268
336
|
avgSpeed: 'Avg speed',
|
|
269
337
|
speedLegend: 'Avg speed',
|
|
338
|
+
ttftLegend: 'First-token latency',
|
|
270
339
|
topModel: 'Top model',
|
|
271
340
|
topModelHint: 'Ranked by token usage, not call count',
|
|
272
341
|
heatmap: 'Activity heatmap',
|
|
@@ -529,11 +598,14 @@ const BAR_MAX_WIDTH = 30
|
|
|
529
598
|
const AXIS_TICK_COUNT = 4
|
|
530
599
|
// 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
|
|
531
600
|
const SPEED_SCALE_FLOOR = 1
|
|
601
|
+
// 首 token 延迟刻度上限钳底(毫秒):全零或无数据防除零(不设轴,读数走 tooltip)
|
|
602
|
+
const TTFT_SCALE_FLOOR = 1
|
|
532
603
|
// 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
|
|
533
604
|
const AXIS_SCALE_HEADROOM = 1.1
|
|
534
605
|
// 图例键:折线项与模型项共处同一显隐集合
|
|
535
606
|
const LEGEND_KEY_RATE = 'rate'
|
|
536
607
|
const LEGEND_KEY_SPEED = 'speed'
|
|
608
|
+
const LEGEND_KEY_TTFT = 'ttft'
|
|
537
609
|
|
|
538
610
|
function niceTicks(max, count) {
|
|
539
611
|
if (max <= 0 || count <= 0) return []
|
|
@@ -614,6 +686,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
|
|
|
614
686
|
const PERCENT_SCALE = 100
|
|
615
687
|
const RATE_AXIS_STEPS = 4
|
|
616
688
|
const TREND_LINE_WIDTH = 2
|
|
689
|
+
const TREND_LINE_GAP_SLOTS = 1.5
|
|
617
690
|
const TREND_DOT_RADIUS = 4
|
|
618
691
|
const TREND_DOT_RING = 2
|
|
619
692
|
const AXIS_RATE_GAP = 8
|
|
@@ -650,8 +723,22 @@ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
|
|
|
650
723
|
return points
|
|
651
724
|
}
|
|
652
725
|
|
|
653
|
-
//
|
|
654
|
-
function
|
|
726
|
+
// 首 token 延迟曲线点:仅带 ttft 槽产出,高度按刻度上限归一(毫秒域独立归一)
|
|
727
|
+
function trendTtftPoints(slots, bars, plotHeight, scaleMax) {
|
|
728
|
+
const points = []
|
|
729
|
+
slots.forEach((slot, index) => {
|
|
730
|
+
if (slot.ttft === undefined) return
|
|
731
|
+
points.push({
|
|
732
|
+
day: slot.day,
|
|
733
|
+
x: bars[index].x,
|
|
734
|
+
y: CHART_PAD.top + plotHeight - (slot.ttft / scaleMax) * plotHeight,
|
|
735
|
+
})
|
|
736
|
+
})
|
|
737
|
+
return points
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返;相邻点间距超 maxGap 折线断开成新段(稀疏槽防长弧跨越)
|
|
741
|
+
function smoothPath(points, maxGap = Number.POSITIVE_INFINITY) {
|
|
655
742
|
if (points.length === 0) return ''
|
|
656
743
|
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
|
|
657
744
|
let d = `M ${points[0].x} ${points[0].y}`
|
|
@@ -660,6 +747,10 @@ function smoothPath(points) {
|
|
|
660
747
|
const p1 = points[i]
|
|
661
748
|
const p2 = points[i + 1]
|
|
662
749
|
const p3 = points[i + 2] ?? p2
|
|
750
|
+
if (p2.x - p1.x > maxGap) {
|
|
751
|
+
d += ` M ${p2.x} ${p2.y}`
|
|
752
|
+
continue
|
|
753
|
+
}
|
|
663
754
|
const c1x = p1.x + (p2.x - p0.x) / 6
|
|
664
755
|
const c1y = p1.y + (p2.y - p0.y) / 6
|
|
665
756
|
const c2x = p2.x - (p3.x - p1.x) / 6
|
|
@@ -719,6 +810,30 @@ function speedScaleMax(slots) {
|
|
|
719
810
|
return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
|
|
720
811
|
}
|
|
721
812
|
|
|
813
|
+
// 首 token 延迟刻度上限(毫秒):全零或无数据钳底防除零
|
|
814
|
+
function ttftScaleMax(slots) {
|
|
815
|
+
return Math.max(TTFT_SCALE_FLOOR, ...slots.map((slot) => slot.ttft ?? 0))
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// tooltip 首 token 延迟行文本:无 ttft 与命中率同款占位符,有则为官方时长口径
|
|
819
|
+
function ttftTipText(ttft, t) {
|
|
820
|
+
return ttft === undefined ? TOOLTIP_MISSING : formatDuration(ttft, t)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(模型列表第二行用,禁本地化)
|
|
824
|
+
function formatDurationShort(ms) {
|
|
825
|
+
const seconds = ms / MS_PER_SECOND
|
|
826
|
+
if (seconds < DURATION_MINUTE_SECONDS) return `${Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL}s`
|
|
827
|
+
const whole = Math.round(seconds)
|
|
828
|
+
return `${Math.floor(whole / SECONDS_PER_MINUTE)}m${whole % SECONDS_PER_MINUTE}s`
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 模型首 token 延迟短文本:语言中立;无 ttft(无配对数据)为空串
|
|
832
|
+
function modelTtftText(ttft) {
|
|
833
|
+
if (ttft === undefined) return ''
|
|
834
|
+
return `TTFT ${formatDurationShort(ttft)}`
|
|
835
|
+
}
|
|
836
|
+
|
|
722
837
|
// 热力图:窗口固定 26 周,与所选范围无关
|
|
723
838
|
const HEAT_WEEKS = 26
|
|
724
839
|
const HEAT_ROW_COUNT = 7
|
|
@@ -776,29 +891,33 @@ function heatLevel(tokens, max) {
|
|
|
776
891
|
return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
|
|
777
892
|
}
|
|
778
893
|
|
|
779
|
-
// ChartTip
|
|
894
|
+
// ChartTip 定位:进入命中区时锚定指针,先放指针右下,右侧或下方越界翻对侧,仍越界钳进边界
|
|
780
895
|
const TIP_GAP_PX = 8
|
|
781
896
|
const TIP_MARGIN_PX = 8
|
|
782
897
|
|
|
898
|
+
// 悬停锚点:指针事件取指针坐标;焦点事件无坐标,回退目标矩形中心保键盘可访问
|
|
899
|
+
const pointerAt = (event) => {
|
|
900
|
+
if (event.clientX != null && event.clientY != null) return { x: event.clientX, y: event.clientY }
|
|
901
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
902
|
+
return { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 }
|
|
903
|
+
}
|
|
904
|
+
|
|
783
905
|
// 回合费用芯片与前置官方芯片(结束时钟)的间距
|
|
784
906
|
const TURN_COST_GAP_PX = 8
|
|
785
907
|
|
|
786
908
|
function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
|
|
787
909
|
if (!tip || tip.width <= 0 || tip.height <= 0) return null
|
|
788
|
-
if (!anchor || (anchor.
|
|
910
|
+
if (!anchor || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) return null
|
|
789
911
|
const minX = bounds.left + margin
|
|
790
912
|
const minY = bounds.top + margin
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
const left =
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
else if (above >= minY) top = above
|
|
800
|
-
else if (below + tip.height <= maxY) top = below
|
|
801
|
-
else top = minY
|
|
913
|
+
const maxLeft = bounds.right - margin - tip.width
|
|
914
|
+
const maxTop = bounds.bottom - margin - tip.height
|
|
915
|
+
const left = anchor.x + gap + tip.width <= bounds.right - margin
|
|
916
|
+
? anchor.x + gap
|
|
917
|
+
: Math.max(minX, Math.min(anchor.x - gap - tip.width, maxLeft))
|
|
918
|
+
const top = anchor.y + gap + tip.height <= bounds.bottom - margin
|
|
919
|
+
? anchor.y + gap
|
|
920
|
+
: Math.max(minY, Math.min(anchor.y - gap - tip.height, maxTop))
|
|
802
921
|
return { left, top }
|
|
803
922
|
}
|
|
804
923
|
|
|
@@ -1264,32 +1383,76 @@ const TURN_COST_CHIP_ORDER = 20 + 10
|
|
|
1264
1383
|
// messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
|
|
1265
1384
|
// 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
|
|
1266
1385
|
// 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
|
|
1267
|
-
|
|
1386
|
+
// 索引缓存:每份节点表快照只全量扫描一次建 messageId→turn-tail 索引(WeakMap 随快照释放),
|
|
1387
|
+
// 长会话流式期间多芯片各自全量扫描是 O(消息数×节点数) 放大,索引后单快照 O(节点数)
|
|
1388
|
+
const TURN_USAGE_INDEX_CACHE = new WeakMap()
|
|
1389
|
+
|
|
1390
|
+
function turnUsageIndexOf(nodes) {
|
|
1391
|
+
let index = TURN_USAGE_INDEX_CACHE.get(nodes)
|
|
1392
|
+
if (index !== undefined) return index
|
|
1393
|
+
index = new Map()
|
|
1268
1394
|
const list = nodes && typeof nodes.values === 'function' ? [...nodes.values()] : nodes
|
|
1269
|
-
if (
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const sampled = node.data?.closing?.usage
|
|
1279
|
-
if (!sampled) return null
|
|
1280
|
-
return {
|
|
1281
|
-
uncachedInputTokens: sampled.inputTokens,
|
|
1282
|
-
outputTokens: sampled.outputTokens,
|
|
1283
|
-
totalTokens: sampled.totalTokens,
|
|
1284
|
-
...sampled.cacheReadTokens === undefined ? {} : { cacheReadTokens: sampled.cacheReadTokens },
|
|
1285
|
-
...sampled.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: sampled.cacheWriteTokens },
|
|
1286
|
-
...sampled.reasoningTokens === undefined ? {} : { reasoningTokens: sampled.reasoningTokens },
|
|
1287
|
-
}
|
|
1288
|
-
} catch { /* 单节点形状残缺跳过,扫描继续 */ }
|
|
1395
|
+
if (Array.isArray(list)) {
|
|
1396
|
+
for (const node of list) {
|
|
1397
|
+
try {
|
|
1398
|
+
if (node?.kind !== 'turn-tail') continue
|
|
1399
|
+
const messageId = node.data?.closing?.finalNode?.messageId
|
|
1400
|
+
if (typeof messageId !== 'string' || index.has(messageId)) continue
|
|
1401
|
+
index.set(messageId, node)
|
|
1402
|
+
} catch { /* 单节点形状残缺跳过 */ }
|
|
1403
|
+
}
|
|
1289
1404
|
}
|
|
1405
|
+
TURN_USAGE_INDEX_CACHE.set(nodes, index)
|
|
1406
|
+
return index
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function turnTokenUsageOfMessage(nodes, messageId) {
|
|
1410
|
+
// 非对象(含 null/undefined)直接 null:WeakMap 键要求对象,展开校验收进索引构建,
|
|
1411
|
+
// 每快照只物化一次节点序列
|
|
1412
|
+
if (nodes === null || typeof nodes !== 'object') return null
|
|
1413
|
+
const node = turnUsageIndexOf(nodes).get(messageId)
|
|
1414
|
+
if (node === undefined) return null
|
|
1415
|
+
try {
|
|
1416
|
+
const turnData = node.location?.turn?.data
|
|
1417
|
+
const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
|
|
1418
|
+
if (tail?.tokenUsage) return tail.tokenUsage
|
|
1419
|
+
if (node.data.tokenUsage) return node.data.tokenUsage
|
|
1420
|
+
return usageSourceBuckets(node.data?.closing?.usage ?? null)
|
|
1421
|
+
} catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
|
|
1290
1422
|
return null
|
|
1291
1423
|
}
|
|
1292
1424
|
|
|
1425
|
+
// messageId 反查用量源:返回仓库内既有引用(官方聚合 tokenUsage → 视图节点 tokenUsage → closing.usage 采样),
|
|
1426
|
+
// 引用恒定直至该回合数据被替换;全缺返回 null。供 useChat selector 使用 ——
|
|
1427
|
+
// selector 必须返回稳定引用(useSyncExternalStore 以 Object.is 比对快照),返回新建对象会造成无限渲染;
|
|
1428
|
+
// 采样形态(字段名差异)由 usageSourceBuckets 在渲染层归一
|
|
1429
|
+
function turnUsageSourceOfMessage(nodes, messageId) {
|
|
1430
|
+
if (nodes === null || typeof nodes !== 'object') return null
|
|
1431
|
+
const node = turnUsageIndexOf(nodes).get(messageId)
|
|
1432
|
+
if (node === undefined) return null
|
|
1433
|
+
try {
|
|
1434
|
+
const turnData = node.location?.turn?.data
|
|
1435
|
+
const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
|
|
1436
|
+
return tail?.tokenUsage ?? node.data.tokenUsage ?? node.data?.closing?.usage ?? null
|
|
1437
|
+
} catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
|
|
1438
|
+
return null
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// 用量源 → 聚合桶形态:closing.usage 采样字段名不同(inputTokens 即 uncached 口径,实测 total-input=output 恒等),
|
|
1442
|
+
// 聚合形态(已带 uncachedInputTokens)原样直通保持引用
|
|
1443
|
+
function usageSourceBuckets(source) {
|
|
1444
|
+
if (!source || source.uncachedInputTokens !== undefined) return source
|
|
1445
|
+
if (source.inputTokens === undefined) return null
|
|
1446
|
+
return {
|
|
1447
|
+
uncachedInputTokens: source.inputTokens,
|
|
1448
|
+
outputTokens: source.outputTokens,
|
|
1449
|
+
totalTokens: source.totalTokens,
|
|
1450
|
+
...source.cacheReadTokens === undefined ? {} : { cacheReadTokens: source.cacheReadTokens },
|
|
1451
|
+
...source.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: source.cacheWriteTokens },
|
|
1452
|
+
...source.reasoningTokens === undefined ? {} : { reasoningTokens: source.reasoningTokens },
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1293
1456
|
// 计价模型键:routes 首条按官方 messageRoute 分离字段拼两段键,与采集器 refOf 双实现同源
|
|
1294
1457
|
// (官方 source.provider/model 是分离字段,routes[].model 是裸模型名,展示侧才拼 provider),
|
|
1295
1458
|
// 仅 model 用裸名命中 */model 档,双缺回退全通配键;多 route 取首条(单轮估算口径)
|
|
@@ -1693,9 +1856,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1693
1856
|
const STYLE_CSS = `
|
|
1694
1857
|
.ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
|
|
1695
1858
|
--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);
|
|
1696
|
-
--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}
|
|
1859
|
+
--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;--ud-trend-ttft:#e8590c}
|
|
1697
1860
|
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);
|
|
1698
|
-
--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}
|
|
1861
|
+
--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;--ud-trend-ttft:#ffa94d}
|
|
1699
1862
|
.ud-toolbar{display:flex;align-items:flex-start;gap:8px}
|
|
1700
1863
|
.ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
|
|
1701
1864
|
.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)}
|
|
@@ -1781,10 +1944,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1781
1944
|
.ud-bar-hit{fill:transparent;pointer-events:all}
|
|
1782
1945
|
.ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
|
|
1783
1946
|
.ud-trend--speed{stroke:var(--ud-trend-speed)}
|
|
1947
|
+
.ud-trend--ttft{stroke:var(--ud-trend-ttft)}
|
|
1784
1948
|
.ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
|
|
1785
1949
|
.ud-trend-dot--speed{fill:var(--ud-trend-speed)}
|
|
1950
|
+
.ud-trend-dot--ttft{fill:var(--ud-trend-ttft)}
|
|
1786
1951
|
.ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
|
|
1787
1952
|
.ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
|
|
1953
|
+
.ud-legend-swatch--line--ttft{background:var(--ud-trend-ttft)}
|
|
1788
1954
|
.ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
|
|
1789
1955
|
.ud-donut-wrap{flex:0 0 auto}
|
|
1790
1956
|
.ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
|
|
@@ -1935,7 +2101,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1935
2101
|
h(FitText, null, String(stats.activeDays))))
|
|
1936
2102
|
}
|
|
1937
2103
|
|
|
1938
|
-
function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
|
|
2104
|
+
function Legend({ models, colorFor, speedEnabled = false, ttftEnabled = false, isVisible, onItem, t = defaultT }) {
|
|
1939
2105
|
const itemProps = (key) => ({
|
|
1940
2106
|
className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
|
|
1941
2107
|
onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
|
|
@@ -1950,7 +2116,10 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1950
2116
|
h('span', null, t('hitRateLegend'))),
|
|
1951
2117
|
speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
|
|
1952
2118
|
h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
|
|
1953
|
-
h('span', null, t('speedLegend'))) : null
|
|
2119
|
+
h('span', null, t('speedLegend'))) : null,
|
|
2120
|
+
ttftEnabled ? h('span', { key: 'ttft', title: t('ttftLegend'), ...itemProps(LEGEND_KEY_TTFT) },
|
|
2121
|
+
h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--ttft' }),
|
|
2122
|
+
h('span', null, t('ttftLegend'))) : null)
|
|
1954
2123
|
}
|
|
1955
2124
|
|
|
1956
2125
|
const colorForModel = (models) => (model) => {
|
|
@@ -1980,23 +2149,34 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1980
2149
|
return () => observer.disconnect()
|
|
1981
2150
|
}, [])
|
|
1982
2151
|
const hasSpeed = slots.some((slot) => slot.speed !== undefined)
|
|
1983
|
-
const
|
|
2152
|
+
const hasTtft = slots.some((slot) => slot.ttft !== undefined)
|
|
2153
|
+
const visibleSet = visibleKeys ?? new Set([
|
|
2154
|
+
...modelOrder, LEGEND_KEY_RATE,
|
|
2155
|
+
...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
|
|
2156
|
+
...(hasTtft ? [LEGEND_KEY_TTFT] : []),
|
|
2157
|
+
])
|
|
1984
2158
|
const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
|
|
1985
2159
|
const showRate = visibleSet.has(LEGEND_KEY_RATE)
|
|
1986
2160
|
const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
|
|
2161
|
+
const showTtft = hasTtft && visibleSet.has(LEGEND_KEY_TTFT)
|
|
1987
2162
|
const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
|
|
1988
2163
|
const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
|
|
1989
2164
|
const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
|
|
1990
2165
|
const speedMax = speedScaleMax(slots)
|
|
1991
2166
|
const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
|
|
1992
2167
|
const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
|
|
2168
|
+
const ttftMax = ttftScaleMax(slots)
|
|
2169
|
+
const ttftAxisMax = ttftMax * AXIS_SCALE_HEADROOM
|
|
2170
|
+
const ttftPoints = showTtft ? trendTtftPoints(slots, layout.bars, layout.plotHeight, ttftAxisMax) : []
|
|
1993
2171
|
// 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
|
|
2172
|
+
// (ttft 不设轴,读数走 tooltip,毫秒域与速度轴不同单位不混轴)
|
|
1994
2173
|
const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
|
|
1995
2174
|
const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
|
|
1996
2175
|
const hoverSlot = hover ? slots[hover.index] : null
|
|
1997
2176
|
const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
1998
2177
|
const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
|
|
1999
|
-
const
|
|
2178
|
+
const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2179
|
+
const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
|
|
2000
2180
|
const clear = () => setHover(null)
|
|
2001
2181
|
const otherEntries = hoverSlot
|
|
2002
2182
|
? Object.entries(hoverSlot.otherByModel ?? {}).sort((a, b) => b[1] - a[1])
|
|
@@ -2038,11 +2218,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2038
2218
|
? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
|
|
2039
2219
|
: null),
|
|
2040
2220
|
showRate ? h('path', {
|
|
2041
|
-
className: 'ud-trend', d: smoothPath(ratePoints),
|
|
2221
|
+
className: 'ud-trend', d: smoothPath(ratePoints, layout.step * TREND_LINE_GAP_SLOTS),
|
|
2042
2222
|
strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
|
|
2043
2223
|
}) : null,
|
|
2044
2224
|
showSpeed ? h('path', {
|
|
2045
|
-
className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
|
|
2225
|
+
className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints, layout.step * TREND_LINE_GAP_SLOTS),
|
|
2226
|
+
strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
|
|
2227
|
+
}) : null,
|
|
2228
|
+
showTtft ? h('path', {
|
|
2229
|
+
className: cx('ud-trend', 'ud-trend--ttft'), d: smoothPath(ttftPoints, layout.step * TREND_LINE_GAP_SLOTS),
|
|
2046
2230
|
strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
|
|
2047
2231
|
}) : null,
|
|
2048
2232
|
hoverRatePoint
|
|
@@ -2051,13 +2235,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2051
2235
|
hoverSpeedPoint
|
|
2052
2236
|
? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
|
|
2053
2237
|
: null,
|
|
2238
|
+
hoverTtftPoint
|
|
2239
|
+
? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--ttft'), cx: hoverTtftPoint.x, cy: hoverTtftPoint.y, r: TREND_DOT_RADIUS })
|
|
2240
|
+
: null,
|
|
2054
2241
|
slots.map((slot, index) => h('rect', {
|
|
2055
2242
|
key: `hit-${slot.day}`, className: 'ud-bar-hit',
|
|
2056
2243
|
x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
|
|
2057
2244
|
onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
|
|
2058
2245
|
})))),
|
|
2059
2246
|
h(Legend, {
|
|
2060
|
-
models: legendModels, colorFor, speedEnabled: hasSpeed,
|
|
2247
|
+
models: legendModels, colorFor, speedEnabled: hasSpeed, ttftEnabled: hasTtft,
|
|
2061
2248
|
isVisible: (key) => visibleSet.has(key),
|
|
2062
2249
|
onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
|
|
2063
2250
|
}),
|
|
@@ -2073,6 +2260,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2073
2260
|
`${model}: ${formatTokens(tokens)}`)) : []),
|
|
2074
2261
|
showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
|
|
2075
2262
|
showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
|
|
2263
|
+
showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
|
|
2076
2264
|
costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
|
|
2077
2265
|
? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
|
|
2078
2266
|
: null,
|
|
@@ -2092,7 +2280,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2092
2280
|
? panel.getBoundingClientRect()
|
|
2093
2281
|
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
|
|
2094
2282
|
const next = tipPlace(
|
|
2095
|
-
anchor
|
|
2283
|
+
anchor,
|
|
2096
2284
|
{ width: tip.offsetWidth, height: tip.offsetHeight },
|
|
2097
2285
|
bounds,
|
|
2098
2286
|
)
|
|
@@ -2144,7 +2332,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2144
2332
|
const display = geom ? heatDisplayDays(days, geom.cols) : null
|
|
2145
2333
|
const grid = display ? heatGrid(display, geom.size) : null
|
|
2146
2334
|
const peak = display ? Math.max(1, ...display.map((slot) => slot.tokens)) : 1
|
|
2147
|
-
const pick = (cell) => (event) => setHover({ ...cell, anchor: event
|
|
2335
|
+
const pick = (cell) => (event) => setHover({ ...cell, anchor: pointerAt(event) })
|
|
2148
2336
|
const clear = () => setHover(null)
|
|
2149
2337
|
return h('div', { className: 'ud-section' },
|
|
2150
2338
|
h('div', { className: 'ud-section-head' },
|
|
@@ -2193,7 +2381,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2193
2381
|
const displayName = (model) => (model === OTHER_MODEL ? t('other') : model)
|
|
2194
2382
|
const pick = (model) => (event) => {
|
|
2195
2383
|
setHover(model)
|
|
2196
|
-
setTip({ model, anchor: event
|
|
2384
|
+
setTip({ model, anchor: pointerAt(event) })
|
|
2197
2385
|
}
|
|
2198
2386
|
const clear = () => {
|
|
2199
2387
|
setHover(null)
|
|
@@ -2233,6 +2421,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2233
2421
|
models.map((item) => {
|
|
2234
2422
|
const isOther = item.model === OTHER_MODEL
|
|
2235
2423
|
const speedText = modelSpeedText(item.speed)
|
|
2424
|
+
const ttftText = modelTtftText(item.ttft)
|
|
2425
|
+
const metaParts = [
|
|
2426
|
+
item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
|
|
2427
|
+
ttftText,
|
|
2428
|
+
speedText,
|
|
2429
|
+
].filter(Boolean)
|
|
2236
2430
|
return h(React.Fragment, { key: item.model },
|
|
2237
2431
|
h('div', {
|
|
2238
2432
|
className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
|
|
@@ -2254,11 +2448,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2254
2448
|
}, '›')
|
|
2255
2449
|
: null,
|
|
2256
2450
|
h('div', { className: 'ud-model-values' },
|
|
2257
|
-
h('span', { className: 'ud-model-tokens' },
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
formatPercent((item.tokens / total) * PERCENT_SCALE),
|
|
2261
|
-
speedText ? ` · ${speedText}` : null))),
|
|
2451
|
+
h('span', { className: 'ud-model-tokens' },
|
|
2452
|
+
`${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
|
|
2453
|
+
h('span', { className: 'ud-model-pct' }, metaParts.join(' · ')))),
|
|
2262
2454
|
isOther
|
|
2263
2455
|
? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
|
|
2264
2456
|
h('div', { className: 'ud-model-other-list' },
|
|
@@ -2350,9 +2542,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2350
2542
|
// 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
|
|
2351
2543
|
// 受费用显示开关;价格异步首帧未回不渲染,回包后补渲染;显隐节奏随官方 data-actions-reveal
|
|
2352
2544
|
// 官方槽容器固定在用量/用时芯片之前,末位排布由 SlotTailPortal 移交实现
|
|
2545
|
+
// 响应性:useChat selector 返回用量源稳定引用(null→引用 / 引用→引用),回合结束数据发布即触发重渲染;
|
|
2546
|
+
// 返回仓库本体则引用恒定,快照比对恒等,新回合永不重渲染(刷新才出现的根因)
|
|
2353
2547
|
const CostChip = React.memo(function CostChip({ messageId, useChat, t = defaultT }) {
|
|
2354
2548
|
if (typeof useChat !== 'function') return null
|
|
2355
|
-
const
|
|
2549
|
+
const usageSource = typeof messageId === 'string' && messageId !== ''
|
|
2550
|
+
? useChat((state) => turnUsageSourceOfMessage(state?.nodes, messageId))
|
|
2551
|
+
: null
|
|
2356
2552
|
const [prefs, setPrefs] = useState(() => statsLineState.get())
|
|
2357
2553
|
useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
|
|
2358
2554
|
const [pricingRules, setPricingRules] = useState(null)
|
|
@@ -2363,9 +2559,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2363
2559
|
})
|
|
2364
2560
|
return () => { alive = false }
|
|
2365
2561
|
}, [])
|
|
2366
|
-
const matched =
|
|
2367
|
-
? turnTokenUsageOfMessage(chatNodes, messageId)
|
|
2368
|
-
: null
|
|
2562
|
+
const matched = usageSourceBuckets(usageSource)
|
|
2369
2563
|
const price = matched && prefs.costDisplay && pricingRules !== null
|
|
2370
2564
|
? matchPrice(pricingRules, turnModelOf(matched), new Date())
|
|
2371
2565
|
: null
|
|
@@ -2829,6 +3023,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2829
3023
|
const [error, setError] = useState('')
|
|
2830
3024
|
const [hourPreset, setHourPreset] = useState(DEFAULT_HOUR_PRESET)
|
|
2831
3025
|
const [minutePreset, setMinutePreset] = useState(DEFAULT_MINUTE_PRESET)
|
|
3026
|
+
// 自定义挡 datetime 输入,时/分视图共享一对,各视图按桶粒度分别归一
|
|
3027
|
+
const [pointCustomFrom, setPointCustomFrom] = useState('')
|
|
3028
|
+
const [pointCustomTo, setPointCustomTo] = useState('')
|
|
2832
3029
|
const [pointStats, setPointStats] = useState(null)
|
|
2833
3030
|
const [pointStatus, setPointStatus] = useState('idle')
|
|
2834
3031
|
const [fetchTick, setFetchTick] = useState(0)
|
|
@@ -2895,17 +3092,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2895
3092
|
load()
|
|
2896
3093
|
}, [load])
|
|
2897
3094
|
|
|
2898
|
-
const
|
|
3095
|
+
const rawPreset = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
|
|
3096
|
+
// 自定义挡输入不完整时 query 为 null:不发请求,已取数据留存缓存,输入恢复即按键命中
|
|
3097
|
+
const activeQuery = view === 'day' ? null : resolvePointQuery(view, rawPreset, pointCustomFrom, pointCustomTo)
|
|
2899
3098
|
|
|
2900
3099
|
useEffect(() => {
|
|
2901
|
-
if (
|
|
2902
|
-
if (pointStatsMatches(pointStats, view,
|
|
2903
|
-
const request = view === 'hour'
|
|
2904
|
-
? resolveHourRange(presetId, new Date())
|
|
2905
|
-
: resolveMinuteRange(presetId, new Date())
|
|
3100
|
+
if (!activeQuery) return
|
|
3101
|
+
if (pointStatsMatches(pointStats, view, activeQuery.key)) return
|
|
2906
3102
|
const generation = ++pointGenerationRef.current
|
|
2907
3103
|
setPointStatus('loading')
|
|
2908
|
-
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, request).then((result) => {
|
|
3104
|
+
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, activeQuery.request).then((result) => {
|
|
2909
3105
|
if (pointGenerationRef.current !== generation) return
|
|
2910
3106
|
if (!result.ok) {
|
|
2911
3107
|
setPointStatus('error')
|
|
@@ -2914,9 +3110,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2914
3110
|
}
|
|
2915
3111
|
setError('')
|
|
2916
3112
|
setPointStatus('ok')
|
|
2917
|
-
setPointStats({ view, preset:
|
|
3113
|
+
setPointStats({ view, preset: activeQuery.key, value: result.value })
|
|
2918
3114
|
})
|
|
2919
|
-
}, [view,
|
|
3115
|
+
}, [view, rawPreset, pointCustomFrom, pointCustomTo, fetchTick])
|
|
2920
3116
|
|
|
2921
3117
|
const refreshPoints = useCallback(() => {
|
|
2922
3118
|
setPointStats(null)
|
|
@@ -2937,6 +3133,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2937
3133
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
|
|
2938
3134
|
}, [])
|
|
2939
3135
|
|
|
3136
|
+
// 切换时/分挡位;切到自定义挡且无历史输入时预填最近窗口,即点即用
|
|
3137
|
+
const selectPointPreset = (id) => {
|
|
3138
|
+
if (id === CUSTOM_RANGE_ID && !pointCustomFrom && !pointCustomTo) {
|
|
3139
|
+
setPointCustomFrom(formatDateTimeInput(new Date(Date.now() - POINT_CUSTOM_DEFAULT_HOURS * MS_PER_HOUR)))
|
|
3140
|
+
setPointCustomTo(formatDateTimeInput(new Date()))
|
|
3141
|
+
}
|
|
3142
|
+
if (view === 'hour') setHourPreset(id)
|
|
3143
|
+
else setMinutePreset(id)
|
|
3144
|
+
}
|
|
3145
|
+
|
|
2940
3146
|
const refresh = () => {
|
|
2941
3147
|
setError('')
|
|
2942
3148
|
if (view === 'day') load()
|
|
@@ -2982,7 +3188,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2982
3188
|
}, [])
|
|
2983
3189
|
|
|
2984
3190
|
const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
|
|
2985
|
-
const pointView = pointStatsMatches(pointStats, view,
|
|
3191
|
+
const pointView = activeQuery && pointStatsMatches(pointStats, view, activeQuery.key) ? pointStats.value : null
|
|
2986
3192
|
const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
|
|
2987
3193
|
const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
|
|
2988
3194
|
|
|
@@ -2990,7 +3196,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2990
3196
|
const trendSource = pointActive
|
|
2991
3197
|
? (pointGrouped ? { slots: pointGrouped.daily, models: pointGrouped.models, value: pointView } : null)
|
|
2992
3198
|
: (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
|
|
2993
|
-
const maxSlots = pointActive ? maxSlotsFor(view,
|
|
3199
|
+
const maxSlots = pointActive ? maxSlotsFor(view, rawPreset) : DAY_MAX_SLOTS
|
|
2994
3200
|
const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
|
|
2995
3201
|
const notes = []
|
|
2996
3202
|
if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
|
|
@@ -3011,13 +3217,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3011
3217
|
onClick: () => setView(tab.id),
|
|
3012
3218
|
}, viewLabel(t, tab.id)))),
|
|
3013
3219
|
pointActive
|
|
3014
|
-
? h(
|
|
3015
|
-
(
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3220
|
+
? h(React.Fragment, null,
|
|
3221
|
+
h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
|
|
3222
|
+
(view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
|
|
3223
|
+
key: id,
|
|
3224
|
+
className: cx('ud-seg-item', rawPreset === id && 'ud-seg-item--on'),
|
|
3225
|
+
'aria-pressed': rawPreset === id,
|
|
3226
|
+
onClick: () => selectPointPreset(id),
|
|
3227
|
+
}, presetLabel(t, view, id))),
|
|
3228
|
+
h('button', {
|
|
3229
|
+
className: cx('ud-seg-item', rawPreset === CUSTOM_RANGE_ID && 'ud-seg-item--on'),
|
|
3230
|
+
'aria-pressed': rawPreset === CUSTOM_RANGE_ID,
|
|
3231
|
+
onClick: () => selectPointPreset(CUSTOM_RANGE_ID),
|
|
3232
|
+
}, t('rangeCustom'))),
|
|
3233
|
+
rawPreset === CUSTOM_RANGE_ID
|
|
3234
|
+
? h('div', { className: 'ud-custom-range' },
|
|
3235
|
+
h('input', {
|
|
3236
|
+
type: 'datetime-local', className: 'ud-date-input', 'aria-label': t('from'),
|
|
3237
|
+
value: pointCustomFrom, max: pointCustomTo || undefined,
|
|
3238
|
+
onChange: (event) => setPointCustomFrom(event.target.value),
|
|
3239
|
+
}),
|
|
3240
|
+
h('span', { className: 'ud-custom-sep' }, '–'),
|
|
3241
|
+
h('input', {
|
|
3242
|
+
type: 'datetime-local', className: 'ud-date-input', 'aria-label': t('to'),
|
|
3243
|
+
value: pointCustomTo, min: pointCustomFrom || undefined, max: formatDateTimeInput(new Date()),
|
|
3244
|
+
onChange: (event) => setPointCustomTo(event.target.value),
|
|
3245
|
+
}))
|
|
3246
|
+
: null)
|
|
3021
3247
|
: h(React.Fragment, null,
|
|
3022
3248
|
h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
|
|
3023
3249
|
DAY_PRESETS.map((id) => h('button', {
|