dsh-usage-badge 0.1.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/lib/client.js ADDED
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * dsh-usage-badge — browser half.
3
+ *
4
+ * A single hand-written client bundle: it registers one entry into the layout's
5
+ * `shell.overlay` seat (the frame-wide floating layer the layout docs describe as
6
+ * the home for "a badge, a toast stack or a status pill") and renders the ¥ pill
7
+ * plus its dialog.
8
+ *
9
+ * No build step: the module is written directly in the loader's factory form, so
10
+ * `lib/client.js` is both the source and the artifact. Only `react` is required
11
+ * from the platform module table; no chart library is bundled, because the bars
12
+ * are plain flexbox.
13
+ *
14
+ * Two panels hang off the dialog:
15
+ * - 用量 — the ranges, the provider filter, the totals and the chart.
16
+ * - 单价配置 — the published price list fetched live on every open, the
17
+ * currency/rate fields, and the holiday calendar state.
18
+ */
19
+
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-usage-badge',
22
+ factory: (require) => {
23
+ const React = require('react')
24
+ const h = React.createElement
25
+
26
+ /** How often the pill re-reads the host snapshot. */
27
+ const POLL_MS = 5000
28
+
29
+ const RANGES = [
30
+ { id: '24h', label: '24 小时' },
31
+ { id: '7d', label: '近 7 日' },
32
+ { id: '30d', label: '近 30 日' },
33
+ { id: '12m', label: '近 12 个月' },
34
+ ]
35
+
36
+ const TABS = [
37
+ { id: 'usage', label: '用量' },
38
+ { id: 'config', label: '单价配置' },
39
+ ]
40
+
41
+ const OFFICIAL_SOURCES = [
42
+ { id: 'zh-cn', label: '中文页(人民币)' },
43
+ { id: 'en', label: 'English (USD)' },
44
+ ]
45
+
46
+ const CSS = `
47
+ .dub-pill{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;height:36px;padding:0 8px;border:0;border-radius:12px;background:0 0;color:var(--dsw-alias-label-secondary,#666);font:inherit;font-size:14px;line-height:22px;cursor:pointer;text-align:left}
48
+ .dub-pill:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));color:var(--dsw-alias-label-primary,#111)}
49
+ .dub-pill-rail{width:36px;height:36px;padding:0;justify-content:center;gap:0}
50
+ .dub-pill-rail .dub-pill-label,.dub-pill-rail .dub-pill-tokens,.dub-pill-rail .dub-dot{display:none}
51
+ .dub-pill-rail .dub-pill-amount{margin-left:0;font-size:11px;font-weight:600}
52
+ .dub-pill-off{opacity:.55}
53
+ .dub-pill-label{flex:none}
54
+ /* Peak/valley marker: grey off-peak, brand-orange while the peak multiplier is in
55
+ force. A plain flow item — an absolutely positioned one escapes the row. */
56
+ .dub-dot{flex:none;width:6px;height:6px;border-radius:3px;background:var(--dsw-alias-label-tertiary,#999)}
57
+ .dub-dot-peak{background:var(--dsw-alias-brand-primary,#d97706)}
58
+ .dub-pill-tokens{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-variant-numeric:tabular-nums}
59
+ .dub-pill-amount{flex:none;margin-left:6px;color:var(--dsw-alias-label-primary,#111);font-variant-numeric:tabular-nums;font-weight:500}
60
+ .dub-mask{position:fixed;inset:0;z-index:2147483600;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.34)}
61
+ .dub-box{width:min(720px,100vw - 48px);max-height:min(680px,100vh - 80px);display:flex;flex-direction:column;overflow:hidden;border-radius:14px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:var(--dsw-specific-menu,var(--dsw-alias-bg-l1,#fff));color:var(--dsw-alias-label-primary,#111);box-shadow:var(--dsw-elevation-prominent,0 12px 40px rgba(0,0,0,.28))}
62
+ .dub-head{display:flex;align-items:center;gap:10px;padding:14px 18px 10px}
63
+ .dub-title{font-size:14px;font-weight:600;flex:1}
64
+ .dub-chip{font-size:11px;padding:1px 7px;border-radius:9px;background:var(--dsw-alias-fill-l2,rgba(128,128,128,.14));color:var(--dsw-alias-label-secondary,#666);white-space:nowrap}
65
+ .dub-chip-peak{background:var(--dsw-alias-brand-primary,#d97706);color:#fff}
66
+ .dub-chip-holiday{background:#16a34a;color:#fff}
67
+ .dub-x{border:0;background:0 0;color:inherit;font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px;opacity:.7}
68
+ .dub-x:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}
69
+ .dub-tabs{display:flex;gap:6px;padding:0 18px 10px;flex-wrap:wrap;align-items:center}
70
+ .dub-tab{font-size:12px;padding:3px 10px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:0 0;color:inherit;cursor:pointer}
71
+ .dub-tab-on{background:var(--dsw-alias-fill-l2,rgba(128,128,128,.16));border-color:transparent;font-weight:600}
72
+ .dub-panetabs{padding:0 18px 8px;gap:0}
73
+ .dub-panetabs .dub-tab{border:0;border-bottom:2px solid transparent;border-radius:0;padding:4px 12px;font-size:13px;opacity:.7}
74
+ .dub-panetabs .dub-tab-on{background:0 0;border-bottom-color:var(--dsw-alias-label-primary,#111);font-weight:600;opacity:1}
75
+ .dub-sep{width:1px;height:16px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.25));margin:0 4px}
76
+ .dub-body{padding:0 18px 14px;overflow:auto}
77
+ .dub-stats{display:flex;gap:22px;flex-wrap:wrap;padding:10px 0 14px;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2))}
78
+ .dub-stat{display:flex;flex-direction:column;gap:2px}
79
+ .dub-stat-k{font-size:11px;color:var(--dsw-alias-label-tertiary,#999)}
80
+ .dub-stat-v{font-size:15px;font-weight:600;font-variant-numeric:tabular-nums}
81
+ .dub-chartwrap{padding-top:6px}
82
+ .dub-plot{position:relative}
83
+ .dub-legend{display:flex;gap:14px;flex-wrap:wrap;padding:2px 0 6px;font-size:11px;color:var(--dsw-alias-label-secondary,#666)}
84
+ .dub-legend-item{display:inline-flex;align-items:center;gap:5px}
85
+ .dub-legend-swatch{flex:none;width:11px;height:11px;border-radius:3px}
86
+ .dub-svg{display:block;width:100%;height:auto;overflow:visible}
87
+ .dub-grid{stroke:var(--dsw-alias-border-l1,rgba(128,128,128,.18));stroke-width:1}
88
+ .dub-guide{stroke:var(--dsw-alias-border-l3,rgba(128,128,128,.5));stroke-width:1;stroke-dasharray:3 3}
89
+ .dub-band-holiday{fill:rgba(22,163,74,.10)}
90
+ .dub-axis{fill:var(--dsw-alias-label-tertiary,#8b949e);font-size:10px;text-anchor:middle}
91
+ .dub-tip{position:absolute;top:18px;transform:translateX(-50%);pointer-events:none;box-sizing:border-box;width:172px;padding:8px 10px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:var(--dsw-specific-menu,var(--dsw-alias-bg-l1,#fff));box-shadow:0 6px 20px rgba(0,0,0,.18);font-size:11px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);z-index:2}
92
+ .dub-tip-title{font-weight:600;color:var(--dsw-alias-label-primary,#111);margin-bottom:2px}
93
+ .dub-tip-row{display:flex;align-items:center;gap:6px;white-space:nowrap}
94
+ .dub-tip-label{flex:1}
95
+ .dub-tip-value{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#111)}
96
+ .dub-empty{padding:34px 0;text-align:center;font-size:12px;color:var(--dsw-alias-label-tertiary,#999)}
97
+ .dub-block{border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));border-radius:10px;padding:12px 14px;margin:12px 0}
98
+ .dub-block-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}
99
+ .dub-block-title{font-size:13px;font-weight:600;flex:1}
100
+ .dub-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary,#666);padding:3px 0}
101
+ .dub-table{width:100%;border-collapse:collapse;font-size:12px;margin:6px 0}
102
+ .dub-table th{text-align:left;font-weight:600;color:var(--dsw-alias-label-secondary,#666);padding:4px 8px 4px 0;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));white-space:nowrap}
103
+ .dub-table td{padding:4px 8px 4px 0;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.12));font-variant-numeric:tabular-nums;white-space:nowrap}
104
+ .dub-table td.dub-model{font-family:var(--dsw-font-mono,monospace);color:var(--dsw-alias-label-primary,#111)}
105
+ .dub-note{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:1.5}
106
+ .dub-warn{color:#b45309;font-size:11px}
107
+ .dub-policy{font-size:11px;color:var(--dsw-alias-label-tertiary,#999);line-height:1.6;border-left:2px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));padding-left:8px;margin-top:6px}
108
+ .dub-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 18px;border-top:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));font-size:12px;color:var(--dsw-alias-label-secondary,#666)}
109
+ .dub-foot input,.dub-foot select,.dub-block input,.dub-block select{font:inherit;font-size:12px;padding:2px 6px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));background:var(--dsw-alias-bg-l1,transparent);color:inherit;width:88px}
110
+ .dub-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:7px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));background:0 0;color:inherit;cursor:pointer;white-space:nowrap}
111
+ .dub-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}
112
+ .dub-btn:disabled{opacity:.5;cursor:default}
113
+ .dub-btn-primary{border-color:transparent;background:var(--dsw-alias-brand-primary,#3b82f6);color:#fff}
114
+ .dub-btn-primary:hover{opacity:.9;background:var(--dsw-alias-brand-primary,#3b82f6)}
115
+ `
116
+
117
+ /** Inject the stylesheet once per document, guarded the way shipped plugins do. */
118
+ function ensureStyles() {
119
+ if (typeof document === 'undefined') return
120
+ const tagId = 'dsh-usage-badge/style.css'
121
+ if (document.querySelector(`style[data-plugin-css="${tagId}"]`)) return
122
+ const tag = document.createElement('style')
123
+ tag.dataset.plugin = 'dsh-usage-badge'
124
+ tag.dataset.pluginCss = tagId
125
+ tag.textContent = CSS
126
+ document.head.appendChild(tag)
127
+ }
128
+
129
+ const money = (value) => `¥${(Number(value) || 0).toFixed(2)}`
130
+
131
+ /** A per-million price, keeping the small cache-hit rates legible. */
132
+ function price(value, currency) {
133
+ const symbol = currency === 'usd' ? '$' : '¥'
134
+ const number = Number(value) || 0
135
+ return symbol + String(Number(number.toFixed(number >= 1 ? 2 : 4)))
136
+ }
137
+
138
+ /** Compact token counts, so a header row stays readable at every range. */
139
+ function tokens(value) {
140
+ const n = Number(value) || 0
141
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
142
+ if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`
143
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`
144
+ return String(n)
145
+ }
146
+
147
+ const totalTokens = (t) => (t.input || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) + (t.output || 0)
148
+
149
+ /** Share of input tokens served from cache; 0 when the range has no input. */
150
+ function hitRate(t) {
151
+ const input = (t.input || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0)
152
+ return input > 0 ? (t.cacheRead || 0) / input : 0
153
+ }
154
+
155
+ const EMPTY_TOTALS = { amount: 0, requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
156
+
157
+ /** One day entry reduced to a single provider (or kept whole). */
158
+ function entryTotals(entry, provider) {
159
+ if (!provider) return entry
160
+ return (entry.providers || []).find((p) => p.provider === provider) ?? EMPTY_TOTALS
161
+ }
162
+
163
+ function sumTotals(list) {
164
+ const out = { ...EMPTY_TOTALS }
165
+ for (const item of list) {
166
+ out.amount += item.amount || 0
167
+ out.requests += item.requests || 0
168
+ out.input += item.input || 0
169
+ out.cacheRead += item.cacheRead || 0
170
+ out.cacheWrite += item.cacheWrite || 0
171
+ out.output += item.output || 0
172
+ }
173
+ return out
174
+ }
175
+
176
+ /**
177
+ * Build the chart points and totals for one range under one provider filter.
178
+ *
179
+ * Each point carries all four plotted metrics, mirroring the desktop shell's
180
+ * chart (amount, token volume, request count and cache-hit rate). Every range
181
+ * answers from the same snapshot, so switching a tab or a provider never issues
182
+ * another request.
183
+ */
184
+ function buildSeries(snapshot, range, provider) {
185
+ /** One point from any of the shapes the host sends. */
186
+ const point = (label, title, src, dayClass) => ({
187
+ label,
188
+ title,
189
+ dayClass,
190
+ amount: Number(src.amount) || 0,
191
+ tokens: totalTokens(src),
192
+ requests: Number(src.requests) || 0,
193
+ hitRate: hitRate(src) * 100,
194
+ // Raw input-side buckets, so a range total can compute one exact hit rate.
195
+ input: (Number(src.input) || 0) + (Number(src.cacheRead) || 0) + (Number(src.cacheWrite) || 0),
196
+ cacheRead: Number(src.cacheRead) || 0,
197
+ })
198
+
199
+ if (range === '24h') {
200
+ const today = snapshot.today
201
+ const row = provider ? (today.providers || []).find((p) => p.provider === provider) : null
202
+ const hourly = (provider ? row?.hourly : today.hourly) || []
203
+ return {
204
+ points: hourly.map((slot) => point(String(slot.hour), `${String(slot.hour).padStart(2, '0')}:00`, slot)),
205
+ marks: false,
206
+ }
207
+ }
208
+
209
+ if (range === '12m') {
210
+ const months = new Map()
211
+ for (const day of snapshot.days) {
212
+ const key = day.date.slice(0, 7)
213
+ if (!months.has(key)) months.set(key, [])
214
+ months.get(key).push(entryTotals(day, provider))
215
+ }
216
+ const keys = [...months.keys()].sort().slice(-12)
217
+ return {
218
+ points: keys.map((key) => point(key.slice(2), key, sumTotals(months.get(key)))),
219
+ marks: false,
220
+ }
221
+ }
222
+
223
+ const count = range === '7d' ? 7 : 30
224
+ const slice = snapshot.days.slice(0, count).reverse()
225
+ return {
226
+ points: slice.map((day) =>
227
+ point(
228
+ day.date.slice(5),
229
+ `${day.date}${day.dayName ? ` ${day.dayName}` : ''}`,
230
+ entryTotals(day, provider),
231
+ day.dayClass,
232
+ ),
233
+ ),
234
+ marks: true,
235
+ }
236
+ }
237
+
238
+ /** Which x labels to draw: all when few, otherwise an even stride. */
239
+ function labelVisible(index, length) {
240
+ if (length <= 12) return true
241
+ const stride = Math.ceil(length / 12)
242
+ return index % stride === 0 || index === length - 1
243
+ }
244
+
245
+ // ── the chart ─────────────────────────────────────────────────────────────
246
+ // Plotted metrics and their styling, copied from the desktop shell's chart so
247
+ // the two read the same: blue amount (filled), green token volume, purple
248
+ // request count, dashed amber cache-hit rate on its own 0–100 scale.
249
+ const SERIES = [
250
+ { key: 'amount', label: '金额(¥)', color: '#1f6feb', fill: 'rgba(31,111,235,0.10)', radius: 3, format: (v) => money(v) },
251
+ { key: 'tokens', label: 'token 总量', color: '#2da44e', radius: 3, format: (v) => tokens(v) },
252
+ { key: 'requests', label: '请求数', color: '#8250df', radius: 2, format: (v) => String(v) },
253
+ { key: 'hitRate', label: '缓存命中率(%)', color: '#e8930c', dash: '5 3', radius: 3, max: 100, format: (v) => `${v.toFixed(1)}%` },
254
+ ]
255
+
256
+ const CHART = { width: 640, height: 180, top: 10, bottom: 24, side: 10, tension: 0.35 }
257
+
258
+ /** Horizontal position of point `index` of `count` inside the viewBox. */
259
+ const chartX = (index, count) => {
260
+ const span = CHART.width - CHART.side * 2
261
+ return count <= 1 ? CHART.side + span / 2 : CHART.side + (index / (count - 1)) * span
262
+ }
263
+
264
+ /** Vertical position of `value` under a series' own scale. */
265
+ const chartY = (value, max) => {
266
+ const span = CHART.height - CHART.top - CHART.bottom
267
+ const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0
268
+ return CHART.top + span - ratio * span
269
+ }
270
+
271
+ /**
272
+ * A smoothed path through the points, using the same cardinal-spline shape
273
+ * Chart.js draws at `tension: 0.35`.
274
+ */
275
+ function smoothPath(points) {
276
+ if (points.length === 0) return ''
277
+ if (points.length < 3) return `M${points.map((p) => `${p.x},${p.y}`).join('L')}`
278
+ const t = CHART.tension
279
+ let d = `M${points[0].x},${points[0].y}`
280
+ for (let i = 0; i < points.length - 1; i++) {
281
+ const p0 = points[i - 1] ?? points[i]
282
+ const p1 = points[i]
283
+ const p2 = points[i + 1]
284
+ const p3 = points[i + 2] ?? p2
285
+ const c1x = p1.x + ((p2.x - p0.x) / 6) * t * 2
286
+ const c1y = p1.y + ((p2.y - p0.y) / 6) * t * 2
287
+ const c2x = p2.x - ((p3.x - p1.x) / 6) * t * 2
288
+ const c2y = p2.y - ((p3.y - p1.y) / 6) * t * 2
289
+ d += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`
290
+ }
291
+ return d
292
+ }
293
+
294
+ /**
295
+ * The usage chart: four smoothed series over a shared index axis, with a
296
+ * hover tooltip that reports every series at the hovered index — the same
297
+ * `interaction: { mode: 'index' }` behavior the shell's chart has.
298
+ */
299
+ function UsageChart({ points }) {
300
+ const [hover, setHover] = React.useState(null)
301
+ const count = points.length
302
+ const hasData = count > 0 && points.some((p) => SERIES.some((s) => (p[s.key] || 0) > 0))
303
+ if (!hasData) return h('div', { className: 'dub-empty' }, '该区间暂无用量')
304
+
305
+ const plotted = SERIES.map((series) => {
306
+ const max = series.max ?? Math.max(...points.map((p) => p[series.key] || 0), 0)
307
+ const coords = points.map((p, index) => ({ x: chartX(index, count), y: chartY(p[series.key] || 0, max || 1) }))
308
+ return { series, coords, max }
309
+ })
310
+
311
+ const gridY = [0, 0.25, 0.5, 0.75, 1].map((ratio) => CHART.top + ratio * (CHART.height - CHART.top - CHART.bottom))
312
+ const baseline = CHART.height - CHART.bottom
313
+ const amountSeries = plotted[0]
314
+
315
+ /** Nearest index for a pointer position, from the viewBox x it maps to. */
316
+ const indexAt = (event) => {
317
+ const box = event.currentTarget.getBoundingClientRect()
318
+ const ratio = box.width > 0 ? (event.clientX - box.left) / box.width : 0
319
+ const x = ratio * CHART.width
320
+ const span = CHART.width - CHART.side * 2
321
+ const raw = count <= 1 ? 0 : ((x - CHART.side) / span) * (count - 1)
322
+ return Math.min(count - 1, Math.max(0, Math.round(raw)))
323
+ }
324
+
325
+ const hovered = hover === null ? null : points[hover]
326
+
327
+ return h(
328
+ 'div',
329
+ { className: 'dub-chartwrap' },
330
+ h(
331
+ 'div',
332
+ { className: 'dub-legend' },
333
+ SERIES.map((series) =>
334
+ h(
335
+ 'span',
336
+ { className: 'dub-legend-item', key: series.key },
337
+ h('span', { className: 'dub-legend-swatch', style: { background: series.color } }),
338
+ series.label,
339
+ ),
340
+ ),
341
+ ),
342
+ h(
343
+ 'div',
344
+ { className: 'dub-plot' },
345
+ h(
346
+ 'svg',
347
+ {
348
+ className: 'dub-svg',
349
+ viewBox: `0 0 ${CHART.width} ${CHART.height}`,
350
+ onMouseMove: (event) => setHover(indexAt(event)),
351
+ onMouseLeave: () => setHover(null),
352
+ },
353
+ // Faint horizontal grid, as the shell's left value axis draws.
354
+ gridY.map((y, index) =>
355
+ h('line', {
356
+ key: `grid${index}`,
357
+ x1: CHART.side,
358
+ x2: CHART.width - CHART.side,
359
+ y1: y,
360
+ y2: y,
361
+ className: 'dub-grid',
362
+ }),
363
+ ),
364
+ // Public holidays get a full-height band behind the series.
365
+ points.map((p, index) =>
366
+ p.dayClass === 'holiday'
367
+ ? h('rect', {
368
+ key: `mark${index}`,
369
+ x: chartX(index, count) - (CHART.width - CHART.side * 2) / Math.max(1, count) / 2,
370
+ y: CHART.top,
371
+ width: Math.max(2, (CHART.width - CHART.side * 2) / Math.max(1, count)),
372
+ height: baseline - CHART.top,
373
+ className: 'dub-band-holiday',
374
+ })
375
+ : null,
376
+ ),
377
+ // The amount series is filled, matching the shell's filled first dataset.
378
+ h('path', {
379
+ d: `${smoothPath(amountSeries.coords)}L${amountSeries.coords.at(-1)?.x ?? CHART.side},${baseline}L${amountSeries.coords[0]?.x ?? CHART.side},${baseline}Z`,
380
+ fill: amountSeries.series.fill,
381
+ stroke: 'none',
382
+ }),
383
+ ...plotted.map(({ series, coords }) =>
384
+ h('path', {
385
+ key: series.key,
386
+ d: smoothPath(coords),
387
+ fill: 'none',
388
+ stroke: series.color,
389
+ strokeWidth: 2,
390
+ strokeDasharray: series.dash,
391
+ strokeLinecap: 'round',
392
+ }),
393
+ ),
394
+ ...plotted.flatMap(({ series, coords }) =>
395
+ coords.map((c, index) =>
396
+ h('circle', {
397
+ key: `${series.key}-${index}`,
398
+ cx: c.x,
399
+ cy: c.y,
400
+ r: series.radius,
401
+ fill: '#fff',
402
+ stroke: series.color,
403
+ strokeWidth: 2,
404
+ }),
405
+ ),
406
+ ),
407
+ // Guide line at the hovered index.
408
+ hovered
409
+ ? h('line', {
410
+ x1: chartX(hover, count),
411
+ x2: chartX(hover, count),
412
+ y1: CHART.top,
413
+ y2: baseline,
414
+ className: 'dub-guide',
415
+ })
416
+ : null,
417
+ // X labels, thinned the way `maxTicksLimit` thins them.
418
+ points.map((p, index) =>
419
+ labelVisible(index, count)
420
+ ? h(
421
+ 'text',
422
+ { key: `lab${index}`, x: chartX(index, count), y: CHART.height - 6, className: 'dub-axis' },
423
+ p.label,
424
+ )
425
+ : null,
426
+ ),
427
+ ),
428
+ hovered
429
+ ? h(
430
+ 'div',
431
+ {
432
+ className: 'dub-tip',
433
+ // The tooltip is centre-anchored on its point, so at either end half
434
+ // of it would fall outside the dialog's clipped box. The clamp keeps
435
+ // it fully inside however narrow the panel gets, in one declaration:
436
+ // 86px is half of the tooltip's own width.
437
+ style: { left: `clamp(86px, ${(chartX(hover, count) / CHART.width) * 100}%, calc(100% - 86px))` },
438
+ },
439
+ h('div', { className: 'dub-tip-title' }, hovered.title),
440
+ SERIES.map((series) =>
441
+ h(
442
+ 'div',
443
+ { className: 'dub-tip-row', key: series.key },
444
+ h('span', { className: 'dub-legend-swatch', style: { background: series.color } }),
445
+ h('span', { className: 'dub-tip-label' }, series.label),
446
+ h('span', { className: 'dub-tip-value' }, series.format(hovered[series.key] || 0)),
447
+ ),
448
+ ),
449
+ )
450
+ : null,
451
+ ),
452
+ )
453
+ }
454
+
455
+ /** The period badge: the holiday calendar outranks the peak/valley window. */
456
+ function bandChip(snapshot) {
457
+ const calendar = snapshot.calendar
458
+ const band = snapshot.band
459
+ const today = calendar?.today
460
+ if (calendar?.enabled && today?.class === 'holiday') {
461
+ return { text: `${today.name ?? '法定节假日'} · 全天空闲`, peak: false, holiday: true }
462
+ }
463
+ if (band?.enabled) return { text: `${band.peak ? '峰时' : '谷时'} ×${band.multiplier}`, peak: band.peak, holiday: false }
464
+ return null
465
+ }
466
+
467
+ function Stat({ label, value }) {
468
+ return h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, label), h('div', { className: 'dub-stat-v' }, value))
469
+ }
470
+
471
+ /** The 用量 panel: ranges, provider filter, totals and the chart. */
472
+ function UsagePanel({ snapshot }) {
473
+ const [range, setRange] = React.useState('24h')
474
+ const [provider, setProvider] = React.useState('')
475
+ const providers = snapshot.today.providers || []
476
+ const series = buildSeries(snapshot, range, provider)
477
+ // The stat cards describe exactly what the chart plots, so they are summed
478
+ // from the same points rather than read from a separate total. Summing the
479
+ // raw token buckets keeps the aggregate hit rate exact instead of averaging
480
+ // per-point percentages.
481
+ const totals = series.points.reduce(
482
+ (acc, item) => ({
483
+ amount: acc.amount + item.amount,
484
+ requests: acc.requests + item.requests,
485
+ tokens: acc.tokens + item.tokens,
486
+ input: acc.input + item.input,
487
+ cacheRead: acc.cacheRead + item.cacheRead,
488
+ }),
489
+ { amount: 0, requests: 0, tokens: 0, input: 0, cacheRead: 0 },
490
+ )
491
+ const hitPercent = totals.input > 0 ? (totals.cacheRead / totals.input) * 100 : 0
492
+
493
+ return h(
494
+ React.Fragment,
495
+ null,
496
+ h(
497
+ 'div',
498
+ { className: 'dub-tabs' },
499
+ RANGES.map((item) =>
500
+ h(
501
+ 'button',
502
+ { key: item.id, className: `dub-tab${range === item.id ? ' dub-tab-on' : ''}`, onClick: () => setRange(item.id) },
503
+ item.label,
504
+ ),
505
+ ),
506
+ providers.length > 1 ? h('span', { className: 'dub-sep' }) : null,
507
+ providers.length > 1
508
+ ? [
509
+ h(
510
+ 'button',
511
+ { key: '__all', className: `dub-tab${provider === '' ? ' dub-tab-on' : ''}`, onClick: () => setProvider('') },
512
+ '全部',
513
+ ),
514
+ ...providers.map((row) =>
515
+ h(
516
+ 'button',
517
+ {
518
+ key: row.provider,
519
+ className: `dub-tab${provider === row.provider ? ' dub-tab-on' : ''}`,
520
+ onClick: () => setProvider(row.provider),
521
+ },
522
+ row.provider,
523
+ ),
524
+ ),
525
+ ]
526
+ : null,
527
+ ),
528
+ h(
529
+ 'div',
530
+ { className: 'dub-body' },
531
+ h(
532
+ 'div',
533
+ { className: 'dub-stats' },
534
+ h(Stat, { label: '金额', value: money(totals.amount) }),
535
+ h(Stat, { label: '请求数', value: String(totals.requests || 0) }),
536
+ h(Stat, { label: 'Token', value: tokens(totals.tokens) }),
537
+ h(Stat, { label: '缓存命中率', value: `${hitPercent.toFixed(1)}%` }),
538
+ ),
539
+ h(UsageChart, { points: series.points }),
540
+ series.marks
541
+ ? h('div', { className: 'dub-note', style: { paddingTop: '8px' } }, '绿色背景色带标示法定节假日(全天按空闲时段计价)')
542
+ : null,
543
+ ),
544
+ )
545
+ }
546
+
547
+ /**
548
+ * The 单价配置 panel.
549
+ *
550
+ * The published price list is fetched live every time this panel opens, which
551
+ * is what makes the official numbers visible without a background poll. The
552
+ * host coalesces requests that land within a few seconds of each other so
553
+ * reopening the panel cannot hammer the docs site.
554
+ */
555
+ function ConfigPanel({ snapshot, reload, initialConfig = null, initialOfficial = null }) {
556
+ const [source, setSource] = React.useState('zh-cn')
557
+ const [official, setOfficial] = React.useState(initialOfficial)
558
+ const [officialState, setOfficialState] = React.useState(initialOfficial ? 'ready' : 'loading')
559
+ const [applyState, setApplyState] = React.useState('idle')
560
+ const [config, setConfig] = React.useState(initialConfig)
561
+
562
+ const loadOfficial = React.useCallback(
563
+ async (which) => {
564
+ setOfficialState('loading')
565
+ try {
566
+ const response = await fetch(`/usage-badge/official-pricing?refresh=1&source=${which}`, { cache: 'no-store' })
567
+ const body = await response.json()
568
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
569
+ setOfficial(body)
570
+ setOfficialState('ready')
571
+ } catch (error) {
572
+ setOfficial(null)
573
+ setOfficialState(`failed: ${error?.message ?? error}`)
574
+ }
575
+ },
576
+ [],
577
+ )
578
+
579
+ const loadConfig = React.useCallback(async () => {
580
+ try {
581
+ const response = await fetch('/usage-badge/config', { cache: 'no-store' })
582
+ if (response.ok) setConfig(await response.json())
583
+ } catch {
584
+ // The effective config is informative here; a failure leaves the rest of
585
+ // the panel usable.
586
+ }
587
+ }, [])
588
+
589
+ React.useEffect(() => {
590
+ void loadOfficial(source)
591
+ }, [loadOfficial, source])
592
+
593
+ React.useEffect(() => {
594
+ void loadConfig()
595
+ }, [loadConfig])
596
+
597
+ const applyOfficial = async () => {
598
+ setApplyState('saving')
599
+ try {
600
+ const response = await fetch('/usage-badge/official-pricing/apply', {
601
+ method: 'POST',
602
+ headers: { 'content-type': 'application/json' },
603
+ body: JSON.stringify({ source }),
604
+ })
605
+ const body = await response.json()
606
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
607
+ setApplyState('applied')
608
+ await loadConfig()
609
+ reload()
610
+ } catch (error) {
611
+ setApplyState(`failed: ${error?.message ?? error}`)
612
+ }
613
+ }
614
+
615
+ const effective = config?.effective
616
+ const calendar = config?.calendar ?? snapshot.calendar
617
+ const bundled = config?.bundled
618
+ const overrides = Object.entries(effective?.overrides ?? {})
619
+ const rowsWithMultiplier = overrides.some(([, row]) => row?.multiplier != null && Number(row.multiplier) !== 1)
620
+
621
+ const policy = official?.policy
622
+ const windowsSource = policy ? policy.peakRangesSource.map(([s, e]) => `${String(s).padStart(2, '0')}:00–${String(e).padStart(2, '0')}:00`).join('、') : ''
623
+ const windowsLocal = policy ? policy.peakRangesLocal.map(([s, e]) => `${String(s).padStart(2, '0')}:00–${String(e).padStart(2, '0')}:00`).join('、') : ''
624
+
625
+ return h(
626
+ 'div',
627
+ { className: 'dub-body' },
628
+ // ── the published price list ──────────────────────────────────────────
629
+ h(
630
+ 'div',
631
+ { className: 'dub-block' },
632
+ h(
633
+ 'div',
634
+ { className: 'dub-block-head' },
635
+ h('div', { className: 'dub-block-title' }, '官方定价'),
636
+ h(
637
+ 'select',
638
+ { value: source, onChange: (event) => setSource(event.target.value), title: '官方页面语言(决定币种)' },
639
+ OFFICIAL_SOURCES.map((item) => h('option', { key: item.id, value: item.id }, item.label)),
640
+ ),
641
+ h('button', { className: 'dub-btn', onClick: () => void loadOfficial(source), disabled: officialState === 'loading' }, '重新获取'),
642
+ h(
643
+ 'button',
644
+ {
645
+ className: 'dub-btn dub-btn-primary',
646
+ onClick: applyOfficial,
647
+ disabled: officialState !== 'ready' || applyState === 'saving',
648
+ title: '把官方单价写入本插件的 pricing.json(会覆盖同名模型的单价行)',
649
+ },
650
+ '应用官方价格',
651
+ ),
652
+ ),
653
+ officialState === 'loading' ? h('div', { className: 'dub-note' }, '正在获取官方定价…') : null,
654
+ officialState.startsWith('failed') ? h('div', { className: 'dub-warn' }, `获取失败:${officialState.slice(8)}`) : null,
655
+ applyState === 'applied' ? h('div', { className: 'dub-note' }, '已应用并写入 pricing.json') : null,
656
+ applyState.startsWith('failed') ? h('div', { className: 'dub-warn' }, `应用失败:${applyState.slice(8)}`) : null,
657
+ h(
658
+ 'div',
659
+ { className: 'dub-row' },
660
+ h('span', { className: 'dub-note' }, '这是唯一的单价来源:汇率、币种、倍率都不需要配置(官方中文页即人民币,徽标也始终是人民币)。'),
661
+ ),
662
+ official
663
+ ? [
664
+ h(
665
+ 'table',
666
+ { className: 'dub-table', key: 'table' },
667
+ h(
668
+ 'thead',
669
+ null,
670
+ h(
671
+ 'tr',
672
+ null,
673
+ h('th', null, '模型'),
674
+ h('th', null, '空闲 · 输入 / 缓存 / 输出'),
675
+ h('th', null, '高峰 · 输入 / 缓存 / 输出'),
676
+ ),
677
+ ),
678
+ h(
679
+ 'tbody',
680
+ null,
681
+ official.models.map((model) =>
682
+ h(
683
+ 'tr',
684
+ { key: model.model },
685
+ h('td', { className: 'dub-model' }, model.model),
686
+ h(
687
+ 'td',
688
+ null,
689
+ `${price(model.offPeak.input, official.currency)} / ${price(model.offPeak.cacheRead, official.currency)} / ${price(model.offPeak.output, official.currency)}`,
690
+ ),
691
+ h(
692
+ 'td',
693
+ null,
694
+ `${price(model.peak.input, official.currency)} / ${price(model.peak.cacheRead, official.currency)} / ${price(model.peak.output, official.currency)}`,
695
+ ),
696
+ ),
697
+ ),
698
+ ),
699
+ ),
700
+ h(
701
+ 'div',
702
+ { className: 'dub-row', key: 'meta' },
703
+ h('span', { className: 'dub-note' }, `单位:百万 tokens · 币种 ${official.currency.toUpperCase()} · 获取于 ${new Date(official.fetchedAt).toLocaleTimeString()}`),
704
+ ),
705
+ official.aliases?.names?.length
706
+ ? h(
707
+ 'div',
708
+ { className: 'dub-row', key: 'alias' },
709
+ h('span', { className: 'dub-note' }, `旧模型名(仍按 ${official.aliases.target} 计价):${official.aliases.names.join('、')}`),
710
+ )
711
+ : null,
712
+ h(
713
+ 'div',
714
+ { className: 'dub-policy', key: 'policy' },
715
+ h('div', null, `高峰时段:${policy.sourceZone ? `${policy.sourceZone.name} ` : ''}${windowsSource}`),
716
+ policy.peakRangesLocal.join() !== policy.peakRangesSource.join()
717
+ ? h('div', null, `本机时区换算后:${windowsLocal}`)
718
+ : null,
719
+ h('div', null, policy.offPeakIsHalfOfPeak ? '空闲时段价格为高峰时段价格的一半。' : ''),
720
+ h('div', null, policy.holidaysOffPeak ? '周末与中国法定节假日全天均为空闲时段。' : ''),
721
+ ),
722
+ ]
723
+ : null,
724
+ ),
725
+
726
+ // ── the holiday calendar ──────────────────────────────────────────────
727
+ h(
728
+ 'div',
729
+ { className: 'dub-block' },
730
+ h(
731
+ 'div',
732
+ { className: 'dub-block-head' },
733
+ h('div', { className: 'dub-block-title' }, '法定节假日日历'),
734
+ h('span', { className: `dub-chip${calendar?.enabled ? ' dub-chip-holiday' : ''}` }, calendar?.enabled ? '已启用' : '未启用'),
735
+ ),
736
+ // What the bundled table covers is shown whether or not the calendar is
737
+ // on, so switching it on is an informed choice.
738
+ bundled
739
+ ? h(
740
+ 'div',
741
+ { className: 'dub-row' },
742
+ h(
743
+ 'span',
744
+ { className: 'dub-note' },
745
+ `内置日历覆盖 ${bundled.years.join('、')}:${bundled.holidayCount} 个法定节假日(另收录 ${bundled.workdayCount} 个调休上班日,按官方口径不影响计价)。`,
746
+ ),
747
+ )
748
+ : null,
749
+ calendar?.enabled
750
+ ? h(
751
+ 'div',
752
+ { className: 'dub-row' },
753
+ h(
754
+ 'span',
755
+ { className: 'dub-note' },
756
+ `今天:${
757
+ calendar.today.class === 'holiday'
758
+ ? `${calendar.today.name ?? '法定节假日'}(全天按空闲时段计价)`
759
+ : '按「周一至周五」判峰谷'
760
+ }`,
761
+ ),
762
+ )
763
+ : h(
764
+ 'div',
765
+ { className: 'dub-row' },
766
+ h('span', { className: 'dub-note' }, '未启用时只按「周一至周五」判断,中国法定节假日会被当成普通工作日计费;点「应用官方价格」会自动启用。'),
767
+ ),
768
+ bundled?.source && Object.keys(bundled.source).length
769
+ ? h(
770
+ 'div',
771
+ { className: 'dub-row' },
772
+ h(
773
+ 'span',
774
+ { className: 'dub-note' },
775
+ `数据来源:${Object.entries(bundled.source).map(([year, meta]) => `${year} ${meta.document}`).join(';')}`,
776
+ ),
777
+ )
778
+ : null,
779
+ calendar?.missingYears?.length
780
+ ? h('div', { className: 'dub-warn' }, `日历缺少 ${calendar.missingYears.join('、')} 年的数据(尚未公布或未收录),这些日期按普通工作日处理。`)
781
+ : null,
782
+ h(
783
+ 'div',
784
+ { className: 'dub-row' },
785
+ h('span', { className: 'dub-note' }, '自定义日期可在 pricing.json 的 holidays.extra 中追加。'),
786
+ ),
787
+ ),
788
+
789
+ // ── what is in effect ────────────────────────────────────────────────
790
+ // Read-only on purpose: prices come from the published list above, so
791
+ // there is nothing to maintain by hand here.
792
+ h(
793
+ 'div',
794
+ { className: 'dub-block' },
795
+ h(
796
+ 'div',
797
+ { className: 'dub-block-head' },
798
+ h('div', { className: 'dub-block-title' }, '当前生效单价'),
799
+ h('span', { className: 'dub-chip' }, '只读'),
800
+ effective
801
+ ? h('span', { className: 'dub-note' }, `default: 输入 ${price(effective.default?.inputPerMillion, effective.default?.currency)} · 缓存 ${price(effective.default?.cacheReadPerMillion, effective.default?.currency)} · 输出 ${price(effective.default?.outputPerMillion, effective.default?.currency)}`)
802
+ : null,
803
+ ),
804
+ !effective
805
+ ? h('div', { className: 'dub-note' }, '正在读取当前配置…')
806
+ : overrides.length === 0
807
+ ? h('div', { className: 'dub-note' }, '没有覆盖行,全部模型按 default 计价。')
808
+ : h(
809
+ 'table',
810
+ { className: 'dub-table' },
811
+ h('thead', null, h('tr', null, h('th', null, '键'), h('th', null, '输入'), h('th', null, '缓存命中'), h('th', null, '输出'), rowsWithMultiplier ? h('th', null, '倍率') : null)),
812
+ h(
813
+ 'tbody',
814
+ null,
815
+ overrides.map(([key, row]) =>
816
+ h(
817
+ 'tr',
818
+ { key },
819
+ h('td', { className: 'dub-model' }, key),
820
+ h('td', null, row.inputPerMillion ?? '—'),
821
+ h('td', null, row.cacheReadPerMillion ?? '—'),
822
+ h('td', null, row.outputPerMillion ?? '—'),
823
+ // The multiplier column only appears when a row actually
824
+ // carries one, so a pure official-price setup stays clean but
825
+ // a factor that changes the number is never invisible.
826
+ rowsWithMultiplier ? h('td', null, row.multiplier ?? '—') : null,
827
+ ),
828
+ ),
829
+ ),
830
+ ),
831
+ h(
832
+ 'div',
833
+ { className: 'dub-row' },
834
+ h('span', { className: 'dub-note' }, '单价由上面的官方定价决定,这里只做展示;「应用官方价格」会按官方页面覆盖同名模型的行。'),
835
+ ),
836
+ config?.paths?.dataDir
837
+ ? h(
838
+ 'div',
839
+ { className: 'dub-row' },
840
+ h(
841
+ 'span',
842
+ { className: 'dub-note' },
843
+ `本插件的全部文件都在:${String(config.paths.dataDir)
844
+ .replace(/^.*[\\/]\.dsh[\\/]/, '~/.dsh/')
845
+ .replace(/\\/g, '/')}/`,
846
+ ),
847
+ )
848
+ : null,
849
+ ),
850
+ )
851
+ }
852
+
853
+ function UsageDialog({ snapshot, onClose, reload, initialTab = 'usage', initialConfig = null, initialOfficial = null }) {
854
+ const [tab, setTab] = React.useState(initialTab)
855
+ const chip = bandChip(snapshot)
856
+
857
+ React.useEffect(() => {
858
+ const onKey = (event) => {
859
+ if (event.key === 'Escape') onClose()
860
+ }
861
+ window.addEventListener('keydown', onKey)
862
+ return () => window.removeEventListener('keydown', onKey)
863
+ }, [onClose])
864
+
865
+ return h(
866
+ 'div',
867
+ { className: 'dub-mask', onClick: (event) => event.target === event.currentTarget && onClose() },
868
+ h(
869
+ 'div',
870
+ { className: 'dub-box' },
871
+ h(
872
+ 'div',
873
+ { className: 'dub-head' },
874
+ h('div', { className: 'dub-title' }, '用量统计'),
875
+ h('span', { className: 'dub-chip' }, snapshot.badge.date),
876
+ chip
877
+ ? h(
878
+ 'span',
879
+ { className: `dub-chip${chip.peak ? ' dub-chip-peak' : ''}${chip.holiday ? ' dub-chip-holiday' : ''}` },
880
+ chip.text,
881
+ )
882
+ : null,
883
+ h('button', { className: 'dub-x', onClick: onClose, title: '关闭' }, '×'),
884
+ ),
885
+ h(
886
+ 'div',
887
+ { className: 'dub-tabs dub-panetabs' },
888
+ TABS.map((item) =>
889
+ h(
890
+ 'button',
891
+ { key: item.id, className: `dub-tab${tab === item.id ? ' dub-tab-on' : ''}`, onClick: () => setTab(item.id) },
892
+ item.label,
893
+ ),
894
+ ),
895
+ ),
896
+ // Only the active panel is mounted, so returning to 单价配置 re-fetches
897
+ // the published list — which is the behavior that was asked for.
898
+ tab === 'usage'
899
+ ? h(UsagePanel, { snapshot })
900
+ : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial }),
901
+ ),
902
+ )
903
+ }
904
+
905
+ /**
906
+ * The pill and its dialog.
907
+ *
908
+ * The two optional props exist so a headless test can render the real
909
+ * component against a real snapshot without a browser: the slot renderer
910
+ * passes neither, and both default to the live behavior.
911
+ *
912
+ * `wide` is the sidebar's own column state, supplied by the `sidebar.footer.action`
913
+ * seat: the full row while expanded, a 36px square on the collapsed rail.
914
+ *
915
+ * @param {{wide?: boolean, initialSnapshot?: object|null, initialOpen?: boolean,
916
+ * initialTab?: string, initialConfig?: object|null, initialOfficial?: object|null}} props
917
+ */
918
+ function UsageBadge({
919
+ wide = true,
920
+ initialSnapshot = null,
921
+ initialOpen = false,
922
+ initialTab = 'usage',
923
+ initialConfig = null,
924
+ initialOfficial = null,
925
+ } = {}) {
926
+ const [snapshot, setSnapshot] = React.useState(initialSnapshot)
927
+ const [failed, setFailed] = React.useState(false)
928
+ const [open, setOpen] = React.useState(initialOpen)
929
+
930
+ const load = React.useCallback(async () => {
931
+ try {
932
+ const response = await fetch('/usage-badge/summary', { cache: 'no-store' })
933
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
934
+ setSnapshot(await response.json())
935
+ setFailed(false)
936
+ } catch {
937
+ setFailed(true)
938
+ }
939
+ }, [])
940
+
941
+ React.useEffect(() => {
942
+ ensureStyles()
943
+ void load()
944
+ const timer = setInterval(() => void load(), POLL_MS)
945
+ return () => clearInterval(timer)
946
+ }, [load])
947
+
948
+ const amount = snapshot ? snapshot.badge.amount : null
949
+ const chip = snapshot ? bandChip(snapshot) : null
950
+ const text = failed && !snapshot ? '¥—' : amount === null ? '…' : money(amount)
951
+ const tokenTotal = snapshot ? totalTokens(snapshot.today) : 0
952
+ const title = snapshot
953
+ ? `今日 ${money(amount)} · ${tokens(tokenTotal)} tokens · ${snapshot.today.requests} 次请求${chip ? ` · ${chip.text}` : ''}\n点击查看用量明细`
954
+ : '正在读取用量…'
955
+
956
+ return h(
957
+ React.Fragment,
958
+ null,
959
+ h(
960
+ 'button',
961
+ {
962
+ type: 'button',
963
+ className: `dub-pill${wide ? '' : ' dub-pill-rail'}${snapshot ? '' : ' dub-pill-off'}`,
964
+ title,
965
+ onClick: () => setOpen((value) => !value),
966
+ },
967
+ h('span', { className: 'dub-pill-label' }, '今日用量'),
968
+ // The marker is compact, so it carries the band text as its own tooltip:
969
+ // hovering the dot alone explains 峰时 ×2 / 谷时 ×1 / 节假日.
970
+ chip ? h('span', { className: `dub-dot${chip.peak ? ' dub-dot-peak' : ''}`, title: chip.text }) : null,
971
+ tokenTotal > 0 ? h('span', { className: 'dub-pill-tokens' }, `${tokens(tokenTotal)} tokens`) : null,
972
+ h('span', { className: 'dub-pill-amount' }, text),
973
+ ),
974
+ open && snapshot
975
+ ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial })
976
+ : null,
977
+ )
978
+ }
979
+
980
+ /** Required client services: the slot registry the row registers into. */
981
+ const inject = ['slots']
982
+
983
+ /**
984
+ * Client plugin body.
985
+ *
986
+ * Registers one `sidebar.footer.action` entry — the seat the sidebar declares
987
+ * for actions beside Settings at the foot of the column. Using it rather than a
988
+ * floating overlay is what makes the row align with the shipped rows and share
989
+ * their layout: the seat supplies the column's `wide` state, so the entry is a
990
+ * full-width row while the sidebar is expanded and a square on the rail, and it
991
+ * never overlaps another control.
992
+ *
993
+ * @param {import('@deepseek-ai/cordis').Context} ctx - client root context.
994
+ */
995
+ function apply(ctx) {
996
+ ctx.effect(
997
+ () =>
998
+ ctx.slots.inject('sidebar.footer.action', () =>
999
+ ctx.slots.register({ name: 'sidebar.footer.action', id: 'usage-badge', order: 100 }, UsageBadge),
1000
+ ),
1001
+ 'usage-badge: sidebar cost row',
1002
+ )
1003
+ }
1004
+
1005
+ return { apply, inject }
1006
+ },
1007
+ })