@mzzsfy/dsh-usage-dash 0.5.0 → 0.7.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 +237 -71
- package/src/query.js +254 -249
- package/test/client.test.mjs +238 -25
- package/test/query.test.mjs +53 -3
- package/test/turn-tail.test.mjs +16 -0
package/README.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
设置页「使用统计」区块:
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- 三粒度视图,全部支持自定义时间范围:天(7/30/90 天/自定义日期段)、小时(24 小时/3 天/7 天/15 天/自定义时刻段)、分钟(3 小时/24 小时/3 天/7 天/自定义时刻段,10 分钟桶粒度),预设挡为滚动窗口;自定义时刻段为起止 datetime 双输入,两端归一到桶边界(小时取所在整点、分钟起点对齐 10 分钟且终点保留原分钟,均闭区间),跨度上限与数据保留期一致(小时 15 天、分钟 7 天,超限自动钳起点),首次切到自定义挡预填最近 24 小时,时/分两视图共享同一对自定义输入;已知限制:自定义输入残缺(任一端为空)时面板不发请求且图表静默空白,与天视图行为一致,交互反馈留待后续增强
|
|
10
10
|
- 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
|
|
11
11
|
- 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细;仅按天视图展示
|
|
12
12
|
- 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
|
13
|
-
- 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量)
|
|
14
|
-
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl
|
|
13
|
+
- 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量);每行名称两行展示(厂商/模型名,其余折叠为「其他」),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
|
|
14
|
+
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
|
|
15
15
|
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
16
|
- 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
|
|
17
17
|
- 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
- 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
|
|
20
20
|
- 数据 API 守卫:POST 同源校验 + JSON content-type(与 dsh-usage-panel 同构),局域网远程访问可用
|
|
21
21
|
|
|
22
|
-
口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host
|
|
22
|
+
口径:token 总量 = 未缓存输入 + 输出 + 缓存读 + 缓存写;命中率 = 缓存读 / (缓存读 + 未缓存输入 + 缓存写);桶按 host 本地时区。聚合响应 `models` 数组每模型带 `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens` 四桶拆分(与定价四桶同口径),模型列表据此展示输入输出对比。平均生成速度 = decode 配对分子 ÷ 解码时长,两者均取官方吞吐口径(与官方 session-stats 投影同构):解码时长为该步首 token 时刻(首个产出 token 的 attempt 流,回落 message 自带流)到 usage 汇报时刻,不含首 token 前的排队与提示处理等待;首 token 延迟(TTFT)= 首 token 时刻 − `step/start`(起点不随 `llm/retry-started` 重置,即含失败尝试时间)。首 token 时刻在 chunk token 样本上不可得,由后续 usage 报告(message 为主)补发零桶 timing 增量行承载(decodeTokens 与时长同源配对,不重复计 token);存量旧行(时长为旧全时长口径)速度分子回落输出 token,聚合随新数据自然收敛,重建(重扫)可全量按新口径重建。保留策略:天桶永久,小时桶固定 15 天,分钟桶默认 7 天且上限 7 天(设置项 `minuteRetentionDays`,0 = 禁用分钟桶)。
|
|
23
23
|
|
|
24
24
|
写入模型:样本先同步合并进内存 pending,按 2 秒周期批量落盘(单布局存储域每次持久化写都全量重发布 unit 文档,合并把每样本 3 次写降为每脏行 1 次,回扫万级样本写放大降约 99%);查询前自动 flush 保证读己之写;flush 失败的行留 pending 下轮重试并经异常日志可观测,崩溃丢失窗口 = flush 周期,统计可由会话重扫重建。
|
|
25
25
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-dash",
|
|
3
3
|
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
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 用量',
|
|
@@ -137,6 +204,9 @@ const MESSAGES_ZH = {
|
|
|
137
204
|
dailyTrend: '按天 Token 趋势',
|
|
138
205
|
trendLimited: '仅显示最近 {n} 天',
|
|
139
206
|
modelUsage: '模型用量',
|
|
207
|
+
modelCache: '缓存',
|
|
208
|
+
modelInput: '输入',
|
|
209
|
+
modelOutput: '输出',
|
|
140
210
|
other: '其他',
|
|
141
211
|
total: '总用量',
|
|
142
212
|
percent: '占比',
|
|
@@ -277,6 +347,9 @@ const MESSAGES_EN = {
|
|
|
277
347
|
dailyTrend: 'Daily token trend',
|
|
278
348
|
trendLimited: 'Showing only the last {n} days',
|
|
279
349
|
modelUsage: 'Model usage',
|
|
350
|
+
modelCache: 'Cache',
|
|
351
|
+
modelInput: 'Input',
|
|
352
|
+
modelOutput: 'Output',
|
|
280
353
|
other: 'Other',
|
|
281
354
|
total: 'Total',
|
|
282
355
|
percent: 'Share',
|
|
@@ -494,10 +567,12 @@ const topWithOther = (ranked) => {
|
|
|
494
567
|
const models = ranked.slice(0, GROUP_TOP_COUNT)
|
|
495
568
|
if (ranked.length > GROUP_TOP_COUNT) {
|
|
496
569
|
const rest = ranked.slice(GROUP_TOP_COUNT)
|
|
570
|
+
// 四桶与费用同源条件挂载:输入条目无该字段(旧形数据/未配价)时哨兵同样不带,防零值假数据
|
|
497
571
|
models.push({
|
|
498
572
|
model: OTHER_MODEL,
|
|
499
573
|
tokens: rest.reduce((sum, item) => sum + item.tokens, 0),
|
|
500
|
-
|
|
574
|
+
...(rest.some(hasTokenBuckets) ? { ...bucketSumsOf(rest) } : {}),
|
|
575
|
+
...(rest.some((item) => item.cost !== undefined) ? { cost: rest.reduce((sum, item) => sum + (item.cost ?? 0), 0) } : {}),
|
|
501
576
|
items: rest,
|
|
502
577
|
})
|
|
503
578
|
}
|
|
@@ -753,7 +828,7 @@ function ttftTipText(ttft, t) {
|
|
|
753
828
|
return ttft === undefined ? TOOLTIP_MISSING : formatDuration(ttft, t)
|
|
754
829
|
}
|
|
755
830
|
|
|
756
|
-
// 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(
|
|
831
|
+
// 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(模型列表 meta 行用,禁本地化)
|
|
757
832
|
function formatDurationShort(ms) {
|
|
758
833
|
const seconds = ms / MS_PER_SECOND
|
|
759
834
|
if (seconds < DURATION_MINUTE_SECONDS) return `${Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL}s`
|
|
@@ -767,6 +842,39 @@ function modelTtftText(ttft) {
|
|
|
767
842
|
return `TTFT ${formatDurationShort(ttft)}`
|
|
768
843
|
}
|
|
769
844
|
|
|
845
|
+
// 模型条目四桶齐备判定:生产端同源恒齐,旧形数据缺桶即视为不可渲染
|
|
846
|
+
const hasTokenBuckets = (item) => TOKEN_BUCKET_KEYS.every((key) => typeof item?.[key] === 'number')
|
|
847
|
+
|
|
848
|
+
// 逐桶求和:按共享桶键列表产出四桶聚合形态
|
|
849
|
+
const bucketSumsOf = (items) => Object.fromEntries(TOKEN_BUCKET_KEYS.map((key) => [
|
|
850
|
+
key,
|
|
851
|
+
items.reduce((sum, item) => sum + (item[key] ?? 0), 0),
|
|
852
|
+
]))
|
|
853
|
+
|
|
854
|
+
// 模型输入输出占比构成:缓存/输入/输出 三段各占该模型 token 总量百分比(缓存=读+写,
|
|
855
|
+
// 三段合计恒 100%);四桶缺失(旧数据)或零总量为空串
|
|
856
|
+
function modelIoPercentText(item, t) {
|
|
857
|
+
if (!hasTokenBuckets(item) || !(item.tokens > 0)) return ''
|
|
858
|
+
const share = (value) => formatPercent((value / item.tokens) * PERCENT_SCALE)
|
|
859
|
+
return `${t('modelCache')} ${share(item.cacheReadTokens + item.cacheWriteTokens)} · ${t('modelInput')} ${share(item.inputTokens)} · ${t('modelOutput')} ${share(item.outputTokens)}`
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// 趋势 tooltip 内容门:全零槽(无任何用量)不渲染 tooltip
|
|
863
|
+
const hasTipContent = (hoverSlot) => Boolean(hoverSlot) && hoverSlot.total > 0
|
|
864
|
+
|
|
865
|
+
// 趋势 tooltip 模型行数据:主行 = 可见且当前时段有用量的模型;OTHER 子行 = OTHER 可见
|
|
866
|
+
// 且当前时段有用量的其余模型明细,按 tokens 降序由本函数保证
|
|
867
|
+
const tipModelEntries = (hoverSlot, visibleSet) => {
|
|
868
|
+
if (!hasTipContent(hoverSlot)) return { main: [], other: [] }
|
|
869
|
+
const main = Object.entries(hoverSlot.byModel)
|
|
870
|
+
.filter(([model, tokens]) => visibleSet.has(model) && tokens > 0)
|
|
871
|
+
.map(([model, tokens]) => ({ model, tokens }))
|
|
872
|
+
const other = visibleSet.has(OTHER_MODEL)
|
|
873
|
+
? Object.entries(hoverSlot.otherByModel ?? {}).filter(([, tokens]) => tokens > 0).sort((a, b) => b[1] - a[1])
|
|
874
|
+
: []
|
|
875
|
+
return { main, other }
|
|
876
|
+
}
|
|
877
|
+
|
|
770
878
|
// 热力图:窗口固定 26 周,与所选范围无关
|
|
771
879
|
const HEAT_WEEKS = 26
|
|
772
880
|
const HEAT_ROW_COUNT = 7
|
|
@@ -824,29 +932,33 @@ function heatLevel(tokens, max) {
|
|
|
824
932
|
return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
|
|
825
933
|
}
|
|
826
934
|
|
|
827
|
-
// ChartTip
|
|
935
|
+
// ChartTip 定位:进入命中区时锚定指针,先放指针右下,右侧或下方越界翻对侧,仍越界钳进边界
|
|
828
936
|
const TIP_GAP_PX = 8
|
|
829
937
|
const TIP_MARGIN_PX = 8
|
|
830
938
|
|
|
939
|
+
// 悬停锚点:指针事件取指针坐标;焦点事件无坐标,回退目标矩形中心保键盘可访问
|
|
940
|
+
const pointerAt = (event) => {
|
|
941
|
+
if (event.clientX != null && event.clientY != null) return { x: event.clientX, y: event.clientY }
|
|
942
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
943
|
+
return { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 }
|
|
944
|
+
}
|
|
945
|
+
|
|
831
946
|
// 回合费用芯片与前置官方芯片(结束时钟)的间距
|
|
832
947
|
const TURN_COST_GAP_PX = 8
|
|
833
948
|
|
|
834
949
|
function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
|
|
835
950
|
if (!tip || tip.width <= 0 || tip.height <= 0) return null
|
|
836
|
-
if (!anchor || (anchor.
|
|
951
|
+
if (!anchor || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) return null
|
|
837
952
|
const minX = bounds.left + margin
|
|
838
953
|
const minY = bounds.top + margin
|
|
839
|
-
const
|
|
840
|
-
const
|
|
841
|
-
const left =
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
else if (above >= minY) top = above
|
|
848
|
-
else if (below + tip.height <= maxY) top = below
|
|
849
|
-
else top = minY
|
|
954
|
+
const maxLeft = bounds.right - margin - tip.width
|
|
955
|
+
const maxTop = bounds.bottom - margin - tip.height
|
|
956
|
+
const left = anchor.x + gap + tip.width <= bounds.right - margin
|
|
957
|
+
? anchor.x + gap
|
|
958
|
+
: Math.max(minX, Math.min(anchor.x - gap - tip.width, maxLeft))
|
|
959
|
+
const top = anchor.y + gap + tip.height <= bounds.bottom - margin
|
|
960
|
+
? anchor.y + gap
|
|
961
|
+
: Math.max(minY, Math.min(anchor.y - gap - tip.height, maxTop))
|
|
850
962
|
return { left, top }
|
|
851
963
|
}
|
|
852
964
|
|
|
@@ -1245,6 +1357,9 @@ const BUCKET_PRICE_KEYS = [
|
|
|
1245
1357
|
{ tokens: 'cacheWriteTokens', price: 'cacheWrite' },
|
|
1246
1358
|
]
|
|
1247
1359
|
|
|
1360
|
+
// 展示侧四桶键单一来源:模型分组求和与齐备判定共用,禁止各处手工复制键名
|
|
1361
|
+
const TOKEN_BUCKET_KEYS = BUCKET_PRICE_KEYS.map(({ tokens }) => tokens)
|
|
1362
|
+
|
|
1248
1363
|
const toFiniteNumber = (value) => (Number.isFinite(value) ? value : 0)
|
|
1249
1364
|
|
|
1250
1365
|
// 费用 = Σ(桶 token × 桶单价) / 每百万;缺桶或非法值按 0,原始浮点不圆整(展示层负责)
|
|
@@ -1312,15 +1427,20 @@ const TURN_COST_CHIP_ORDER = 20 + 10
|
|
|
1312
1427
|
// messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
|
|
1313
1428
|
// 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
|
|
1314
1429
|
// 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1430
|
+
// 索引缓存:键取节点集数组身份 —— 官方 chat store 实例身份跨回合恒定,values() 返回的缓存数组
|
|
1431
|
+
// 随节点集更替换新引用:未变时全芯片共享一次扫描,新增回合节点经 values() 换引用自动失效重建
|
|
1432
|
+
// (按 store 实例为键会让新节点永远查不到旧索引,表现为回合费用芯片刷新后才出现);
|
|
1433
|
+
// Map/迭代器形态(测试兼容)仍按容器为键
|
|
1317
1434
|
const TURN_USAGE_INDEX_CACHE = new WeakMap()
|
|
1318
1435
|
|
|
1319
1436
|
function turnUsageIndexOf(nodes) {
|
|
1320
|
-
|
|
1437
|
+
const source = nodes && typeof nodes.values === 'function' ? nodes.values() : nodes
|
|
1438
|
+
const snapshotForm = Array.isArray(source)
|
|
1439
|
+
const cacheKey = snapshotForm ? source : nodes
|
|
1440
|
+
let index = TURN_USAGE_INDEX_CACHE.get(cacheKey)
|
|
1321
1441
|
if (index !== undefined) return index
|
|
1322
1442
|
index = new Map()
|
|
1323
|
-
const list =
|
|
1443
|
+
const list = snapshotForm ? source : typeof source?.[Symbol.iterator] === 'function' ? [...source] : source
|
|
1324
1444
|
if (Array.isArray(list)) {
|
|
1325
1445
|
for (const node of list) {
|
|
1326
1446
|
try {
|
|
@@ -1331,13 +1451,13 @@ function turnUsageIndexOf(nodes) {
|
|
|
1331
1451
|
} catch { /* 单节点形状残缺跳过 */ }
|
|
1332
1452
|
}
|
|
1333
1453
|
}
|
|
1334
|
-
TURN_USAGE_INDEX_CACHE.set(
|
|
1454
|
+
TURN_USAGE_INDEX_CACHE.set(cacheKey, index)
|
|
1335
1455
|
return index
|
|
1336
1456
|
}
|
|
1337
1457
|
|
|
1338
1458
|
function turnTokenUsageOfMessage(nodes, messageId) {
|
|
1339
1459
|
// 非对象(含 null/undefined)直接 null:WeakMap 键要求对象,展开校验收进索引构建,
|
|
1340
|
-
//
|
|
1460
|
+
// 每份节点集只物化一次节点序列
|
|
1341
1461
|
if (nodes === null || typeof nodes !== 'object') return null
|
|
1342
1462
|
const node = turnUsageIndexOf(nodes).get(messageId)
|
|
1343
1463
|
if (node === undefined) return null
|
|
@@ -1863,11 +1983,11 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1863
1983
|
.ud-heat-l5{fill:var(--dsw-heat-5);background:var(--dsw-heat-5)}
|
|
1864
1984
|
.ud-heat-legend{display:inline-flex;align-items:center;gap:${HEAT_GAP}px;margin-left:auto;flex:none;font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
1865
1985
|
.ud-heat-legend i{display:inline-block;width:${HEAT_BASE}px;height:${HEAT_BASE}px;border-radius:${HEAT_RX}px;flex:none}
|
|
1866
|
-
.ud-tip{position:fixed;z-index:${TIP_Z_INDEX};visibility:hidden;pointer-events:none;white-space:nowrap;border:1px solid var(--dsw-alias-border-l2);border-radius:
|
|
1867
|
-
.ud-tip-title{font-weight:600}
|
|
1868
|
-
.ud-tip-row{display:flex;align-items:center;gap:
|
|
1869
|
-
.ud-tip-row--sub{padding-left:
|
|
1870
|
-
.ud-tip-breakdown{display:flex;flex-direction:column;gap:
|
|
1986
|
+
.ud-tip{position:fixed;z-index:${TIP_Z_INDEX};visibility:hidden;pointer-events:none;white-space:nowrap;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-overlay);box-shadow:0 1px 2px var(--dsw-alias-bg-mask-2),0 6px 20px var(--dsw-alias-bg-mask-2);color:var(--dsw-alias-label-primary);font-size:13px;padding:10px 12px;font-variant-numeric:tabular-nums}
|
|
1987
|
+
.ud-tip-title{font-weight:600;margin-bottom:6px}
|
|
1988
|
+
.ud-tip-row{display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
|
|
1989
|
+
.ud-tip-row--sub{padding-left:14px;color:var(--dsw-alias-label-tertiary);font-size:11px}
|
|
1990
|
+
.ud-tip-breakdown{display:flex;flex-direction:column;gap:3px;border-top:1px solid var(--dsw-alias-border-l1);margin-top:8px;padding-top:8px}
|
|
1871
1991
|
.ud-axis-rate{fill:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
|
|
1872
1992
|
.ud-bar{transform-box:fill-box;transform-origin:center}
|
|
1873
1993
|
.ud-bar-hit{fill:transparent;pointer-events:all}
|
|
@@ -1887,17 +2007,17 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
1887
2007
|
.ud-donut-center{font-size:18px;font-weight:600;fill:var(--dsw-alias-label-primary)}
|
|
1888
2008
|
.ud-donut-label{font-size:11px;fill:var(--dsw-alias-label-tertiary)}
|
|
1889
2009
|
.ud-models{flex:1 1 260px;min-width:240px;display:flex;flex-direction:column}
|
|
1890
|
-
.ud-model-row{display:flex;align-items:center;gap:8px;min-height:
|
|
2010
|
+
.ud-model-row{display:flex;align-items:center;gap:8px;min-height:48px;padding:2px 4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
1891
2011
|
.ud-model-row--expand{cursor:pointer}
|
|
1892
2012
|
.ud-model-row--expand:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
1893
2013
|
.ud-model-swatch{width:10px;height:10px;border-radius:2px;flex:none}
|
|
1894
2014
|
.ud-model-id{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
|
1895
|
-
.ud-model-name{font-size:
|
|
1896
|
-
.ud-model-provider{font-size:
|
|
2015
|
+
.ud-model-name{font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2016
|
+
.ud-model-provider{font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1897
2017
|
.ud-model-values{display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-variant-numeric:tabular-nums;flex:none}
|
|
1898
2018
|
.ud-model-tokens{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
1899
|
-
.ud-model-
|
|
1900
|
-
.ud-model-
|
|
2019
|
+
.ud-model-io{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
2020
|
+
.ud-model-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
|
|
1901
2021
|
.ud-model-toggle{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 6px;font-size:14px;line-height:1;transition:transform .2s ease}
|
|
1902
2022
|
.ud-model-toggle[aria-expanded="true"]{transform:rotate(90deg)}
|
|
1903
2023
|
.ud-model-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);border-radius:4px}
|
|
@@ -2105,11 +2225,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2105
2225
|
const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
2106
2226
|
const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2107
2227
|
const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2108
|
-
const pick = (index) => (event) => setHover({ index, anchor: event
|
|
2228
|
+
const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
|
|
2109
2229
|
const clear = () => setHover(null)
|
|
2110
|
-
const
|
|
2111
|
-
? Object.entries(hoverSlot.otherByModel ?? {}).sort((a, b) => b[1] - a[1])
|
|
2112
|
-
: []
|
|
2230
|
+
const tipEntries = hasTipContent(hoverSlot) ? tipModelEntries(hoverSlot, visibleSet) : null
|
|
2113
2231
|
return h('div', { className: 'ud-section' },
|
|
2114
2232
|
h('div', { className: 'ud-section-head' },
|
|
2115
2233
|
h('span', { className: 'ud-section-title' }, title),
|
|
@@ -2178,15 +2296,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2178
2296
|
onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
|
|
2179
2297
|
}),
|
|
2180
2298
|
h(ChartTip, { anchor: hover ? hover.anchor : null, panelRef },
|
|
2181
|
-
|
|
2299
|
+
tipEntries
|
|
2182
2300
|
? [
|
|
2183
2301
|
h('div', { key: 'title', className: 'ud-tip-title' }, slotLabelFor ? slotLabelFor(hoverSlot.day) : hoverSlot.day),
|
|
2184
2302
|
h('div', { key: 'total', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(hoverSlot.total)}`),
|
|
2185
|
-
...
|
|
2186
|
-
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(
|
|
2187
|
-
`${
|
|
2188
|
-
...
|
|
2189
|
-
`${model}: ${formatTokens(tokens)}`))
|
|
2303
|
+
...tipEntries.main.map((row) => h('div', { key: `m-${row.model}`, className: 'ud-tip-row' },
|
|
2304
|
+
h('i', { className: 'ud-legend-swatch', style: { background: colorFor(row.model) } }),
|
|
2305
|
+
`${row.model === OTHER_MODEL ? t('other') : row.model}: ${formatTokens(row.tokens)}`)),
|
|
2306
|
+
...tipEntries.other.map(([model, tokens]) => h('div', { key: `om-${model}`, className: 'ud-tip-row ud-tip-row--sub' },
|
|
2307
|
+
`${model}: ${formatTokens(tokens)}`)),
|
|
2190
2308
|
showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
|
|
2191
2309
|
showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
|
|
2192
2310
|
showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
|
|
@@ -2209,7 +2327,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2209
2327
|
? panel.getBoundingClientRect()
|
|
2210
2328
|
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
|
|
2211
2329
|
const next = tipPlace(
|
|
2212
|
-
anchor
|
|
2330
|
+
anchor,
|
|
2213
2331
|
{ width: tip.offsetWidth, height: tip.offsetHeight },
|
|
2214
2332
|
bounds,
|
|
2215
2333
|
)
|
|
@@ -2230,15 +2348,17 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2230
2348
|
observer.disconnect()
|
|
2231
2349
|
}
|
|
2232
2350
|
}, [place])
|
|
2351
|
+
// 空内容(无子节点或空数组)不显示悬浮盒,防零尺寸守卫下出现纯背景空壳
|
|
2352
|
+
const show = Boolean(anchor && children != null && (!Array.isArray(children) || children.length > 0))
|
|
2233
2353
|
const element = h('div', {
|
|
2234
2354
|
className: 'ud-tip',
|
|
2235
2355
|
ref: tipRef,
|
|
2236
2356
|
style: {
|
|
2237
2357
|
left: pos ? pos.left : 0,
|
|
2238
2358
|
top: pos ? pos.top : 0,
|
|
2239
|
-
visibility:
|
|
2359
|
+
visibility: show && pos ? 'visible' : 'hidden',
|
|
2240
2360
|
},
|
|
2241
|
-
},
|
|
2361
|
+
}, show ? children : null)
|
|
2242
2362
|
return createPortal && typeof document !== 'undefined' && document.body
|
|
2243
2363
|
? createPortal(element, document.body)
|
|
2244
2364
|
: element
|
|
@@ -2261,7 +2381,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2261
2381
|
const display = geom ? heatDisplayDays(days, geom.cols) : null
|
|
2262
2382
|
const grid = display ? heatGrid(display, geom.size) : null
|
|
2263
2383
|
const peak = display ? Math.max(1, ...display.map((slot) => slot.tokens)) : 1
|
|
2264
|
-
const pick = (cell) => (event) => setHover({ ...cell, anchor: event
|
|
2384
|
+
const pick = (cell) => (event) => setHover({ ...cell, anchor: pointerAt(event) })
|
|
2265
2385
|
const clear = () => setHover(null)
|
|
2266
2386
|
return h('div', { className: 'ud-section' },
|
|
2267
2387
|
h('div', { className: 'ud-section-head' },
|
|
@@ -2310,7 +2430,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2310
2430
|
const displayName = (model) => (model === OTHER_MODEL ? t('other') : model)
|
|
2311
2431
|
const pick = (model) => (event) => {
|
|
2312
2432
|
setHover(model)
|
|
2313
|
-
setTip({ model, anchor: event
|
|
2433
|
+
setTip({ model, anchor: pointerAt(event) })
|
|
2314
2434
|
}
|
|
2315
2435
|
const clear = () => {
|
|
2316
2436
|
setHover(null)
|
|
@@ -2351,6 +2471,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2351
2471
|
const isOther = item.model === OTHER_MODEL
|
|
2352
2472
|
const speedText = modelSpeedText(item.speed)
|
|
2353
2473
|
const ttftText = modelTtftText(item.ttft)
|
|
2474
|
+
const ioText = modelIoPercentText(item, t)
|
|
2354
2475
|
const metaParts = [
|
|
2355
2476
|
item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
|
|
2356
2477
|
ttftText,
|
|
@@ -2365,8 +2486,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2365
2486
|
},
|
|
2366
2487
|
h('i', { className: 'ud-model-swatch', style: { background: colorFor(item.model) } }),
|
|
2367
2488
|
h('div', { className: 'ud-model-id' },
|
|
2368
|
-
|
|
2369
|
-
|
|
2489
|
+
isOther
|
|
2490
|
+
? h('span', { className: 'ud-model-name' }, displayName(item.model))
|
|
2491
|
+
: [
|
|
2492
|
+
h('span', { className: 'ud-model-provider' }, providerOf(item.model)),
|
|
2493
|
+
h('span', { className: 'ud-model-name' }, modelNameOf(item.model)),
|
|
2494
|
+
]),
|
|
2370
2495
|
isOther
|
|
2371
2496
|
? h('button', {
|
|
2372
2497
|
className: 'ud-model-toggle', 'aria-expanded': expandedOther, 'aria-label': t('other'),
|
|
@@ -2379,7 +2504,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2379
2504
|
h('div', { className: 'ud-model-values' },
|
|
2380
2505
|
h('span', { className: 'ud-model-tokens' },
|
|
2381
2506
|
`${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
|
|
2382
|
-
h('span', { className: 'ud-model-
|
|
2507
|
+
ioText ? h('span', { className: 'ud-model-io' }, ioText) : null,
|
|
2508
|
+
h('span', { className: 'ud-model-meta' }, metaParts.join(' · ')))),
|
|
2383
2509
|
isOther
|
|
2384
2510
|
? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
|
|
2385
2511
|
h('div', { className: 'ud-model-other-list' },
|
|
@@ -2398,6 +2524,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2398
2524
|
h('div', { key: 'title', className: 'ud-tip-title' }, displayName(tipSegment.model)),
|
|
2399
2525
|
h('div', { key: 'tokens', className: 'ud-tip-row' }, `${t('total')}: ${formatTokens(tipSegment.tokens)}`),
|
|
2400
2526
|
h('div', { key: 'pct', className: 'ud-tip-row' }, `${t('percent')}: ${formatPercent(tipSegment.percent)}`),
|
|
2527
|
+
hasTokenBuckets(tipSegment)
|
|
2528
|
+
? [
|
|
2529
|
+
h('div', { key: 'input', className: 'ud-tip-row' }, `${t('priceInput')}: ${formatTokens(tipSegment.inputTokens)}`),
|
|
2530
|
+
h('div', { key: 'cacheRead', className: 'ud-tip-row' }, `${t('priceCacheRead')}: ${formatTokens(tipSegment.cacheReadTokens)}`),
|
|
2531
|
+
h('div', { key: 'cacheWrite', className: 'ud-tip-row' }, `${t('priceCacheWrite')}: ${formatTokens(tipSegment.cacheWriteTokens)}`),
|
|
2532
|
+
h('div', { key: 'output', className: 'ud-tip-row' }, `${t('priceOutput')}: ${formatTokens(tipSegment.outputTokens)}`),
|
|
2533
|
+
]
|
|
2534
|
+
: null,
|
|
2401
2535
|
tipSegment.model === OTHER_MODEL && (tipSegment.items ?? []).length > 0
|
|
2402
2536
|
? h('div', { key: 'breakdown', className: 'ud-tip-breakdown' },
|
|
2403
2537
|
tipSegment.items.map((detail) => h('div', { key: detail.model, className: 'ud-tip-row ud-tip-row--sub' },
|
|
@@ -2952,6 +3086,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2952
3086
|
const [error, setError] = useState('')
|
|
2953
3087
|
const [hourPreset, setHourPreset] = useState(DEFAULT_HOUR_PRESET)
|
|
2954
3088
|
const [minutePreset, setMinutePreset] = useState(DEFAULT_MINUTE_PRESET)
|
|
3089
|
+
// 自定义挡 datetime 输入,时/分视图共享一对,各视图按桶粒度分别归一
|
|
3090
|
+
const [pointCustomFrom, setPointCustomFrom] = useState('')
|
|
3091
|
+
const [pointCustomTo, setPointCustomTo] = useState('')
|
|
2955
3092
|
const [pointStats, setPointStats] = useState(null)
|
|
2956
3093
|
const [pointStatus, setPointStatus] = useState('idle')
|
|
2957
3094
|
const [fetchTick, setFetchTick] = useState(0)
|
|
@@ -3018,17 +3155,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3018
3155
|
load()
|
|
3019
3156
|
}, [load])
|
|
3020
3157
|
|
|
3021
|
-
const
|
|
3158
|
+
const rawPreset = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
|
|
3159
|
+
// 自定义挡输入不完整时 query 为 null:不发请求,已取数据留存缓存,输入恢复即按键命中
|
|
3160
|
+
const activeQuery = view === 'day' ? null : resolvePointQuery(view, rawPreset, pointCustomFrom, pointCustomTo)
|
|
3022
3161
|
|
|
3023
3162
|
useEffect(() => {
|
|
3024
|
-
if (
|
|
3025
|
-
if (pointStatsMatches(pointStats, view,
|
|
3026
|
-
const request = view === 'hour'
|
|
3027
|
-
? resolveHourRange(presetId, new Date())
|
|
3028
|
-
: resolveMinuteRange(presetId, new Date())
|
|
3163
|
+
if (!activeQuery) return
|
|
3164
|
+
if (pointStatsMatches(pointStats, view, activeQuery.key)) return
|
|
3029
3165
|
const generation = ++pointGenerationRef.current
|
|
3030
3166
|
setPointStatus('loading')
|
|
3031
|
-
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, request).then((result) => {
|
|
3167
|
+
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, activeQuery.request).then((result) => {
|
|
3032
3168
|
if (pointGenerationRef.current !== generation) return
|
|
3033
3169
|
if (!result.ok) {
|
|
3034
3170
|
setPointStatus('error')
|
|
@@ -3037,9 +3173,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3037
3173
|
}
|
|
3038
3174
|
setError('')
|
|
3039
3175
|
setPointStatus('ok')
|
|
3040
|
-
setPointStats({ view, preset:
|
|
3176
|
+
setPointStats({ view, preset: activeQuery.key, value: result.value })
|
|
3041
3177
|
})
|
|
3042
|
-
}, [view,
|
|
3178
|
+
}, [view, rawPreset, pointCustomFrom, pointCustomTo, fetchTick])
|
|
3043
3179
|
|
|
3044
3180
|
const refreshPoints = useCallback(() => {
|
|
3045
3181
|
setPointStats(null)
|
|
@@ -3060,6 +3196,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3060
3196
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
|
|
3061
3197
|
}, [])
|
|
3062
3198
|
|
|
3199
|
+
// 切换时/分挡位;切到自定义挡且无历史输入时预填最近窗口,即点即用
|
|
3200
|
+
const selectPointPreset = (id) => {
|
|
3201
|
+
if (id === CUSTOM_RANGE_ID && !pointCustomFrom && !pointCustomTo) {
|
|
3202
|
+
setPointCustomFrom(formatDateTimeInput(new Date(Date.now() - POINT_CUSTOM_DEFAULT_HOURS * MS_PER_HOUR)))
|
|
3203
|
+
setPointCustomTo(formatDateTimeInput(new Date()))
|
|
3204
|
+
}
|
|
3205
|
+
if (view === 'hour') setHourPreset(id)
|
|
3206
|
+
else setMinutePreset(id)
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3063
3209
|
const refresh = () => {
|
|
3064
3210
|
setError('')
|
|
3065
3211
|
if (view === 'day') load()
|
|
@@ -3105,7 +3251,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3105
3251
|
}, [])
|
|
3106
3252
|
|
|
3107
3253
|
const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
|
|
3108
|
-
const pointView = pointStatsMatches(pointStats, view,
|
|
3254
|
+
const pointView = activeQuery && pointStatsMatches(pointStats, view, activeQuery.key) ? pointStats.value : null
|
|
3109
3255
|
const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
|
|
3110
3256
|
const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
|
|
3111
3257
|
|
|
@@ -3113,7 +3259,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3113
3259
|
const trendSource = pointActive
|
|
3114
3260
|
? (pointGrouped ? { slots: pointGrouped.daily, models: pointGrouped.models, value: pointView } : null)
|
|
3115
3261
|
: (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
|
|
3116
|
-
const maxSlots = pointActive ? maxSlotsFor(view,
|
|
3262
|
+
const maxSlots = pointActive ? maxSlotsFor(view, rawPreset) : DAY_MAX_SLOTS
|
|
3117
3263
|
const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
|
|
3118
3264
|
const notes = []
|
|
3119
3265
|
if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
|
|
@@ -3134,13 +3280,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3134
3280
|
onClick: () => setView(tab.id),
|
|
3135
3281
|
}, viewLabel(t, tab.id)))),
|
|
3136
3282
|
pointActive
|
|
3137
|
-
? h(
|
|
3138
|
-
(
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3283
|
+
? h(React.Fragment, null,
|
|
3284
|
+
h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
|
|
3285
|
+
(view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
|
|
3286
|
+
key: id,
|
|
3287
|
+
className: cx('ud-seg-item', rawPreset === id && 'ud-seg-item--on'),
|
|
3288
|
+
'aria-pressed': rawPreset === id,
|
|
3289
|
+
onClick: () => selectPointPreset(id),
|
|
3290
|
+
}, presetLabel(t, view, id))),
|
|
3291
|
+
h('button', {
|
|
3292
|
+
className: cx('ud-seg-item', rawPreset === CUSTOM_RANGE_ID && 'ud-seg-item--on'),
|
|
3293
|
+
'aria-pressed': rawPreset === CUSTOM_RANGE_ID,
|
|
3294
|
+
onClick: () => selectPointPreset(CUSTOM_RANGE_ID),
|
|
3295
|
+
}, t('rangeCustom'))),
|
|
3296
|
+
rawPreset === CUSTOM_RANGE_ID
|
|
3297
|
+
? h('div', { className: 'ud-custom-range' },
|
|
3298
|
+
h('input', {
|
|
3299
|
+
type: 'datetime-local', className: 'ud-date-input', 'aria-label': t('from'),
|
|
3300
|
+
value: pointCustomFrom, max: pointCustomTo || undefined,
|
|
3301
|
+
onChange: (event) => setPointCustomFrom(event.target.value),
|
|
3302
|
+
}),
|
|
3303
|
+
h('span', { className: 'ud-custom-sep' }, '–'),
|
|
3304
|
+
h('input', {
|
|
3305
|
+
type: 'datetime-local', className: 'ud-date-input', 'aria-label': t('to'),
|
|
3306
|
+
value: pointCustomTo, min: pointCustomFrom || undefined, max: formatDateTimeInput(new Date()),
|
|
3307
|
+
onChange: (event) => setPointCustomTo(event.target.value),
|
|
3308
|
+
}))
|
|
3309
|
+
: null)
|
|
3144
3310
|
: h(React.Fragment, null,
|
|
3145
3311
|
h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
|
|
3146
3312
|
DAY_PRESETS.map((id) => h('button', {
|