@mzzsfy/dsh-usage-dash 0.5.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 +1 -1
- package/package.json +1 -1
- package/src/client.js +142 -39
- package/test/client.test.mjs +144 -25
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
|
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.6.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 用量',
|
|
@@ -824,29 +891,33 @@ function heatLevel(tokens, max) {
|
|
|
824
891
|
return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
|
|
825
892
|
}
|
|
826
893
|
|
|
827
|
-
// ChartTip
|
|
894
|
+
// ChartTip 定位:进入命中区时锚定指针,先放指针右下,右侧或下方越界翻对侧,仍越界钳进边界
|
|
828
895
|
const TIP_GAP_PX = 8
|
|
829
896
|
const TIP_MARGIN_PX = 8
|
|
830
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
|
+
|
|
831
905
|
// 回合费用芯片与前置官方芯片(结束时钟)的间距
|
|
832
906
|
const TURN_COST_GAP_PX = 8
|
|
833
907
|
|
|
834
908
|
function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
|
|
835
909
|
if (!tip || tip.width <= 0 || tip.height <= 0) return null
|
|
836
|
-
if (!anchor || (anchor.
|
|
910
|
+
if (!anchor || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) return null
|
|
837
911
|
const minX = bounds.left + margin
|
|
838
912
|
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
|
|
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))
|
|
850
921
|
return { left, top }
|
|
851
922
|
}
|
|
852
923
|
|
|
@@ -2105,7 +2176,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2105
2176
|
const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
|
|
2106
2177
|
const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2107
2178
|
const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
|
|
2108
|
-
const pick = (index) => (event) => setHover({ index, anchor: event
|
|
2179
|
+
const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
|
|
2109
2180
|
const clear = () => setHover(null)
|
|
2110
2181
|
const otherEntries = hoverSlot
|
|
2111
2182
|
? Object.entries(hoverSlot.otherByModel ?? {}).sort((a, b) => b[1] - a[1])
|
|
@@ -2209,7 +2280,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2209
2280
|
? panel.getBoundingClientRect()
|
|
2210
2281
|
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
|
|
2211
2282
|
const next = tipPlace(
|
|
2212
|
-
anchor
|
|
2283
|
+
anchor,
|
|
2213
2284
|
{ width: tip.offsetWidth, height: tip.offsetHeight },
|
|
2214
2285
|
bounds,
|
|
2215
2286
|
)
|
|
@@ -2261,7 +2332,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2261
2332
|
const display = geom ? heatDisplayDays(days, geom.cols) : null
|
|
2262
2333
|
const grid = display ? heatGrid(display, geom.size) : null
|
|
2263
2334
|
const peak = display ? Math.max(1, ...display.map((slot) => slot.tokens)) : 1
|
|
2264
|
-
const pick = (cell) => (event) => setHover({ ...cell, anchor: event
|
|
2335
|
+
const pick = (cell) => (event) => setHover({ ...cell, anchor: pointerAt(event) })
|
|
2265
2336
|
const clear = () => setHover(null)
|
|
2266
2337
|
return h('div', { className: 'ud-section' },
|
|
2267
2338
|
h('div', { className: 'ud-section-head' },
|
|
@@ -2310,7 +2381,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2310
2381
|
const displayName = (model) => (model === OTHER_MODEL ? t('other') : model)
|
|
2311
2382
|
const pick = (model) => (event) => {
|
|
2312
2383
|
setHover(model)
|
|
2313
|
-
setTip({ model, anchor: event
|
|
2384
|
+
setTip({ model, anchor: pointerAt(event) })
|
|
2314
2385
|
}
|
|
2315
2386
|
const clear = () => {
|
|
2316
2387
|
setHover(null)
|
|
@@ -2952,6 +3023,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2952
3023
|
const [error, setError] = useState('')
|
|
2953
3024
|
const [hourPreset, setHourPreset] = useState(DEFAULT_HOUR_PRESET)
|
|
2954
3025
|
const [minutePreset, setMinutePreset] = useState(DEFAULT_MINUTE_PRESET)
|
|
3026
|
+
// 自定义挡 datetime 输入,时/分视图共享一对,各视图按桶粒度分别归一
|
|
3027
|
+
const [pointCustomFrom, setPointCustomFrom] = useState('')
|
|
3028
|
+
const [pointCustomTo, setPointCustomTo] = useState('')
|
|
2955
3029
|
const [pointStats, setPointStats] = useState(null)
|
|
2956
3030
|
const [pointStatus, setPointStatus] = useState('idle')
|
|
2957
3031
|
const [fetchTick, setFetchTick] = useState(0)
|
|
@@ -3018,17 +3092,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3018
3092
|
load()
|
|
3019
3093
|
}, [load])
|
|
3020
3094
|
|
|
3021
|
-
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)
|
|
3022
3098
|
|
|
3023
3099
|
useEffect(() => {
|
|
3024
|
-
if (
|
|
3025
|
-
if (pointStatsMatches(pointStats, view,
|
|
3026
|
-
const request = view === 'hour'
|
|
3027
|
-
? resolveHourRange(presetId, new Date())
|
|
3028
|
-
: resolveMinuteRange(presetId, new Date())
|
|
3100
|
+
if (!activeQuery) return
|
|
3101
|
+
if (pointStatsMatches(pointStats, view, activeQuery.key)) return
|
|
3029
3102
|
const generation = ++pointGenerationRef.current
|
|
3030
3103
|
setPointStatus('loading')
|
|
3031
|
-
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, request).then((result) => {
|
|
3104
|
+
requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, activeQuery.request).then((result) => {
|
|
3032
3105
|
if (pointGenerationRef.current !== generation) return
|
|
3033
3106
|
if (!result.ok) {
|
|
3034
3107
|
setPointStatus('error')
|
|
@@ -3037,9 +3110,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3037
3110
|
}
|
|
3038
3111
|
setError('')
|
|
3039
3112
|
setPointStatus('ok')
|
|
3040
|
-
setPointStats({ view, preset:
|
|
3113
|
+
setPointStats({ view, preset: activeQuery.key, value: result.value })
|
|
3041
3114
|
})
|
|
3042
|
-
}, [view,
|
|
3115
|
+
}, [view, rawPreset, pointCustomFrom, pointCustomTo, fetchTick])
|
|
3043
3116
|
|
|
3044
3117
|
const refreshPoints = useCallback(() => {
|
|
3045
3118
|
setPointStats(null)
|
|
@@ -3060,6 +3133,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3060
3133
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
|
|
3061
3134
|
}, [])
|
|
3062
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
|
+
|
|
3063
3146
|
const refresh = () => {
|
|
3064
3147
|
setError('')
|
|
3065
3148
|
if (view === 'day') load()
|
|
@@ -3105,7 +3188,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3105
3188
|
}, [])
|
|
3106
3189
|
|
|
3107
3190
|
const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
|
|
3108
|
-
const pointView = pointStatsMatches(pointStats, view,
|
|
3191
|
+
const pointView = activeQuery && pointStatsMatches(pointStats, view, activeQuery.key) ? pointStats.value : null
|
|
3109
3192
|
const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
|
|
3110
3193
|
const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
|
|
3111
3194
|
|
|
@@ -3113,7 +3196,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3113
3196
|
const trendSource = pointActive
|
|
3114
3197
|
? (pointGrouped ? { slots: pointGrouped.daily, models: pointGrouped.models, value: pointView } : null)
|
|
3115
3198
|
: (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
|
|
3116
|
-
const maxSlots = pointActive ? maxSlotsFor(view,
|
|
3199
|
+
const maxSlots = pointActive ? maxSlotsFor(view, rawPreset) : DAY_MAX_SLOTS
|
|
3117
3200
|
const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
|
|
3118
3201
|
const notes = []
|
|
3119
3202
|
if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
|
|
@@ -3134,13 +3217,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3134
3217
|
onClick: () => setView(tab.id),
|
|
3135
3218
|
}, viewLabel(t, tab.id)))),
|
|
3136
3219
|
pointActive
|
|
3137
|
-
? h(
|
|
3138
|
-
(
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
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)
|
|
3144
3247
|
: h(React.Fragment, null,
|
|
3145
3248
|
h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
|
|
3146
3249
|
DAY_PRESETS.map((id) => h('button', {
|
package/test/client.test.mjs
CHANGED
|
@@ -75,6 +75,7 @@ const {
|
|
|
75
75
|
otherDetailItems,
|
|
76
76
|
parseEnvelope,
|
|
77
77
|
partitionGroupOf,
|
|
78
|
+
pointerAt,
|
|
78
79
|
providerOf,
|
|
79
80
|
rateAxisTicks,
|
|
80
81
|
removeRulesAt,
|
|
@@ -82,6 +83,11 @@ const {
|
|
|
82
83
|
resolveDayRange,
|
|
83
84
|
resolveHourRange,
|
|
84
85
|
resolveMinuteRange,
|
|
86
|
+
resolveHourCustomRange,
|
|
87
|
+
resolveMinuteCustomRange,
|
|
88
|
+
resolvePointQuery,
|
|
89
|
+
formatDateTimeInput,
|
|
90
|
+
parseLocalDateTime,
|
|
85
91
|
shortDay,
|
|
86
92
|
smoothPath,
|
|
87
93
|
tipPlace,
|
|
@@ -177,6 +183,112 @@ test('pointStats 缓存命中须视图与挡位双匹配', () => {
|
|
|
177
183
|
assert.equal(pointStatsMatches(null, 'hour', '24h'), false)
|
|
178
184
|
})
|
|
179
185
|
|
|
186
|
+
// —— 时/分自定义时间范围:归一语义与预设挡一致(小时 floor 整点桶,分钟 from 对齐 10 分钟桶、to 保原分钟) ——
|
|
187
|
+
|
|
188
|
+
const CUSTOM_FROM = '2026-03-14T09:45'
|
|
189
|
+
const CUSTOM_TO = '2026-03-15T14:10'
|
|
190
|
+
|
|
191
|
+
test('hour 自定义范围两端 floor 到所在小时桶', () => {
|
|
192
|
+
// Given datetime 输入含分钟偏移 When 解析 Then 两端均取所在小时桶起点(闭区间)
|
|
193
|
+
assert.deepEqual(resolveHourCustomRange(CUSTOM_FROM, CUSTOM_TO), { from: '2026-03-14T09', to: '2026-03-15T14' })
|
|
194
|
+
assert.deepEqual(resolveHourCustomRange('2026-03-14T09:00', '2026-03-15T14:59'), { from: '2026-03-14T09', to: '2026-03-15T14' })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('hour 自定义跨度超保留期钳起点,临界窗经 floor 与原窗输出等价', () => {
|
|
198
|
+
// Given 跨度 16 天 When 解析 Then 起点钳到终点前 15 天整点;15 天余零头的窗钳后 floor 输出不变
|
|
199
|
+
assert.deepEqual(
|
|
200
|
+
resolveHourCustomRange('2026-02-27T00:00', '2026-03-15T14:10'),
|
|
201
|
+
{ from: '2026-02-28T14', to: '2026-03-15T14' },
|
|
202
|
+
)
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
resolveHourCustomRange('2026-02-28T14:00', '2026-03-15T14:10'),
|
|
205
|
+
{ from: '2026-02-28T14', to: '2026-03-15T14' },
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('hour 自定义非法输入返回 null', () => {
|
|
210
|
+
// Given 空值/垃圾串/from 晚于 to When 解析 Then 恒 null,不发请求
|
|
211
|
+
assert.equal(resolveHourCustomRange('', CUSTOM_TO), null)
|
|
212
|
+
assert.equal(resolveHourCustomRange(CUSTOM_FROM, ''), null)
|
|
213
|
+
assert.equal(resolveHourCustomRange('junk', CUSTOM_TO), null)
|
|
214
|
+
assert.equal(resolveHourCustomRange('2026-13-40T09:00', CUSTOM_TO), null)
|
|
215
|
+
assert.equal(resolveHourCustomRange(CUSTOM_TO, CUSTOM_FROM), null)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('parseLocalDateTime 拒绝数字分量回卷的日历非法值', () => {
|
|
219
|
+
// Given 超界分量(13 月/2 月 30 日/25 时)在 Date 构造下静默回卷 When 解析 Then 逐分量回读拦截为 null
|
|
220
|
+
assert.equal(parseLocalDateTime('2026-13-01T00:00'), null)
|
|
221
|
+
assert.equal(parseLocalDateTime('2026-02-30T10:00'), null)
|
|
222
|
+
assert.equal(parseLocalDateTime('2026-03-14T25:00'), null)
|
|
223
|
+
assert.deepEqual(parseLocalDateTime('2026-03-14T09:45'), new Date(2026, 2, 14, 9, 45))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('hour 自定义拒绝回卷后仍保序的非法日历输入', () => {
|
|
227
|
+
// Given 13 月回卷为次年 1 月且整体仍早于 to When 解析 Then 恒 null,不静默改写查询时段
|
|
228
|
+
assert.equal(resolveHourCustomRange('2026-13-01T00:00', '2027-06-01T00:00'), null)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('minute 自定义 from 对齐 10 分钟桶,to 保持原始分钟', () => {
|
|
232
|
+
// Given from 含非对齐分钟 When 解析 Then from floor 到桶边界,to 原值保留(闭区间上界)
|
|
233
|
+
assert.deepEqual(resolveMinuteCustomRange(CUSTOM_FROM, CUSTOM_TO), { from: '2026-03-14T09:40', to: '2026-03-15T14:10' })
|
|
234
|
+
assert.deepEqual(resolveMinuteCustomRange('2026-03-14T09:40', '2026-03-15T14:17'), { from: '2026-03-14T09:40', to: '2026-03-15T14:17' })
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('minute 自定义跨度超保留期钳起点', () => {
|
|
238
|
+
// Given 跨度 8 天 When 解析 Then 起点钳到终点前 7 天的 10 分钟桶边界
|
|
239
|
+
assert.deepEqual(
|
|
240
|
+
resolveMinuteCustomRange('2026-03-06T00:00', '2026-03-15T14:10'),
|
|
241
|
+
{ from: '2026-03-08T14:10', to: '2026-03-15T14:10' },
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('minute 自定义非法输入返回 null', () => {
|
|
246
|
+
assert.equal(resolveMinuteCustomRange('', CUSTOM_TO), null)
|
|
247
|
+
assert.equal(resolveMinuteCustomRange('junk', CUSTOM_TO), null)
|
|
248
|
+
assert.equal(resolveMinuteCustomRange(CUSTOM_TO, CUSTOM_FROM), null)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('resolvePointQuery 预设挡返回挡位键与滚动窗口请求', () => {
|
|
252
|
+
// Given 时/分预设挡 When 解析查询 Then 键为挡位 id,请求与既有 resolve 同构
|
|
253
|
+
const hour = resolvePointQuery('hour', '24h', '', '', NOW)
|
|
254
|
+
assert.equal(hour.key, '24h')
|
|
255
|
+
assert.deepEqual(hour.request, resolveHourRange('24h', NOW))
|
|
256
|
+
const minute = resolvePointQuery('minute', '3h', '', '', NOW)
|
|
257
|
+
assert.equal(minute.key, '3h')
|
|
258
|
+
assert.deepEqual(minute.request, resolveMinuteRange('3h', NOW))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('resolvePointQuery 自定义挡键含归一范围且随范围变化', () => {
|
|
262
|
+
// Given 同视图同挡不同范围 When 解析查询 Then 键互异;同范围键稳定(缓存可命中)
|
|
263
|
+
const first = resolvePointQuery('hour', 'custom', CUSTOM_FROM, CUSTOM_TO, NOW)
|
|
264
|
+
assert.deepEqual(first.request, { from: '2026-03-14T09', to: '2026-03-15T14' })
|
|
265
|
+
assert.equal(first.key, 'custom:2026-03-14T09:2026-03-15T14')
|
|
266
|
+
const again = resolvePointQuery('hour', 'custom', CUSTOM_FROM, CUSTOM_TO, NOW)
|
|
267
|
+
assert.equal(again.key, first.key)
|
|
268
|
+
const shifted = resolvePointQuery('hour', 'custom', '2026-03-14T10:45', CUSTOM_TO, NOW)
|
|
269
|
+
assert.notEqual(shifted.key, first.key)
|
|
270
|
+
const minute = resolvePointQuery('minute', 'custom', CUSTOM_FROM, CUSTOM_TO, NOW)
|
|
271
|
+
assert.deepEqual(minute.request, { from: '2026-03-14T09:40', to: '2026-03-15T14:10' })
|
|
272
|
+
assert.notEqual(minute.key, first.key)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('resolvePointQuery 自定义输入不完整返回 null', () => {
|
|
276
|
+
// Given 任一端为空 When 解析查询 Then null,面板不发请求不误清缓存
|
|
277
|
+
assert.equal(resolvePointQuery('hour', 'custom', '', CUSTOM_TO, NOW), null)
|
|
278
|
+
assert.equal(resolvePointQuery('minute', 'custom', CUSTOM_FROM, '', NOW), null)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('maxSlotsFor custom 挡取保留期桶数上限', () => {
|
|
282
|
+
// Given 自定义挡受保留期钳制 When 取渲染上限 Then 小时为 15 天小时桶数,分钟为 7 天 10 分钟桶数
|
|
283
|
+
assert.equal(maxSlotsFor('hour', 'custom'), 15 * 24 + 1)
|
|
284
|
+
assert.equal(maxSlotsFor('minute', 'custom'), (7 * 24 * 60) / 10 + 1)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('formatDateTimeInput 产出 datetime-local 分钟粒度值', () => {
|
|
288
|
+
assert.equal(formatDateTimeInput(new Date(2026, 2, 15, 9, 5)), '2026-03-15T09:05')
|
|
289
|
+
assert.equal(formatDateTimeInput(NOW), '2026-03-15T14:30')
|
|
290
|
+
})
|
|
291
|
+
|
|
180
292
|
test('每视图渲染上限等于闭区间桶数', () => {
|
|
181
293
|
assert.equal(maxSlotsFor('day', '90'), DAY_MAX_SLOTS)
|
|
182
294
|
assert.equal(maxSlotsFor('hour', '24h'), 25)
|
|
@@ -476,50 +588,57 @@ test('heatLevel 零值为空档其余按峰值四分位', () => {
|
|
|
476
588
|
assert.equal(heatLevel(1, 1), 5)
|
|
477
589
|
})
|
|
478
590
|
|
|
479
|
-
const TIP_ANCHOR = {
|
|
591
|
+
const TIP_ANCHOR = { x: 100, y: 100 }
|
|
480
592
|
const TIP_SIZE = { width: 80, height: 40 }
|
|
481
593
|
const TIP_BOUNDS = { left: 0, top: 0, right: 800, bottom: 600 }
|
|
482
594
|
|
|
483
|
-
test('tipPlace
|
|
484
|
-
assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left:
|
|
595
|
+
test('tipPlace 指针右下空位放右下', () => {
|
|
596
|
+
assert.deepEqual(tipPlace(TIP_ANCHOR, TIP_SIZE, TIP_BOUNDS), { left: 108, top: 108 })
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test('tipPlace 右侧越界翻左侧', () => {
|
|
600
|
+
assert.deepEqual(tipPlace({ x: 730, y: 100 }, TIP_SIZE, TIP_BOUNDS), { left: 642, top: 108 })
|
|
485
601
|
})
|
|
486
602
|
|
|
487
|
-
test('tipPlace
|
|
488
|
-
|
|
489
|
-
assert.deepEqual(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS), { left: 130, top: 108 })
|
|
603
|
+
test('tipPlace 下方越界翻上方', () => {
|
|
604
|
+
assert.deepEqual(tipPlace({ x: 100, y: 570 }, TIP_SIZE, TIP_BOUNDS), { left: 108, top: 522 })
|
|
490
605
|
})
|
|
491
606
|
|
|
492
|
-
test('tipPlace
|
|
493
|
-
|
|
494
|
-
assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 68)
|
|
607
|
+
test('tipPlace 右下均越界双翻', () => {
|
|
608
|
+
assert.deepEqual(tipPlace({ x: 730, y: 570 }, TIP_SIZE, TIP_BOUNDS), { left: 642, top: 522 })
|
|
495
609
|
})
|
|
496
610
|
|
|
497
|
-
test('tipPlace
|
|
498
|
-
assert.deepEqual(tipPlace({
|
|
499
|
-
assert.equal(tipPlace({ left: 100, top: 56, right: 140, bottom: 96 }, TIP_SIZE, TIP_BOUNDS).top, 8)
|
|
500
|
-
assert.equal(tipPlace({ left: 100, top: 8, right: 140, bottom: 48 }, TIP_SIZE, { left: 0, top: 0, right: 800, bottom: 104 }).top, 56)
|
|
611
|
+
test('tipPlace 双翻后仍越界钳进边界', () => {
|
|
612
|
+
assert.deepEqual(tipPlace({ x: 798, y: 598 }, TIP_SIZE, TIP_BOUNDS), { left: 710, top: 550 })
|
|
501
613
|
})
|
|
502
614
|
|
|
503
|
-
test('tipPlace
|
|
504
|
-
|
|
505
|
-
assert.equal(tipPlace(anchor, TIP_SIZE, TIP_BOUNDS).top, 492)
|
|
615
|
+
test('tipPlace 恰贴右缘等号归属放右侧', () => {
|
|
616
|
+
assert.deepEqual(tipPlace({ x: 704, y: 100 }, TIP_SIZE, TIP_BOUNDS), { left: 712, top: 108 })
|
|
506
617
|
})
|
|
507
618
|
|
|
508
|
-
test('tipPlace
|
|
509
|
-
|
|
510
|
-
const anchor = { left: 100, top: 20, right: 140, bottom: 50 }
|
|
511
|
-
assert.equal(tipPlace(anchor, TIP_SIZE, bounds).top, 8)
|
|
619
|
+
test('tipPlace 左上角指针放右下不翻转', () => {
|
|
620
|
+
assert.deepEqual(tipPlace({ x: 2, y: 2 }, TIP_SIZE, TIP_BOUNDS), { left: 10, top: 10 })
|
|
512
621
|
})
|
|
513
622
|
|
|
514
|
-
test('tipPlace
|
|
515
|
-
|
|
516
|
-
|
|
623
|
+
test('tipPlace 提示框宽于边界钳贴左缘', () => {
|
|
624
|
+
assert.equal(tipPlace({ x: 400, y: 100 }, { width: 900, height: 40 }, TIP_BOUNDS).left, 8)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('pointerAt 指针事件取指针坐标', () => {
|
|
628
|
+
assert.deepEqual(pointerAt({ clientX: 12, clientY: 34 }), { x: 12, y: 34 })
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('pointerAt 焦点事件回退目标矩形中心', () => {
|
|
632
|
+
const event = {
|
|
633
|
+
currentTarget: { getBoundingClientRect: () => ({ left: 10, top: 20, right: 30, bottom: 60 }) },
|
|
634
|
+
}
|
|
635
|
+
assert.deepEqual(pointerAt(event), { x: 20, y: 40 })
|
|
517
636
|
})
|
|
518
637
|
|
|
519
|
-
test('tipPlace
|
|
638
|
+
test('tipPlace 零尺寸或非法锚点返回隐藏', () => {
|
|
520
639
|
assert.equal(tipPlace(TIP_ANCHOR, { width: 0, height: 0 }, TIP_BOUNDS), null)
|
|
521
|
-
assert.equal(tipPlace({ left: 0, top: 0, right: 0, bottom: 0 }, TIP_SIZE, TIP_BOUNDS), null)
|
|
522
640
|
assert.equal(tipPlace(null, TIP_SIZE, TIP_BOUNDS), null)
|
|
641
|
+
assert.equal(tipPlace({ x: Number.NaN, y: 100 }, TIP_SIZE, TIP_BOUNDS), null)
|
|
523
642
|
})
|
|
524
643
|
|
|
525
644
|
// —— S8 命中率曲线 ——
|