dsh-usage-badge 0.1.1 → 0.1.3

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 CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * dsh-usage-badge — browser half.
3
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.
4
+ * A single hand-written client bundle: it registers one entry into the sidebar's
5
+ * `sidebar.footer.action` seat — the row beside Settings at the foot of the column — and
6
+ * renders the ¥ row plus its dialog. (That seat, rather than a floating overlay, is what
7
+ * makes the row share the shipped rows' layout and collapse with the column.)
8
8
  *
9
9
  * No build step: the module is written directly in the loader's factory form, so
10
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.
11
+ * from the platform module table; no chart library is bundled, because every chart
12
+ * here — four smoothed lines, the contributions grid — is hand-written SVG.
13
13
  *
14
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.
15
+ * - 用量 — the ranges, the provider filter, the totals, the chart and the year grid.
16
+ * - 单价配置 — the published price list fetched live on every open, the price rows you
17
+ * maintain (overrides and the catch-all), the peak/valley switches, and the
18
+ * holiday calendar state.
18
19
  */
19
20
 
20
21
  window.__ModuleLoader__.load({
@@ -31,6 +32,7 @@ window.__ModuleLoader__.load({
31
32
  { id: '7d', label: '近 7 日' },
32
33
  { id: '30d', label: '近 30 日' },
33
34
  { id: '12m', label: '近 12 个月' },
35
+ { id: 'year', label: '按年' },
34
36
  ]
35
37
 
36
38
  const TABS = [
@@ -38,10 +40,23 @@ window.__ModuleLoader__.load({
38
40
  { id: 'config', label: '单价配置' },
39
41
  ]
40
42
 
41
- const OFFICIAL_SOURCES = [
42
- { id: 'zh-cn', label: '中文页(人民币)' },
43
- { id: 'en', label: 'English (USD)' },
44
- ]
43
+ /**
44
+ * The published list this panel reads. Only the Chinese page: it is quoted in CNY, which is
45
+ * what the badge and every total are in, so the English page's USD column would need an
46
+ * exchange rate to mean anything. (The route still accepts `?source=en` for anyone calling it
47
+ * directly — the panel just has no reason to offer the choice.)
48
+ */
49
+ const OFFICIAL_SOURCE = 'zh-cn'
50
+
51
+ /**
52
+ * The host-half API this bundle needs; must equal `HOST_API_VERSION` in `lib/index.js`.
53
+ *
54
+ * The host half loads once, when the DSH process starts, while this file is re-read on
55
+ * every page load — so "I changed the host half and only refreshed" is a normal state,
56
+ * and in it a write the host does not understand comes back 200 and does nothing. The
57
+ * panel compares this number and says so rather than letting a button look broken.
58
+ */
59
+ const REQUIRED_HOST_API = 6
45
60
 
46
61
  const CSS = `
47
62
  .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}
@@ -51,10 +66,14 @@ window.__ModuleLoader__.load({
51
66
  .dub-pill-rail .dub-pill-amount{margin-left:0;font-size:11px;font-weight:600}
52
67
  .dub-pill-off{opacity:.55}
53
68
  .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. */
69
+ /* Peak/valley marker: grey off-peak, amber while the peak multiplier is in force. A
70
+ plain flow item — an absolutely positioned one escapes the row.
71
+ The amber is an explicit static swatch, never the brand token: --dsw-alias-brand-primary
72
+ is the *ink* colour (near-black #0f1115 in the light theme, near-white in the dark
73
+ one), so borrowing it left the peak dot looking like ordinary text — and turned the
74
+ peak chip into white on white. */
56
75
  .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)}
76
+ .dub-dot-peak{background:var(--dsw-static-amber-500,#f59e0b)}
58
77
  .dub-pill-tokens{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-variant-numeric:tabular-nums}
59
78
  .dub-pill-amount{flex:none;margin-left:6px;color:var(--dsw-alias-label-primary,#111);font-variant-numeric:tabular-nums;font-weight:500}
60
79
  .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)}
@@ -62,13 +81,15 @@ window.__ModuleLoader__.load({
62
81
  .dub-head{display:flex;align-items:center;gap:10px;padding:14px 18px 10px}
63
82
  .dub-title{font-size:14px;font-weight:600;flex:1}
64
83
  .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}
84
+ .dub-chip-peak{background:var(--dsw-static-amber-500,#f59e0b);color:#111}
66
85
  .dub-chip-holiday{background:#16a34a;color:#fff}
67
86
  .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
87
  .dub-x:hover{opacity:1;background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}
69
88
  .dub-tabs{display:flex;gap:6px;padding:0 18px 10px;flex-wrap:wrap;align-items:center}
70
89
  .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
90
  .dub-tab-on{background:var(--dsw-alias-fill-l2,rgba(128,128,128,.16));border-color:transparent;font-weight:600}
91
+ /* The year picker sits in the range row, so it is styled as one more tab. */
92
+ .dub-year{font:inherit;font-size:12px;padding:3px 6px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:var(--dsw-alias-bg-l1,transparent);color:inherit;cursor:pointer}
72
93
  .dub-panetabs{padding:0 18px 8px;gap:0}
73
94
  .dub-panetabs .dub-tab{border:0;border-bottom:2px solid transparent;border-radius:0;padding:4px 12px;font-size:13px;opacity:.7}
74
95
  .dub-panetabs .dub-tab-on{background:0 0;border-bottom-color:var(--dsw-alias-label-primary,#111);font-weight:600;opacity:1}
@@ -81,7 +102,15 @@ window.__ModuleLoader__.load({
81
102
  .dub-chartwrap{padding-top:6px}
82
103
  .dub-plot{position:relative}
83
104
  .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}
105
+ /* Legend entries are toggles, so a series can be read on its own — a lone line at
106
+ its own scale says more than four lines sharing one plot. The swatch of a hidden
107
+ series loses its colour rather than being restyled, so the inline background stays
108
+ the only place a series colour is written. */
109
+ .dub-legend-item{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:inherit;background:0 0;border:0;padding:0;cursor:pointer}
110
+ .dub-legend-item:hover{color:var(--dsw-alias-label-primary,#111)}
111
+ .dub-legend-off{opacity:.5}
112
+ .dub-legend-off .dub-legend-swatch{background:var(--dsw-alias-fill-l2,rgba(128,128,128,.35))}
113
+ .dub-legend-reset{font:inherit;font-size:11px;background:0 0;border:0;padding:0;cursor:pointer;color:var(--dsw-alias-link,#5686fe);text-decoration:underline}
85
114
  .dub-legend-swatch{flex:none;width:11px;height:11px;border-radius:3px}
86
115
  .dub-svg{display:block;width:100%;height:auto;overflow:visible}
87
116
  .dub-grid{stroke:var(--dsw-alias-border-l1,rgba(128,128,128,.18));stroke-width:1}
@@ -104,14 +133,65 @@ window.__ModuleLoader__.load({
104
133
  .dub-table td.dub-model{font-family:var(--dsw-font-mono,monospace);color:var(--dsw-alias-label-primary,#111)}
105
134
  .dub-note{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:1.5}
106
135
  .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}
136
+ /* The version handshake lives at the top of the dialog, above the tabs, so a mismatch is stated
137
+ on whichever tab the reader opened. */
138
+ .dub-version{padding:8px 18px;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:var(--dsw-alias-bg-l2,rgba(128,128,128,.06))}
139
+ /* Explanatory prose lives in these tooltips instead of under every block: the panel is a table of
140
+ numbers, and a wall of notes under each one is what made it hard to scan. */
141
+ .dub-info{flex:none;display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;border:1px solid currentColor;font-size:9px;font-style:normal;line-height:1;opacity:.5;cursor:help;user-select:none}
142
+ .dub-info:hover{opacity:.9}
108
143
  .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
144
  .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
145
  .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
146
  .dub-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}
112
147
  .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)}
148
+ /* The one filled action in the plugin. --dsw-alias-brand-primary is a *foreground*
149
+ token in this design system (DSH uses it for text, outlines and the switch track),
150
+ so filling with it left white text on near-white in the dark theme. The DeepSeek
151
+ blue is the same family the plugin's own blue fallback came from. */
152
+ .dub-btn-primary{border-color:transparent;background:var(--dsw-static-deepseek-500,#4176e6);color:#fff}
153
+ .dub-btn-primary:hover{opacity:.9;background:var(--dsw-static-deepseek-500,#4176e6)}
154
+ /* Contributions heatmap: one square per calendar day of the last year. The five
155
+ steps are the same rgba green at rising opacity, so the ramp reads correctly on
156
+ both a light and a dark panel without a theme query. Each step sets fill for the
157
+ svg squares and background for the HTML swatches. */
158
+ .dub-hm{position:relative}
159
+ .dub-hm-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}
160
+ .dub-hm-title{flex:1;font-size:12px;font-weight:600}
161
+ .dub-hm-metrics{display:flex;gap:4px}
162
+ .dub-hm-metric{font:inherit;font-size:11px;padding:2px 8px;border-radius:7px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:0 0;color:inherit;cursor:pointer}
163
+ .dub-hm-metric:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}
164
+ .dub-hm-metric-on{background:var(--dsw-alias-fill-l2,rgba(128,128,128,.16));border-color:transparent;font-weight:600}
165
+ /* The tooltip's percentages are squares of the grid, so it hangs off a wrapper that
166
+ is exactly as tall as the svg rather than off the block (title and legend included). */
167
+ .dub-hm-plot{position:relative}
168
+ .dub-hm-cell{stroke:none}
169
+ .dub-hm-l0{fill:var(--dsw-alias-fill-l2,rgba(128,128,128,.14));background:var(--dsw-alias-fill-l2,rgba(128,128,128,.14))}
170
+ .dub-hm-l1{fill:rgba(45,164,78,.25);background:rgba(45,164,78,.25)}
171
+ .dub-hm-l2{fill:rgba(45,164,78,.45);background:rgba(45,164,78,.45)}
172
+ .dub-hm-l3{fill:rgba(45,164,78,.7);background:rgba(45,164,78,.7)}
173
+ .dub-hm-l4{fill:rgba(45,164,78,.95);background:rgba(45,164,78,.95)}
174
+ .dub-hm-month,.dub-hm-wd{fill:var(--dsw-alias-label-tertiary,#8b949e);font-size:9px}
175
+ /* The hover card is the chart's .dub-tip, so this only re-anchors it: the style is
176
+ shared, the position is a fraction of the grid. */
177
+ .dub-hm-tip{margin-top:5px}
178
+ .dub-hm-tip-up{margin-top:-5px;transform:translate(-50%,-100%)}
179
+ .dub-hm-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:6px;font-size:11px;color:var(--dsw-alias-label-tertiary,#999)}
180
+ .dub-hm-legend{margin-left:auto;display:inline-flex;align-items:center;gap:4px}
181
+ .dub-hm-swatch{display:inline-block;width:10px;height:10px;border-radius:2px}
182
+ /* The price-row editor. The block already styles its inputs at 88px wide, so the fields
183
+ that need another width are written as a two-class selector to outrank that rule. */
184
+ .dub-form{border-top:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));margin-top:10px;padding-top:10px}
185
+ .dub-form-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary,#666);padding:3px 0}
186
+ .dub-form-row label{white-space:nowrap}
187
+ .dub-block .dub-form-key{width:250px}
188
+ .dub-block .dub-form-num{width:74px}
189
+ .dub-block input[type=checkbox]{width:auto;height:auto;margin:0}
190
+ .dub-form-check{display:inline-flex;align-items:center;gap:4px}
191
+ .dub-form-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;padding-top:8px}
192
+ .dub-table-ops{display:flex;gap:6px}
193
+ .dub-op{font:inherit;font-size:11px;padding:1px 7px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));background:0 0;color:inherit;cursor:pointer;white-space:nowrap}
194
+ .dub-op:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16))}
115
195
  `
116
196
 
117
197
  /** Inject the stylesheet once per document, guarded the way shipped plugins do. */
@@ -135,6 +215,22 @@ window.__ModuleLoader__.load({
135
215
  return symbol + String(Number(number.toFixed(number >= 1 ? 2 : 4)))
136
216
  }
137
217
 
218
+ /**
219
+ * How old a timestamp is, coarsely, or '' while it is still fresh.
220
+ *
221
+ * The published list is fetched once per page load rather than on every visit to the tab, so
222
+ * the panel owes the reader its age — a rate with no date on it is the kind of thing that
223
+ * quietly goes stale.
224
+ */
225
+ function agoSince(at) {
226
+ const minutes = Math.round((Date.now() - Number(at || 0)) / 60000)
227
+ if (!Number.isFinite(minutes) || minutes < 10) return ''
228
+ if (minutes < 60) return `${minutes} 分钟前`
229
+ const hours = Math.round(minutes / 60)
230
+ if (hours < 24) return `${hours} 小时前`
231
+ return `${Math.round(hours / 24)} 天前`
232
+ }
233
+
138
234
  /** Compact token counts, so a header row stays readable at every range. */
139
235
  function tokens(value) {
140
236
  const n = Number(value) || 0
@@ -173,28 +269,108 @@ window.__ModuleLoader__.load({
173
269
  return out
174
270
  }
175
271
 
272
+ const pad2 = (value) => String(value).padStart(2, '0')
273
+
274
+ /** The local `YYYY-MM-DD` key of a Date. */
275
+ const dayKeyOf = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
276
+
277
+ /** The inverse of `dayKeyOf`: the local midnight of a `YYYY-MM-DD` key. */
278
+ function dateOfKey(key) {
279
+ const [year, month, day] = String(key).split('-').map(Number)
280
+ return new Date(year, month - 1, day)
281
+ }
282
+
176
283
  /**
177
- * Build the chart points and totals for one range under one provider filter.
284
+ * The last `count` calendar days ending at `endKey`, oldest first.
178
285
  *
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.
286
+ * Enumerated from the calendar, not from the days that happen to have usage: the
287
+ * host only reports days with requests, so taking "the last 7 of those" stretches
288
+ * the window to whatever 7 days last had data and hides the gaps instead of
289
+ * showing them. A quiet Saturday is still a day.
183
290
  */
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,
291
+ function lastDays(endKey, count) {
292
+ const cursor = dateOfKey(endKey)
293
+ const out = []
294
+ for (let index = 0; index < count; index++) {
295
+ out.unshift(dayKeyOf(cursor))
296
+ cursor.setDate(cursor.getDate() - 1)
297
+ }
298
+ return out
299
+ }
300
+
301
+ /** The last `count` calendar months ending at `endKey`'s month, oldest first. */
302
+ function lastMonths(endKey, count) {
303
+ const [year, month] = endKey.split('-').map(Number)
304
+ const out = []
305
+ for (let index = count - 1; index >= 0; index--) {
306
+ const cursor = new Date(year, month - 1 - index, 1)
307
+ out.push(`${cursor.getFullYear()}-${pad2(cursor.getMonth() + 1)}`)
308
+ }
309
+ return out
310
+ }
311
+
312
+ /** The day a snapshot is anchored on: the host's own "today". */
313
+ function anchorDate(snapshot) {
314
+ return snapshot.badge?.date ?? snapshot.today?.date ?? snapshot.days?.[0]?.date ?? ''
315
+ }
316
+
317
+ /**
318
+ * The four plotted metrics of one bucket, from any of the shapes the host sends
319
+ * (an hourly slot, a provider row, a day row or a hand-summed month).
320
+ *
321
+ * Shared with the heatmap so a hovered square and a hovered chart point report
322
+ * the same numbers under the same keys — the two tooltips are the same card.
323
+ */
324
+ function metricsOf(src) {
325
+ return {
190
326
  amount: Number(src.amount) || 0,
191
327
  tokens: totalTokens(src),
192
328
  requests: Number(src.requests) || 0,
193
329
  hitRate: hitRate(src) * 100,
330
+ }
331
+ }
332
+
333
+ /** One chart point from any of the shapes the host sends. */
334
+ function seriesPoint(label, title, src, dayClass) {
335
+ return {
336
+ label,
337
+ title,
338
+ dayClass,
339
+ ...metricsOf(src),
194
340
  // Raw input-side buckets, so a range total can compute one exact hit rate.
195
341
  input: (Number(src.input) || 0) + (Number(src.cacheRead) || 0) + (Number(src.cacheWrite) || 0),
196
342
  cacheRead: Number(src.cacheRead) || 0,
197
- })
343
+ }
344
+ }
345
+
346
+ /**
347
+ * The twelve months of one calendar year, from the host's per-year payload.
348
+ *
349
+ * The host has already bucketed them (a year older than the loading window only exists in
350
+ * the store), so this applies the provider filter and the same point shape the other ranges
351
+ * build — the chart, the stat cards and the tooltips are then shared code.
352
+ */
353
+ function yearPoints(yearData, provider) {
354
+ if (!yearData?.months?.length) return { points: [], marks: false }
355
+ return {
356
+ points: yearData.months.map((month) =>
357
+ seriesPoint(month.label, `${yearData.year} 年 ${Number(month.month.slice(5, 7))} 月`, entryTotals(month, provider)),
358
+ ),
359
+ marks: false,
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Build the chart points and totals for one range under one provider filter.
365
+ *
366
+ * Each point carries all four plotted metrics, mirroring the desktop shell's
367
+ * chart (amount, token volume, request count and cache-hit rate). Every range
368
+ * answers from the same snapshot, so switching a tab or a provider never issues
369
+ * another request. The one exception is `year`, whose months may be older than the
370
+ * snapshot's window and therefore come from `/usage-badge/year`.
371
+ */
372
+ function buildSeries(snapshot, range, provider) {
373
+ const point = seriesPoint
198
374
 
199
375
  if (range === '24h') {
200
376
  const today = snapshot.today
@@ -207,30 +383,35 @@ window.__ModuleLoader__.load({
207
383
  }
208
384
 
209
385
  if (range === '12m') {
210
- const months = new Map()
386
+ const byMonth = new Map()
211
387
  for (const day of snapshot.days) {
212
388
  const key = day.date.slice(0, 7)
213
- if (!months.has(key)) months.set(key, [])
214
- months.get(key).push(entryTotals(day, provider))
389
+ if (!byMonth.has(key)) byMonth.set(key, [])
390
+ byMonth.get(key).push(entryTotals(day, provider))
215
391
  }
216
- const keys = [...months.keys()].sort().slice(-12)
392
+ // Twelve calendar months, so a month with no usage keeps its empty slot
393
+ // instead of quietly pushing an older month into the window.
217
394
  return {
218
- points: keys.map((key) => point(key.slice(2), key, sumTotals(months.get(key)))),
395
+ points: lastMonths(anchorDate(snapshot), 12).map((key) =>
396
+ point(key.slice(2), key, sumTotals(byMonth.get(key) ?? [])),
397
+ ),
219
398
  marks: false,
220
399
  }
221
400
  }
222
401
 
223
402
  const count = range === '7d' ? 7 : 30
224
- const slice = snapshot.days.slice(0, count).reverse()
403
+ const byDate = new Map(snapshot.days.map((day) => [day.date, day]))
225
404
  return {
226
- points: slice.map((day) =>
227
- point(
228
- day.date.slice(5),
229
- `${day.date}${day.dayName ? ` ${day.dayName}` : ''}`,
405
+ points: lastDays(anchorDate(snapshot), count).map((date) => {
406
+ const day = byDate.get(date)
407
+ if (!day) return point(date.slice(5), `${date}(无用量)`, EMPTY_TOTALS)
408
+ return point(
409
+ date.slice(5),
410
+ `${date}${day.dayName ? ` ${day.dayName}` : ''}`,
230
411
  entryTotals(day, provider),
231
412
  day.dayClass,
232
- ),
233
- ),
413
+ )
414
+ }),
234
415
  marks: true,
235
416
  }
236
417
  }
@@ -293,16 +474,76 @@ window.__ModuleLoader__.load({
293
474
 
294
475
  /**
295
476
  * 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.
477
+ * hover tooltip that reports every visible series at the hovered index — the
478
+ * same `interaction: { mode: 'index' }` behavior the shell's chart has.
479
+ *
480
+ * Each legend entry toggles its own series. A series on its own keeps its own
481
+ * scale, so isolating one is how a metric gets read at full height instead of
482
+ * being compressed by a neighbour three orders of magnitude larger.
483
+ *
484
+ * @param {{points: Array<object>, initialHidden?: string[]}} props `initialHidden`
485
+ * is a test hook: the slot renderer passes no such prop and everything is shown.
298
486
  */
299
- function UsageChart({ points }) {
487
+ function UsageChart({ points, initialHidden = [] }) {
300
488
  const [hover, setHover] = React.useState(null)
489
+ const [hidden, setHidden] = React.useState(() => new Set(initialHidden))
301
490
  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' }, '该区间暂无用量')
491
+ const shown = SERIES.filter((series) => !hidden.has(series.key))
492
+ const hasData = count > 0 && points.some((p) => shown.some((s) => (p[s.key] || 0) > 0))
493
+ const anyData = count > 0 && points.some((p) => SERIES.some((s) => (p[s.key] || 0) > 0))
304
494
 
305
- const plotted = SERIES.map((series) => {
495
+ // The legend is built before the empty branch rather than inside the plot, because it is
496
+ // the only way back: an empty state that hid the legend would strand whoever hid the
497
+ // series that had the data.
498
+ const legend = h(
499
+ 'div',
500
+ { className: 'dub-legend' },
501
+ SERIES.map((series) => {
502
+ const on = !hidden.has(series.key)
503
+ return h(
504
+ 'button',
505
+ {
506
+ key: series.key,
507
+ type: 'button',
508
+ className: `dub-legend-item${on ? '' : ' dub-legend-off'}`,
509
+ title: `${on ? '隐藏' : '显示'}「${series.label}」`,
510
+ onClick: () => toggle(series.key),
511
+ },
512
+ h('span', { className: 'dub-legend-swatch', style: on ? { background: series.color } : undefined }),
513
+ series.label,
514
+ )
515
+ }),
516
+ // Only offered once something is hidden, so the plain legend stays plain.
517
+ hidden.size > 0
518
+ ? h(
519
+ 'button',
520
+ {
521
+ type: 'button',
522
+ className: 'dub-legend-reset',
523
+ onClick: () => {
524
+ setHidden(new Set())
525
+ setHover(null)
526
+ },
527
+ },
528
+ '全部显示',
529
+ )
530
+ : null,
531
+ )
532
+
533
+ if (!hasData) {
534
+ return h(
535
+ 'div',
536
+ { className: 'dub-chartwrap' },
537
+ legend,
538
+ h(
539
+ 'div',
540
+ { className: 'dub-empty' },
541
+ anyData ? '所选序列在该区间没有用量 —— 点上面的图例把其它序列显示出来' : '该区间暂无用量',
542
+ ),
543
+ )
544
+ }
545
+
546
+ const plotted = shown.map((series) => {
306
547
  const max = series.max ?? Math.max(...points.map((p) => p[series.key] || 0), 0)
307
548
  const coords = points.map((p, index) => ({ x: chartX(index, count), y: chartY(p[series.key] || 0, max || 1) }))
308
549
  return { series, coords, max }
@@ -310,7 +551,22 @@ window.__ModuleLoader__.load({
310
551
 
311
552
  const gridY = [0, 0.25, 0.5, 0.75, 1].map((ratio) => CHART.top + ratio * (CHART.height - CHART.top - CHART.bottom))
312
553
  const baseline = CHART.height - CHART.bottom
313
- const amountSeries = plotted[0]
554
+ // Looked up rather than taken as plotted[0]: with series hidden, the first
555
+ // entry is not the amount series, and filling the wrong line would be silent.
556
+ const amountSeries = plotted.find((entry) => entry.series.key === 'amount') ?? null
557
+
558
+ /** Show or hide one series; hiding the last visible one brings them all back. */
559
+ const toggle = (key) => {
560
+ setHidden((current) => {
561
+ const next = new Set(current)
562
+ if (next.has(key)) next.delete(key)
563
+ // Never leave the plot empty: the last click that would do so resets instead.
564
+ else if (next.size >= SERIES.length - 1) return new Set()
565
+ else next.add(key)
566
+ return next
567
+ })
568
+ setHover(null)
569
+ }
314
570
 
315
571
  /** Nearest index for a pointer position, from the viewBox x it maps to. */
316
572
  const indexAt = (event) => {
@@ -327,18 +583,7 @@ window.__ModuleLoader__.load({
327
583
  return h(
328
584
  'div',
329
585
  { 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
- ),
586
+ legend,
342
587
  h(
343
588
  'div',
344
589
  { className: 'dub-plot' },
@@ -375,11 +620,14 @@ window.__ModuleLoader__.load({
375
620
  : null,
376
621
  ),
377
622
  // 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
- }),
623
+ // Hidden, it takes its fill with it instead of filling someone else's line.
624
+ amountSeries
625
+ ? h('path', {
626
+ d: `${smoothPath(amountSeries.coords)}L${amountSeries.coords.at(-1)?.x ?? CHART.side},${baseline}L${amountSeries.coords[0]?.x ?? CHART.side},${baseline}Z`,
627
+ fill: amountSeries.series.fill,
628
+ stroke: 'none',
629
+ })
630
+ : null,
383
631
  ...plotted.map(({ series, coords }) =>
384
632
  h('path', {
385
633
  key: series.key,
@@ -437,7 +685,8 @@ window.__ModuleLoader__.load({
437
685
  style: { left: `clamp(86px, ${(chartX(hover, count) / CHART.width) * 100}%, calc(100% - 86px))` },
438
686
  },
439
687
  h('div', { className: 'dub-tip-title' }, hovered.title),
440
- SERIES.map((series) =>
688
+ // Only the visible series, so the card agrees with the plot it belongs to.
689
+ shown.map((series) =>
441
690
  h(
442
691
  'div',
443
692
  { className: 'dub-tip-row', key: series.key },
@@ -452,6 +701,330 @@ window.__ModuleLoader__.load({
452
701
  )
453
702
  }
454
703
 
704
+ // ── the contributions heatmap ─────────────────────────────────────────────
705
+ // One square per calendar day for the last year, in GitHub's layout: a column
706
+ // is a week (Monday first), a row is a weekday, and the shade is how that day
707
+ // ranks against the year's other days that had usage.
708
+ const HEAT = { cell: 10, gap: 2, left: 26, top: 14 }
709
+ const HEAT_PITCH = HEAT.cell + HEAT.gap
710
+ // A year is usually 53 week columns, but a leap year starting on a Sunday needs 54 — so the
711
+ // width comes from the grid that was actually built rather than from a fixed number.
712
+ const heatWidth = (columns) => HEAT.left + columns * HEAT_PITCH - HEAT.gap
713
+ const HEAT_HEIGHT = HEAT.top + 7 * HEAT_PITCH - HEAT.gap
714
+ /** Weekday names in row order, for the axis and the hovered day's title. */
715
+ const HEAT_WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
716
+ /** Which single metric shades the grid. */
717
+ const HEAT_METRICS = [
718
+ { id: 'amount', label: '金额', format: money },
719
+ { id: 'tokens', label: 'Token', format: tokens },
720
+ { id: 'requests', label: '请求数', format: (value) => String(value) },
721
+ ]
722
+
723
+ /** Top-left x of a column inside the heatmap viewBox. */
724
+ const heatX = (column) => HEAT.left + column * HEAT_PITCH
725
+ /** Top-left y of a row inside the heatmap viewBox. */
726
+ const heatY = (row) => HEAT.top + row * HEAT_PITCH
727
+ /**
728
+ * Height the hover card needs, in px, for its title and four rows at the chart
729
+ * tooltip's metrics (5 × 17.6px of line box + 16px of padding). It is estimated
730
+ * rather than measured because measuring would mean rendering the card twice;
731
+ * only a card taller than this can still be clipped, and the estimate is generous.
732
+ */
733
+ const HEAT_CARD_HEIGHT = 112
734
+ /** Gap held between the card and the square it belongs to. */
735
+ const HEAT_CARD_GAP = 5
736
+ /** Breathing room left between the card and the edge of the scrolling body. */
737
+ const HEAT_CARD_CLEARANCE = 12
738
+
739
+ /**
740
+ * Where the hover card goes for a square: which way it opens, and the fraction of
741
+ * the grid its anchor sits at.
742
+ *
743
+ * The grid is the last block of a scrolling body, so a card that opens downward
744
+ * from a square near that body's edge is cut off by it — and the row number cannot
745
+ * tell you where that edge is, because the panel scrolls. So the room is measured
746
+ * against the scroller and the card is then clamped into it, which keeps it visible
747
+ * wherever the panel has been scrolled to.
748
+ *
749
+ * @returns {{up: boolean, top: number}|null} null when there is no layout to
750
+ * measure — a static render, where the caller falls back to the row's anchor.
751
+ */
752
+ function cardPlacement(square, plot) {
753
+ const scroller = square?.closest?.('.dub-body')
754
+ const box = scroller?.getBoundingClientRect?.()
755
+ const grid = plot?.getBoundingClientRect?.()
756
+ if (!box || !grid?.height) return null
757
+ const rect = square.getBoundingClientRect()
758
+ const below = box.bottom - rect.bottom
759
+ const above = rect.top - box.top
760
+ // Open downward when the card fits below the square, upward otherwise — and of
761
+ // the two, upward only when it is not the roomier side.
762
+ const up = below < HEAT_CARD_HEIGHT + HEAT_CARD_CLEARANCE && below <= above
763
+ // Clamp the card's own top edge into the scroller, then turn that back into the
764
+ // anchor the stylesheet expects for the chosen direction.
765
+ const wanted = up ? rect.top - HEAT_CARD_GAP - HEAT_CARD_HEIGHT : rect.bottom + HEAT_CARD_GAP
766
+ const clamped = Math.min(Math.max(wanted, box.top + 4), box.bottom - HEAT_CARD_HEIGHT - 4)
767
+ const anchor = up ? clamped + HEAT_CARD_HEIGHT + HEAT_CARD_GAP : clamped - HEAT_CARD_GAP
768
+ return { up, top: (anchor - grid.top) / grid.height }
769
+ }
770
+
771
+ /** The one number a day contributes to the grid, out of its four metrics. */
772
+ function heatValue(metrics, metric) {
773
+ return metrics ? Number(metrics[metric]) || 0 : 0
774
+ }
775
+
776
+ /**
777
+ * The grid behind the heatmap: Monday-first week columns covering `from` … `to`.
778
+ *
779
+ * Days are enumerated from the calendar rather than from the rows: the host only reports days
780
+ * that had requests, and a grid built from those would have no squares for the quiet days —
781
+ * which are exactly the ones the picture is meant to show.
782
+ *
783
+ * Both callers share this one grid, which is why the bounds are arguments rather than derived
784
+ * here from "today": the range views draw a trailing year (`from` a year back, `to` today)
785
+ * and the year view draws a calendar year (`from` Jan 1, `to` Dec 31 — or today while that
786
+ * year is still running). Days past `to` are left undrawn either way.
787
+ */
788
+ function heatGrid({ days, provider, metric, from, to }) {
789
+ const byDate = new Map((days || []).map((day) => [day.date, day]))
790
+ // Bounds arrive as either a Date or an ISO day key; both callers pass what they have.
791
+ const keyOf = (value) => (typeof value === 'string' ? value : dayKeyOf(value))
792
+ const end = keyOf(to)
793
+ const start = dateOfKey(keyOf(from))
794
+ // Back to that week's Monday, so the first column is a whole week.
795
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
796
+ const span = Math.round((dateOfKey(end) - start) / 86400000)
797
+ const weeks = Math.max(1, Math.ceil((span + 1) / 7))
798
+
799
+ const cursor = new Date(start)
800
+ const columns = []
801
+ const values = []
802
+ for (let week = 0; week < weeks; week++) {
803
+ const cells = []
804
+ for (let row = 0; row < 7; row++) {
805
+ const key = dayKeyOf(cursor)
806
+ // ISO keys compare correctly as strings, so a day that is still to come can be
807
+ // told apart from one that simply had no usage.
808
+ const future = key > end
809
+ const day = future ? null : byDate.get(key)
810
+ const totals = future ? null : day ? entryTotals(day, provider) : EMPTY_TOTALS
811
+ // The cell carries the day's four metrics, not just the one shading it, so
812
+ // the hover card can report the same four rows the chart's tooltip does.
813
+ const metrics = totals ? metricsOf(totals) : null
814
+ const value = heatValue(metrics, metric)
815
+ if (!future) values.push(value)
816
+ cells.push({
817
+ key,
818
+ row,
819
+ column: week,
820
+ date: new Date(cursor),
821
+ future,
822
+ value,
823
+ metrics,
824
+ dayName: day?.dayName ?? null,
825
+ })
826
+ cursor.setDate(cursor.getDate() + 1)
827
+ }
828
+ columns.push(cells)
829
+ }
830
+
831
+ // The five steps are quartiles of the days that had usage, as GitHub's are.
832
+ // A share-of-maximum ramp would flatten a whole year to one shade as soon as
833
+ // a single record day dwarfs the rest.
834
+ const ranked = values.filter((value) => value > 0).sort((a, b) => a - b)
835
+ const cut = (p) => (ranked.length ? ranked[Math.min(ranked.length - 1, Math.floor(p * ranked.length))] : 0)
836
+ const q1 = cut(0.25)
837
+ const q2 = cut(0.5)
838
+ const q3 = cut(0.75)
839
+ const levelOf = (value) => {
840
+ if (!(value > 0)) return 0
841
+ if (value <= q1) return 1
842
+ if (value <= q2) return 2
843
+ if (value <= q3) return 3
844
+ return 4
845
+ }
846
+
847
+ // Month labels sit above the column holding a 1st; the opening column is
848
+ // labelled with its own month so the year does not start blank. A label that
849
+ // would land within two columns of the previous one is dropped.
850
+ const marks = []
851
+ columns.forEach((cells, column) => {
852
+ const firstOfMonth = cells.find((cell) => cell.date.getDate() === 1)
853
+ const anchor = column === 0 || firstOfMonth ? firstOfMonth ?? cells[0] : null
854
+ if (!anchor) return
855
+ // A 1st that is still to come must not caption its column: the last week of a
856
+ // calendar year holds the next January, and that column is mostly December.
857
+ if (anchor.future) return
858
+ if (marks.length > 0 && column - marks[marks.length - 1].column < 3) return
859
+ marks.push({ column, month: anchor.date.getMonth() + 1 })
860
+ })
861
+
862
+ return {
863
+ columns,
864
+ marks,
865
+ total: values.reduce((sum, value) => sum + value, 0),
866
+ active: ranked.length,
867
+ levelOf,
868
+ }
869
+ }
870
+
871
+ /**
872
+ * The hovered square's date line: the day, its holiday name when it has one, and
873
+ * otherwise a note that the four zeroes below it are a quiet day, not a bug.
874
+ */
875
+ function heatTitle(cell) {
876
+ const metrics = cell.metrics
877
+ const quiet = !metrics || SERIES.every((series) => !(Number(metrics[series.key]) > 0))
878
+ const note = cell.dayName ?? (quiet ? '无用量' : '')
879
+ return `${cell.key} ${HEAT_WEEKDAYS[cell.row]}${note ? ` · ${note}` : ''}`
880
+ }
881
+
882
+ /**
883
+ * The year heatmap in the 用量 panel.
884
+ *
885
+ * It draws whichever range it is handed — the trailing year under the range chart, or one
886
+ * calendar year (`days`, `from`, `to`, `title` are the host's per-year payload) — and honours
887
+ * the same provider filter as the chart, so the two views cannot disagree about a day. The
888
+ * metric selector only picks which of that day's numbers decides the shade.
889
+ */
890
+ function UsageHeatmap({ snapshot, provider, days, from, to, title }) {
891
+ const [metric, setMetric] = React.useState('amount')
892
+ const [hover, setHover] = React.useState(null)
893
+ // The measured placement needs the grid's own box to turn viewport pixels back
894
+ // into a fraction of it.
895
+ const plotRef = React.useRef(null)
896
+ const today = anchorDate(snapshot)
897
+ const meta = HEAT_METRICS.find((item) => item.id === metric) ?? HEAT_METRICS[0]
898
+ if (!today) return null
899
+ // The trailing year: 52 weeks back from today, then out to that week's Monday. The year
900
+ // view passes its own bounds instead, and `title` says which year it is looking at.
901
+ const trailing = dateOfKey(today)
902
+ trailing.setDate(trailing.getDate() - 364)
903
+ const grid = heatGrid({ days: days ?? snapshot.days, provider, metric, from: from ?? trailing, to: to ?? today })
904
+ const width = heatWidth(grid.columns.length)
905
+
906
+ return h(
907
+ 'div',
908
+ { className: 'dub-block dub-hm' },
909
+ h(
910
+ 'div',
911
+ { className: 'dub-hm-head' },
912
+ h('div', { className: 'dub-hm-title' }, title ?? '近一年活跃度'),
913
+ h('span', { className: 'dub-note' }, `共 ${meta.format(grid.total)} · ${grid.active} 天有用量`),
914
+ h(
915
+ 'div',
916
+ { className: 'dub-hm-metrics' },
917
+ HEAT_METRICS.map((item) =>
918
+ h(
919
+ 'button',
920
+ {
921
+ key: item.id,
922
+ className: `dub-hm-metric${metric === item.id ? ' dub-hm-metric-on' : ''}`,
923
+ onClick: () => {
924
+ setMetric(item.id)
925
+ setHover(null)
926
+ },
927
+ },
928
+ item.label,
929
+ ),
930
+ ),
931
+ ),
932
+ ),
933
+ h(
934
+ 'div',
935
+ { className: 'dub-hm-plot', ref: plotRef },
936
+ h(
937
+ 'svg',
938
+ {
939
+ className: 'dub-svg',
940
+ viewBox: `0 0 ${width} ${HEAT_HEIGHT}`,
941
+ onMouseLeave: () => setHover(null),
942
+ },
943
+ grid.marks.map((mark) =>
944
+ h('text', { key: `m${mark.column}`, className: 'dub-hm-month', x: heatX(mark.column), y: 9 }, `${mark.month}月`),
945
+ ),
946
+ // Only Monday, Wednesday and Friday are named: a label per row does not
947
+ // fit the gutter, and the three are enough to read the grid's alignment.
948
+ [0, 2, 4].map((row) =>
949
+ h(
950
+ 'text',
951
+ { key: `w${row}`, className: 'dub-hm-wd', x: 0, y: heatY(row) + HEAT.cell - 1 },
952
+ HEAT_WEEKDAYS[row],
953
+ ),
954
+ ),
955
+ grid.columns.map((cells, column) =>
956
+ cells.map((cell) =>
957
+ cell.future
958
+ ? null
959
+ : h('rect', {
960
+ key: cell.key,
961
+ className: `dub-hm-cell dub-hm-l${grid.levelOf(cell.value)}`,
962
+ x: heatX(column),
963
+ y: heatY(cell.row),
964
+ width: HEAT.cell,
965
+ height: HEAT.cell,
966
+ rx: 2,
967
+ // Which way the card opens, and where it lands, both depend on
968
+ // where the body's edge is — so they are decided here, off the
969
+ // square that was actually entered.
970
+ onMouseEnter: (event) => {
971
+ const placement = cardPlacement(event.currentTarget, plotRef.current)
972
+ setHover({
973
+ ...cell,
974
+ up: placement?.up ?? true,
975
+ // Without layout there is nothing to measure, so the anchor
976
+ // falls back to the square's own row.
977
+ top: placement?.top ?? (heatY(cell.row) + HEAT.cell) / HEAT_HEIGHT,
978
+ })
979
+ },
980
+ }),
981
+ ),
982
+ ),
983
+ ),
984
+ hover
985
+ ? h(
986
+ 'div',
987
+ {
988
+ // The chart's tooltip card, re-anchored to a square and carrying the
989
+ // same four rows, so the two hovers read identically. It opens away
990
+ // from the nearest edge of the scrolling body.
991
+ className: `dub-tip dub-hm-tip${hover.up ? ' dub-hm-tip-up' : ''}`,
992
+ // Clamped the way the chart's tooltip is: half of it would fall
993
+ // outside the dialog's clipped box at either end of the year. 86px
994
+ // is half of the card's fixed width.
995
+ style: {
996
+ left: `clamp(86px, ${((heatX(hover.column) + HEAT.cell / 2) / width) * 100}%, calc(100% - 86px))`,
997
+ top: `${(hover.top * 100).toFixed(3)}%`,
998
+ },
999
+ },
1000
+ h('div', { className: 'dub-tip-title' }, heatTitle(hover)),
1001
+ SERIES.map((series) =>
1002
+ h(
1003
+ 'div',
1004
+ { className: 'dub-tip-row', key: series.key },
1005
+ h('span', { className: 'dub-legend-swatch', style: { background: series.color } }),
1006
+ h('span', { className: 'dub-tip-label' }, series.label),
1007
+ h('span', { className: 'dub-tip-value' }, series.format(hover.metrics?.[series.key] ?? 0)),
1008
+ ),
1009
+ ),
1010
+ )
1011
+ : null,
1012
+ ),
1013
+ h(
1014
+ 'div',
1015
+ { className: 'dub-hm-foot' },
1016
+ h('span', null, '每格一天,颜色越深用量越高;悬停看当天明细'),
1017
+ h(
1018
+ 'span',
1019
+ { className: 'dub-hm-legend' },
1020
+ '少',
1021
+ [0, 1, 2, 3, 4].map((level) => h('span', { key: level, className: `dub-hm-swatch dub-hm-l${level}` })),
1022
+ '多',
1023
+ ),
1024
+ ),
1025
+ )
1026
+ }
1027
+
455
1028
  /** The period badge: the holiday calendar outranks the peak/valley window. */
456
1029
  function bandChip(snapshot) {
457
1030
  const calendar = snapshot.calendar
@@ -468,12 +1041,71 @@ window.__ModuleLoader__.load({
468
1041
  return h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, label), h('div', { className: 'dub-stat-v' }, value))
469
1042
  }
470
1043
 
1044
+ /**
1045
+ * A small ⓘ whose tooltip carries the explanation a block would otherwise print in full.
1046
+ *
1047
+ * Native `title` rather than a styled card: the text is prose, it never needs to be clipped
1048
+ * against the scrolling body, and this costs no state. `aria-label` carries the same text so
1049
+ * it is not only available to a mouse.
1050
+ */
1051
+ function Info({ text }) {
1052
+ return h('span', { className: 'dub-info', title: text, role: 'img', 'aria-label': text }, 'i')
1053
+ }
1054
+
471
1055
  /** The 用量 panel: ranges, provider filter, totals and the chart. */
472
- function UsagePanel({ snapshot }) {
473
- const [range, setRange] = React.useState('24h')
1056
+ function UsagePanel({ snapshot, initialRange = '24h', initialHidden = [], initialYear = null, initialYearData = null }) {
1057
+ const [range, setRange] = React.useState(initialRange)
474
1058
  const [provider, setProvider] = React.useState('')
1059
+ const [year, setYear] = React.useState(initialYear)
1060
+ const [yearData, setYearData] = React.useState(initialYearData)
1061
+ // Which years exist comes from the data, not from a list in this file: the host asks the
1062
+ // store, so the first day a new year has a request it appears here with nothing to edit.
1063
+ //
1064
+ // The host sends them as **strings** (`store.years()` builds them from date keys) while this
1065
+ // panel stores the selected year as a number, so they are normalised once, here, at the
1066
+ // boundary. Without that, `years.includes(2025)` against `['2026','2025']` is always false
1067
+ // and every selection silently snapped back to the newest year.
1068
+ //
1069
+ // A selected year that has since disappeared (the cache was deleted, or a different store is
1070
+ // in use) falls back to the newest one, so the picker and the chart cannot disagree.
1071
+ const yearsKnown = Array.isArray(snapshot.years)
1072
+ const years = (yearsKnown ? snapshot.years : [])
1073
+ .map(Number)
1074
+ .filter((value) => Number.isFinite(value))
1075
+ const selectedYear = year && years.includes(year) ? year : years[0] ?? null
475
1076
  const providers = snapshot.today.providers || []
476
- const series = buildSeries(snapshot, range, provider)
1077
+
1078
+ React.useEffect(() => {
1079
+ if (range !== 'year' || !selectedYear) return undefined
1080
+ if (Number(yearData?.year) === Number(selectedYear)) return undefined
1081
+ let cancelled = false
1082
+ void (async () => {
1083
+ try {
1084
+ const response = await fetch(`/usage-badge/year?year=${selectedYear}`, { cache: 'no-store' })
1085
+ const body = await response.json()
1086
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
1087
+ if (!cancelled) setYearData(body)
1088
+ } catch (error) {
1089
+ if (!cancelled) setYearData({ year: Number(selectedYear), months: [], error: String(error?.message ?? error) })
1090
+ }
1091
+ })()
1092
+ return () => {
1093
+ cancelled = true
1094
+ }
1095
+ }, [range, selectedYear, yearData?.year])
1096
+
1097
+ const series = range === 'year' ? yearPoints(yearData, provider) : buildSeries(snapshot, range, provider)
1098
+ // The calendar bounds of the selected year: the whole year for one that is over, up to
1099
+ // today for the year still running (a future month is not a month with no usage).
1100
+ const todayKey = anchorDate(snapshot)
1101
+ const currentYear = Number(String(todayKey).slice(0, 4))
1102
+ const yearFrom = selectedYear ? `${selectedYear}-01-01` : todayKey
1103
+ const yearTo = selectedYear === currentYear ? todayKey : `${selectedYear}-12-31`
1104
+ // A host older than `daily` still serves the year's months. For the year still running the
1105
+ // snapshot's own days *are* that year's days, so the grid can still be drawn from them —
1106
+ // and for a past year there is no such substitute, which is said rather than drawn empty.
1107
+ // (That equivalence rests on `CACHE_WINDOW_DAYS` and `DAYS_KEPT` both exceeding 366.)
1108
+ const yearDays = yearData?.daily ?? (yearData?.months?.length && selectedYear === currentYear ? snapshot.days : null)
477
1109
  // The stat cards describe exactly what the chart plots, so they are summed
478
1110
  // from the same points rather than read from a separate total. Summing the
479
1111
  // raw token buckets keeps the aggregate hit rate exact instead of averaging
@@ -503,6 +1135,31 @@ window.__ModuleLoader__.load({
503
1135
  item.label,
504
1136
  ),
505
1137
  ),
1138
+ // The year picker is built from the years the store actually holds, newest first.
1139
+ range === 'year'
1140
+ ? years.length
1141
+ ? h(
1142
+ 'select',
1143
+ {
1144
+ key: '__year',
1145
+ className: 'dub-year',
1146
+ value: String(selectedYear ?? ''),
1147
+ title: '缓存里有数据的年份',
1148
+ onChange: (event) => {
1149
+ setYear(Number(event.target.value))
1150
+ setYearData(null)
1151
+ },
1152
+ },
1153
+ years.map((value) => h('option', { key: value, value: String(value) }, `${value} 年`)),
1154
+ )
1155
+ : h(
1156
+ 'span',
1157
+ { key: '__year-none', className: 'dub-note' },
1158
+ // An older host does not send `years` at all; saying "the cache has no dated data"
1159
+ // about a cache that certainly does would be a false statement.
1160
+ yearsKnown ? '缓存里还没有带日期的数据' : '宿主半是旧版本,重启客户端后这里会出现年份选择',
1161
+ )
1162
+ : null,
506
1163
  providers.length > 1 ? h('span', { className: 'dub-sep' }) : null,
507
1164
  providers.length > 1
508
1165
  ? [
@@ -536,10 +1193,303 @@ window.__ModuleLoader__.load({
536
1193
  h(Stat, { label: 'Token', value: tokens(totals.tokens) }),
537
1194
  h(Stat, { label: '缓存命中率', value: `${hitPercent.toFixed(1)}%` }),
538
1195
  ),
539
- h(UsageChart, { points: series.points }),
1196
+ // A route with usage and no price contributes tokens but no amount, so the
1197
+ // totals above are missing it. Naming those models here is what keeps the ¥0
1198
+ // from reading as "free" — the switch that causes it lives in 单价配置.
1199
+ snapshot.unpriced?.length
1200
+ ? h(
1201
+ 'div',
1202
+ { className: 'dub-warn', style: { padding: '8px 0 0' } },
1203
+ `${snapshot.unpriced.length} 个模型没有价格,金额未计入它们的用量(token 与请求数照常统计):` +
1204
+ snapshot.unpriced.slice(0, 5).map((row) => `${row.provider}|${row.model}`).join('、') +
1205
+ (snapshot.unpriced.length > 5 ? ` 等 ${snapshot.unpriced.length} 个` : '') +
1206
+ '。给它们各加一行价格,或在「单价配置」里把「未匹配的模型」改回按 default 计价。',
1207
+ )
1208
+ : null,
1209
+ range === 'year' && selectedYear && !yearData
1210
+ ? h('div', { className: 'dub-note', style: { paddingTop: '8px' } }, `正在从缓存里读 ${selectedYear} 年的数据…`)
1211
+ : null,
1212
+ range === 'year' && yearData?.error
1213
+ ? h('div', { className: 'dub-warn', style: { paddingTop: '8px' } }, `读取 ${yearData.year} 年失败:${yearData.error}`)
1214
+ : null,
1215
+ h(UsageChart, { points: series.points, initialHidden }),
540
1216
  series.marks
541
1217
  ? h('div', { className: 'dub-note', style: { paddingTop: '8px' } }, '绿色背景色带标示法定节假日(全天按空闲时段计价)')
542
1218
  : null,
1219
+ // The year at a glance, under the range chart: the same days, read as a
1220
+ // calendar rather than as a trend. In the 按年 view it is that calendar year's
1221
+ // own grid (Jan 1 to Dec 31), which is exactly what a 53-column picture is for.
1222
+ range === 'year'
1223
+ ? yearData && !yearData.error
1224
+ ? yearDays
1225
+ ? h(UsageHeatmap, {
1226
+ snapshot,
1227
+ provider,
1228
+ days: yearDays,
1229
+ from: dateOfKey(yearFrom),
1230
+ to: yearTo,
1231
+ title: `${selectedYear} 年活跃度`,
1232
+ })
1233
+ : h(
1234
+ 'div',
1235
+ { className: 'dub-note', style: { paddingTop: '8px' } },
1236
+ `宿主半是旧版本,读不到 ${selectedYear} 年的日粒度数据 —— 重启客户端后这里会显示这一年的热力图。`,
1237
+ )
1238
+ : null
1239
+ : h(UsageHeatmap, { snapshot, provider }),
1240
+ ),
1241
+ )
1242
+ }
1243
+
1244
+ /**
1245
+ * The last successful 单价配置 payloads, kept for the life of the page.
1246
+ *
1247
+ * Switching tabs unmounts this panel, so without a cache every return to the tab
1248
+ * starts from the loading placeholder and swaps its contents in a moment later —
1249
+ * a visible flicker. Holding the last answer lets the panel render it straight
1250
+ * away.
1251
+ *
1252
+ * `cachedOfficial` also decides *whether* the published list is fetched at all: the page
1253
+ * asks for it once, on the first visit, and after that only when the button is pressed. The
1254
+ * list changes a few times a year, and a tab switch is not news.
1255
+ */
1256
+ let cachedConfig = null
1257
+ let cachedOfficial = null
1258
+
1259
+ /** The numeric fields the row editor manages, and how to name them in an error. */
1260
+ const ROW_NUMBERS = [
1261
+ ['inputPerMillion', 'input', '输入'],
1262
+ ['cacheReadPerMillion', 'cacheRead', '缓存命中'],
1263
+ ['cacheWritePerMillion', 'cacheWrite', '缓存写入'],
1264
+ ['outputPerMillion', 'output', '输出'],
1265
+ ['multiplier', 'multiplier', '倍率'],
1266
+ ]
1267
+ /** Row fields the editor owns; everything else a row carries is preserved as it is. */
1268
+ const ROW_MANAGED = [...ROW_NUMBERS.map(([field]) => field), 'timeOfUse']
1269
+
1270
+ /**
1271
+ * A row's content, independent of key order, for "has this row changed underneath me?".
1272
+ *
1273
+ * Parsing the file again produces a fresh object with the same content, so identity cannot
1274
+ * answer that question — and comparing `JSON.stringify` directly would flip on key order.
1275
+ */
1276
+ function rowDigest(value) {
1277
+ if (!value || typeof value !== 'object') return JSON.stringify(value ?? null)
1278
+ return JSON.stringify(Object.keys(value).sort().map((key) => [key, value[key]]))
1279
+ }
1280
+
1281
+ /** Whether two override keys are the same row as far as resolution is concerned. */
1282
+ const sameKey = (a, b) => String(a).toLowerCase() === String(b).toLowerCase()
1283
+
1284
+ /** `[[9,12],[14,18]]` → `09:00–12:00、14:00–18:00`. */
1285
+ const rangesText = (ranges) => (ranges ?? []).map(([from, to]) => `${pad2(from)}:00–${pad2(to)}:00`).join('、')
1286
+
1287
+ /**
1288
+ * The editor's draft for one row: numbers as strings, so an empty field stays empty.
1289
+ *
1290
+ * `tableRule` is the table's own `timeOfUse`, used only to seed a rule that does not exist
1291
+ * yet: a custom rule created here should start from the policy already in force (its days,
1292
+ * and whether it honours the holiday calendar) rather than from a hard-coded guess that
1293
+ * could contradict the table for this one row.
1294
+ */
1295
+ function draftOf(entryKey, row, tableRule) {
1296
+ const tou = row?.timeOfUse
1297
+ const text = (value) => (value == null ? '' : String(value))
1298
+ const named = (value) => (['weekday', 'weekend', 'all'].includes(value) ? value : null)
1299
+ return {
1300
+ key: entryKey ?? '',
1301
+ input: text(row?.inputPerMillion),
1302
+ cacheRead: text(row?.cacheReadPerMillion),
1303
+ cacheWrite: text(row?.cacheWritePerMillion),
1304
+ output: text(row?.outputPerMillion),
1305
+ multiplier: text(row?.multiplier),
1306
+ touMode: !tou ? 'inherit' : tou.enabled === false ? 'off' : 'custom',
1307
+ peakMultiplier: text(tou?.peakMultiplier),
1308
+ // A `days` form the picker cannot express (an array, or an unknown word) is kept as it is
1309
+ // rather than replaced by whichever option happens to come first — the blank option says
1310
+ // 保持原样 and `build()` then leaves the field alone.
1311
+ days: tou ? named(tou.days) ?? '' : named(tableRule?.days) ?? 'weekday',
1312
+ honorHolidays: tou ? tou.honorHolidays !== false : tableRule?.honorHolidays !== false,
1313
+ }
1314
+ }
1315
+
1316
+ /** How one row takes part in peak/valley pricing, in as few words as a cell allows. */
1317
+ function touLabel(tou) {
1318
+ if (!tou) return '跟随表级'
1319
+ if (tou.enabled === false) return '平铺'
1320
+ const days = tou.days === 'all' ? '每天' : tou.days === 'weekend' ? '周末' : Array.isArray(tou.days) ? '自定义日子' : '工作日'
1321
+ return `×${Number(tou.peakMultiplier) || 1} ${days}${tou.honorHolidays === false ? ' · 无节假日' : ''}`
1322
+ }
1323
+
1324
+ /**
1325
+ * The editor for one price row.
1326
+ *
1327
+ * One row at a time rather than a control in every cell: a row's *key* is what decides
1328
+ * which models it prices, so the key and its rule belong in one place you can read at
1329
+ * a glance. Fields the form does not show are carried over untouched when it saves —
1330
+ * a panel that quietly dropped a hand-written `currency` or `contextMultiplier` would
1331
+ * be worse than no editor at all.
1332
+ */
1333
+ function PriceRowForm({ entryKey, row, tableRule, isDefault = false, onSave, onCancel, onDelete }) {
1334
+ const [draft, setDraft] = React.useState(() => draftOf(entryKey, row, tableRule))
1335
+ const [error, setError] = React.useState('')
1336
+ const text = (field) => (event) => setDraft((current) => ({ ...current, [field]: event.target.value }))
1337
+ const check = (field) => (event) => setDraft((current) => ({ ...current, [field]: event.target.checked }))
1338
+ const preserved = Object.keys(row ?? {}).filter((field) => !ROW_MANAGED.includes(field))
1339
+
1340
+ /**
1341
+ * The row this draft describes, built on top of whatever the row already held.
1342
+ *
1343
+ * The catch-all row manages its four rates and nothing else: `multiplier` and `timeOfUse` are
1344
+ * read from the table, not from `default`, so offering them here would be offering fields
1345
+ * that do nothing. `currency` is not managed either — everything is quoted in CNY, and a row
1346
+ * that still carries one (an advanced, hand-written field) keeps it untouched.
1347
+ */
1348
+ const build = () => {
1349
+ const next = { ...(row ?? {}) }
1350
+ for (const [field, source] of ROW_NUMBERS) {
1351
+ if (isDefault && field === 'multiplier') continue
1352
+ const value = String(draft[source] ?? '').trim()
1353
+ if (value === '') delete next[field]
1354
+ else next[field] = Number(value)
1355
+ }
1356
+
1357
+ if (isDefault) return next
1358
+ if (draft.touMode === 'inherit') delete next.timeOfUse
1359
+ else if (draft.touMode === 'off') next.timeOfUse = { ...(row?.timeOfUse ?? {}), enabled: false }
1360
+ else {
1361
+ const tou = { ...(row?.timeOfUse ?? {}), enabled: true }
1362
+ const peak = String(draft.peakMultiplier ?? '').trim()
1363
+ if (peak === '') delete tou.peakMultiplier
1364
+ else tou.peakMultiplier = Number(peak)
1365
+ if (draft.days) tou.days = draft.days
1366
+ // Windows are not editable here, so a rule that has none takes the table's
1367
+ // rather than silently becoming a rule that never charges a peak hour.
1368
+ if (!Array.isArray(tou.peakRanges)) tou.peakRanges = tableRule?.peakRanges ?? []
1369
+ if (draft.honorHolidays) delete tou.honorHolidays
1370
+ else tou.honorHolidays = false
1371
+ next.timeOfUse = tou
1372
+ }
1373
+ return next
1374
+ }
1375
+
1376
+ const submit = () => {
1377
+ const key = draft.key.trim()
1378
+ if (!isDefault && !key) return setError('键不能为空')
1379
+ // An all-empty catch-all row does nothing at all — it would just be a key in the file that
1380
+ // the panel reports as "your row" while every rate still comes from the template.
1381
+ if (isDefault && ROW_NUMBERS.every(([, source]) => String(draft[source] ?? '').trim() === '')) {
1382
+ return setError('兜底行至少要填一个值 —— 全是空等于没有这一行')
1383
+ }
1384
+ for (const [field, source, label] of ROW_NUMBERS) {
1385
+ if (isDefault && field === 'multiplier') continue
1386
+ const value = String(draft[source] ?? '').trim()
1387
+ if (value !== '' && !Number.isFinite(Number(value))) return setError(`${label} 不是数字`)
1388
+ }
1389
+ if (!isDefault && draft.touMode === 'custom') {
1390
+ const peak = String(draft.peakMultiplier ?? '').trim()
1391
+ if (peak !== '' && !Number.isFinite(Number(peak))) return setError('峰时倍率不是数字')
1392
+ }
1393
+ setError('')
1394
+ return onSave(key, build())
1395
+ }
1396
+
1397
+ return h(
1398
+ 'div',
1399
+ { className: 'dub-form' },
1400
+ h('div', { className: 'dub-block-title' }, isDefault ? '编辑 default 兜底行' : entryKey ? `编辑 ${entryKey}` : '新增一行'),
1401
+ isDefault
1402
+ ? h('div', { className: 'dub-form-row' }, h('label', null, '键'), h('span', { className: 'dub-model' }, 'default(兜底行,键固定)'))
1403
+ : h(
1404
+ 'div',
1405
+ { className: 'dub-form-row' },
1406
+ h('label', null, '键'),
1407
+ h('input', { className: 'dub-form-key', value: draft.key, onChange: text('key'), placeholder: '模型名 / provider|模型名 / provider|* / *|模型名' }),
1408
+ ),
1409
+ h(
1410
+ 'div',
1411
+ { className: 'dub-form-row' },
1412
+ h('label', null, '输入'),
1413
+ h('input', { className: 'dub-form-num', value: draft.input, onChange: text('input') }),
1414
+ h('label', null, '缓存命中'),
1415
+ h('input', { className: 'dub-form-num', value: draft.cacheRead, onChange: text('cacheRead') }),
1416
+ h('label', null, '缓存写入'),
1417
+ h('input', { className: 'dub-form-num', value: draft.cacheWrite, onChange: text('cacheWrite') }),
1418
+ h('label', null, '输出'),
1419
+ h('input', { className: 'dub-form-num', value: draft.output, onChange: text('output') }),
1420
+ ),
1421
+ h(
1422
+ 'div',
1423
+ { className: 'dub-form-row' },
1424
+ isDefault ? null : h('label', null, '倍率'),
1425
+ isDefault ? null : h('input', { className: 'dub-form-num', value: draft.multiplier, onChange: text('multiplier'), placeholder: '跟随全局' }),
1426
+ isDefault ? null : h('label', null, '峰谷'),
1427
+ isDefault
1428
+ ? null
1429
+ : h(
1430
+ 'select',
1431
+ { value: draft.touMode, onChange: text('touMode') },
1432
+ h('option', { value: 'inherit' }, '跟随表级'),
1433
+ h('option', { value: 'off' }, '不参与峰谷'),
1434
+ h('option', { value: 'custom' }, '自定义'),
1435
+ ),
1436
+ ),
1437
+ !isDefault && draft.touMode === 'custom'
1438
+ ? h(
1439
+ 'div',
1440
+ { className: 'dub-form-row' },
1441
+ h('label', null, '峰时倍率'),
1442
+ h('input', { className: 'dub-form-num', value: draft.peakMultiplier, onChange: text('peakMultiplier') }),
1443
+ h('label', null, '适用日子'),
1444
+ h(
1445
+ 'select',
1446
+ { value: draft.days, onChange: text('days') },
1447
+ draft.days === '' ? h('option', { value: '' }, '保持原样') : null,
1448
+ h('option', { value: 'weekday' }, '周一至周五'),
1449
+ h('option', { value: 'weekend' }, '周末'),
1450
+ h('option', { value: 'all' }, '每天'),
1451
+ ),
1452
+ h(
1453
+ 'label',
1454
+ { className: 'dub-form-check' },
1455
+ h('input', { type: 'checkbox', checked: draft.honorHolidays, onChange: check('honorHolidays') }),
1456
+ '节假日按空闲',
1457
+ ),
1458
+ )
1459
+ : null,
1460
+ !isDefault && draft.touMode === 'custom'
1461
+ ? h(
1462
+ 'div',
1463
+ { className: 'dub-note' },
1464
+ tableRule?.peakRanges?.length
1465
+ ? `峰时窗口沿用表级:${rangesText(tableRule.peakRanges)};倍率留空即不加价,改窗口请直接编辑 pricing.json。`
1466
+ : '表级也没有峰时窗口 —— 这一行会一直不加价;写了倍率也一样(没有窗口就没有高峰时段)。',
1467
+ )
1468
+ : null,
1469
+ isDefault
1470
+ ? h(
1471
+ 'div',
1472
+ { className: 'dub-note' },
1473
+ '兜底行只管四个单价 —— 没有任何行匹配的模型按它计价。倍率与峰谷是表级设置(pricing.json 的 multiplier / timeOfUse),写在这一行里不会生效,所以这里不提供;' +
1474
+ '删掉这一行,表就回到内置模板,下次点「应用官方价格」会按官方列表第一条重新生成。',
1475
+ )
1476
+ : null,
1477
+ preserved.length > 0
1478
+ ? h('div', { className: 'dub-note' }, `本行另有字段原样保留:${preserved.map((field) => `${field}=${JSON.stringify(row[field])}`).join('、')}`)
1479
+ : null,
1480
+ error ? h('div', { className: 'dub-warn' }, error) : null,
1481
+ h(
1482
+ 'div',
1483
+ { className: 'dub-form-actions' },
1484
+ h('button', { className: 'dub-btn', onClick: onCancel }, '取消'),
1485
+ entryKey || isDefault
1486
+ ? h(
1487
+ 'button',
1488
+ { className: 'dub-btn', onClick: () => onDelete(entryKey, isDefault), title: isDefault ? '删除后可用「应用官方价格」重新生成' : '点一下即删除并写入 pricing.json' },
1489
+ isDefault ? '删除兜底行' : '删除此行',
1490
+ )
1491
+ : null,
1492
+ h('button', { className: 'dub-btn dub-btn-primary', onClick: submit }, '保存'),
543
1493
  ),
544
1494
  )
545
1495
  }
@@ -552,34 +1502,44 @@ window.__ModuleLoader__.load({
552
1502
  * host coalesces requests that land within a few seconds of each other so
553
1503
  * reopening the panel cannot hammer the docs site.
554
1504
  */
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')
1505
+ function ConfigPanel({ snapshot, reload, initialConfig = null, initialOfficial = null, initialEdit = null }) {
1506
+ const [official, setOfficial] = React.useState(initialOfficial ?? cachedOfficial)
1507
+ const [officialState, setOfficialState] = React.useState(
1508
+ (initialOfficial ?? cachedOfficial) ? 'ready' : 'loading',
1509
+ )
559
1510
  const [applyState, setApplyState] = React.useState('idle')
560
- const [config, setConfig] = React.useState(initialConfig)
1511
+ const [applyNote, setApplyNote] = React.useState('')
1512
+ const [fallbackState, setFallbackState] = React.useState('idle')
1513
+ const [unmatchedState, setUnmatchedState] = React.useState('idle')
1514
+ const [edit, setEdit] = React.useState(initialEdit)
1515
+ const [listState, setListState] = React.useState('idle')
1516
+ const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
561
1517
 
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
- )
1518
+ const loadOfficial = React.useCallback(async () => {
1519
+ // Only show the placeholder when there is nothing to show: flipping to
1520
+ // 'loading' with a table already on screen blanks it for a frame.
1521
+ if (!cachedOfficial) setOfficialState('loading')
1522
+ try {
1523
+ const response = await fetch(`/usage-badge/official-pricing?refresh=1&source=${OFFICIAL_SOURCE}`, { cache: 'no-store' })
1524
+ const body = await response.json()
1525
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
1526
+ cachedOfficial = body
1527
+ setOfficial(body)
1528
+ setOfficialState('ready')
1529
+ } catch (error) {
1530
+ // A failed refresh keeps the last good list on screen; the warning says why.
1531
+ if (!cachedOfficial) setOfficial(null)
1532
+ setOfficialState(`failed: ${error?.message ?? error}`)
1533
+ }
1534
+ }, [])
578
1535
 
579
1536
  const loadConfig = React.useCallback(async () => {
580
1537
  try {
581
1538
  const response = await fetch('/usage-badge/config', { cache: 'no-store' })
582
- if (response.ok) setConfig(await response.json())
1539
+ if (response.ok) {
1540
+ cachedConfig = await response.json()
1541
+ setConfig(cachedConfig)
1542
+ }
583
1543
  } catch {
584
1544
  // The effective config is informative here; a failure leaves the rest of
585
1545
  // the panel usable.
@@ -587,8 +1547,13 @@ window.__ModuleLoader__.load({
587
1547
  }, [])
588
1548
 
589
1549
  React.useEffect(() => {
590
- void loadOfficial(source)
591
- }, [loadOfficial, source])
1550
+ // Once per page load, on the first visit. The published list is fetched from the network
1551
+ // (the URL carries `refresh=1`, which bypasses the host's own short cache), and it changes
1552
+ // a few times a year — so leaving and re-entering the tab must not hit the network again.
1553
+ // What is on screen is the last answer, with its fetch time shown next to the table and a
1554
+ // button to ask again.
1555
+ if (!cachedOfficial) void loadOfficial()
1556
+ }, [loadOfficial])
592
1557
 
593
1558
  React.useEffect(() => {
594
1559
  void loadConfig()
@@ -600,11 +1565,14 @@ window.__ModuleLoader__.load({
600
1565
  const response = await fetch('/usage-badge/official-pricing/apply', {
601
1566
  method: 'POST',
602
1567
  headers: { 'content-type': 'application/json' },
603
- body: JSON.stringify({ source }),
1568
+ body: JSON.stringify({ source: OFFICIAL_SOURCE }),
604
1569
  })
605
1570
  const body = await response.json()
606
1571
  if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
607
1572
  setApplyState('applied')
1573
+ // Say when the default row was filled in: it decides what an unmatched model costs,
1574
+ // so it must not change without the panel mentioning it.
1575
+ setApplyNote(body.applied?.defaultFrom ? `;default 行原来没有,已按官方列表第一条(${body.applied.defaultFrom})补上` : '')
608
1576
  await loadConfig()
609
1577
  reload()
610
1578
  } catch (error) {
@@ -612,15 +1580,114 @@ window.__ModuleLoader__.load({
612
1580
  }
613
1581
  }
614
1582
 
1583
+ /**
1584
+ * Write one config field and refresh both views.
1585
+ *
1586
+ * Used by the two switches and by the row editor — everything that writes `pricing.json`
1587
+ * through the config route. The summary is reloaded as well, because all of it re-prices
1588
+ * history rather than only future requests.
1589
+ */
1590
+ const writeConfig = async (patch, setState, okValue) => {
1591
+ setState('saving')
1592
+ try {
1593
+ const response = await fetch('/usage-badge/config', {
1594
+ method: 'PUT',
1595
+ headers: { 'content-type': 'application/json' },
1596
+ body: JSON.stringify(patch),
1597
+ })
1598
+ const body = await response.json()
1599
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
1600
+ setState(okValue)
1601
+ await loadConfig()
1602
+ reload()
1603
+ } catch (error) {
1604
+ setState(`failed: ${error?.message ?? error}`)
1605
+ }
1606
+ }
1607
+
1608
+ const toggleFallback = (next) => writeConfig({ modelPrefixFallback: next }, setFallbackState, next ? 'on' : 'off')
1609
+ const toggleUnmatched = (next) => writeConfig({ priceUnmatchedModels: next }, setUnmatchedState, next ? 'on' : 'off')
1610
+
1611
+ /**
1612
+ * Save the whole override map.
1613
+ *
1614
+ * The host replaces `overrides` wholesale, so the map sent here is the map that
1615
+ * lands in pricing.json — which makes a rename a delete plus an add, exactly what
1616
+ * rebuilding the object does. Rows it does not mention are in the map too, and so
1617
+ * are their fields: the editor only rewrites the row it was given.
1618
+ */
1619
+ const saveRow = async (nextKey, row) => {
1620
+ // The catch-all row is a document field, not a row in the map.
1621
+ if (edit?.isDefault) {
1622
+ // Saving a draft seeded before someone else rewrote this row would silently revert
1623
+ // them — 「应用官方价格」 rewrites same-named rows, so this is reachable by clicking
1624
+ // apply while the form is open.
1625
+ if (rowDigest(config?.defaultRow ?? null) !== rowDigest(edit.row ?? null)) {
1626
+ setListState('failed: 兜底行在你编辑期间被改过(例如刚应用了官方价格)—— 取消后重新打开再改')
1627
+ return
1628
+ }
1629
+ await writeConfig({ default: row }, setListState, 'saved')
1630
+ setEdit(null)
1631
+ return
1632
+ }
1633
+ const overrides = { ...(effective?.overrides ?? {}) }
1634
+ // Refuse to land on a key that is already taken, case-insensitively: resolution ignores
1635
+ // case, so writing `DeepSeek-Flash` beside `deepseek-flash` would add a second row that can
1636
+ // never win, and writing the same key would silently replace a row the form never showed.
1637
+ const taken = Object.keys(overrides).find((key) => sameKey(key, nextKey) && key !== edit?.entryKey)
1638
+ if (taken) {
1639
+ setListState(`failed: 已有「${taken}」这一行(键名不区分大小写),直接编辑它即可`)
1640
+ return
1641
+ }
1642
+ if (edit?.entryKey && rowDigest(overrides[edit.entryKey] ?? null) !== rowDigest(edit.row ?? null)) {
1643
+ setListState('failed: 这一行在你编辑期间被改过(例如刚应用了官方价格)—— 取消后重新打开再改')
1644
+ return
1645
+ }
1646
+ if (edit?.entryKey && edit.entryKey !== nextKey) delete overrides[edit.entryKey]
1647
+ overrides[nextKey] = row
1648
+ await writeConfig({ overrides }, setListState, 'saved')
1649
+ setEdit(null)
1650
+ }
1651
+
1652
+ /**
1653
+ * Delete one row. The catch-all is removed with `default: null` and stays gone until an
1654
+ * official apply fills it in again — which is the whole point of being able to delete it.
1655
+ */
1656
+ const deleteRow = async (entryKey, isDefault = false) => {
1657
+ if (isDefault) {
1658
+ await writeConfig({ default: null }, setListState, 'deleted-default')
1659
+ setEdit(null)
1660
+ return
1661
+ }
1662
+ const overrides = { ...(effective?.overrides ?? {}) }
1663
+ delete overrides[entryKey]
1664
+ await writeConfig({ overrides }, setListState, 'deleted')
1665
+ setEdit(null)
1666
+ }
1667
+
615
1668
  const effective = config?.effective
616
1669
  const calendar = config?.calendar ?? snapshot.calendar
617
1670
  const bundled = config?.bundled
1671
+ // What the file has, not the template-merged view: the editor writes what it shows, so a
1672
+ // field the user cleared must read as cleared rather than as the template's value.
1673
+ const defaultRow = config?.defaultRow ?? null
1674
+ const fallbackOff = effective?.modelPrefixFallback === false
1675
+ const unmatchedOff = effective?.priceUnmatchedModels === false
1676
+ // Where the catch-all row came from. A host that is still being read (or whose read failed)
1677
+ // is not an old host, and saying so would send the reader off to restart for nothing.
1678
+ const defaultFrom =
1679
+ config?.defaultSource === 'template'
1680
+ ? '内置模板'
1681
+ : config?.defaultSource === 'file'
1682
+ ? '你的价格表'
1683
+ : config
1684
+ ? '未知(宿主是旧版本,重启后可见)'
1685
+ : '正在读取…'
618
1686
  const overrides = Object.entries(effective?.overrides ?? {})
619
- const rowsWithMultiplier = overrides.some(([, row]) => row?.multiplier != null && Number(row.multiplier) !== 1)
620
1687
 
621
1688
  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('、') : ''
1689
+ const windowsSource = policy ? rangesText(policy.peakRangesSource) : ''
1690
+ const windowsLocal = policy ? rangesText(policy.peakRangesLocal) : ''
624
1691
 
625
1692
  return h(
626
1693
  'div',
@@ -633,12 +1700,10 @@ window.__ModuleLoader__.load({
633
1700
  'div',
634
1701
  { className: 'dub-block-head' },
635
1702
  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' }, '重新获取'),
1703
+ h(Info, {
1704
+ text: '官方单价就是从这里来的:打开页面后第一次进入这个页签时抓一次 DeepSeek 中文定价页(人民币),之后只有点「重新获取」才会再抓 —— 官方价格一年也就改几次,切页签不该联网。官方页面没点名的模型,在下面的覆盖行里自己维护 —— 汇率、币种、倍率都不需要配置。',
1705
+ }),
1706
+ h('button', { className: 'dub-btn', onClick: () => void loadOfficial(), disabled: officialState === 'loading' }, '重新获取'),
642
1707
  h(
643
1708
  'button',
644
1709
  {
@@ -652,13 +1717,8 @@ window.__ModuleLoader__.load({
652
1717
  ),
653
1718
  officialState === 'loading' ? h('div', { className: 'dub-note' }, '正在获取官方定价…') : null,
654
1719
  officialState.startsWith('failed') ? h('div', { className: 'dub-warn' }, `获取失败:${officialState.slice(8)}`) : null,
655
- applyState === 'applied' ? h('div', { className: 'dub-note' }, '已应用并写入 pricing.json') : null,
1720
+ applyState === 'applied' ? h('div', { className: 'dub-note' }, `已应用并写入 pricing.json${applyNote}`) : null,
656
1721
  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
1722
  official
663
1723
  ? [
664
1724
  h(
@@ -700,7 +1760,7 @@ window.__ModuleLoader__.load({
700
1760
  h(
701
1761
  'div',
702
1762
  { className: 'dub-row', key: 'meta' },
703
- h('span', { className: 'dub-note' }, `单位:百万 tokens · 币种 ${official.currency.toUpperCase()} · 获取于 ${new Date(official.fetchedAt).toLocaleTimeString()}`),
1763
+ h('span', { className: 'dub-note' }, `单位:百万 tokens · 人民币 · 获取于 ${new Date(official.fetchedAt).toLocaleTimeString()}${agoSince(official.fetchedAt) ? `(${agoSince(official.fetchedAt)})` : ''}`),
704
1764
  ),
705
1765
  official.aliases?.names?.length
706
1766
  ? h(
@@ -711,13 +1771,18 @@ window.__ModuleLoader__.load({
711
1771
  : null,
712
1772
  h(
713
1773
  '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 ? '周末与中国法定节假日全天均为空闲时段。' : ''),
1774
+ { className: 'dub-row', key: 'policy' },
1775
+ h('span', { className: 'dub-note' }, `高峰时段 ${windowsLocal || windowsSource}`),
1776
+ h(Info, {
1777
+ text:
1778
+ // The page's own sentence, not a paraphrase of it.
1779
+ (policy.text
1780
+ ? `页面原文:${policy.text}`
1781
+ : `官方规则:${policy.offPeakIsHalfOfPeak ? '空闲时段价格为高峰时段价格的一半;' : ''}${policy.holidaysOffPeak ? '周末与中国法定节假日全天均为空闲时段。' : ''}`) +
1782
+ (policy.sourceZone
1783
+ ? `\n页面按${policy.sourceZone.name}书写窗口(${windowsSource}),这里已换算到本机时区。`
1784
+ : ''),
1785
+ }),
721
1786
  ),
722
1787
  ]
723
1788
  : null,
@@ -731,19 +1796,23 @@ window.__ModuleLoader__.load({
731
1796
  'div',
732
1797
  { className: 'dub-block-head' },
733
1798
  h('div', { className: 'dub-block-title' }, '法定节假日日历'),
1799
+ h(Info, {
1800
+ text:
1801
+ (bundled?.source && Object.keys(bundled.source).length
1802
+ ? `数据来源:${Object.entries(bundled.source).map(([year, meta]) => `${year} ${meta.document}`).join(';')}。`
1803
+ : '数据来源:国务院办公厅的年度放假安排通知。') +
1804
+ '调休上班日也一并收录,但按官方口径不影响计价 —— 周末即使被调休成上班日,政策原文仍说它全天空闲。' +
1805
+ '\n自定义日期可在 pricing.json 的 holidays.extra 里追加。',
1806
+ }),
734
1807
  h('span', { className: `dub-chip${calendar?.enabled ? ' dub-chip-holiday' : ''}` }, calendar?.enabled ? '已启用' : '未启用'),
735
1808
  ),
736
- // What the bundled table covers is shown whether or not the calendar is
737
- // on, so switching it on is an informed choice.
1809
+ // What the bundled table covers is shown whether or not the calendar is on, so
1810
+ // switching it on is an informed choice.
738
1811
  bundled
739
1812
  ? h(
740
1813
  'div',
741
1814
  { className: 'dub-row' },
742
- h(
743
- 'span',
744
- { className: 'dub-note' },
745
- `内置日历覆盖 ${bundled.years.join('、')}:${bundled.holidayCount} 个法定节假日(另收录 ${bundled.workdayCount} 个调休上班日,按官方口径不影响计价)。`,
746
- ),
1815
+ h('span', { className: 'dub-note' }, `覆盖 ${bundled.years.join('、')}:${bundled.holidayCount} 个法定节假日`),
747
1816
  )
748
1817
  : null,
749
1818
  calendar?.enabled
@@ -763,32 +1832,17 @@ window.__ModuleLoader__.load({
763
1832
  : h(
764
1833
  'div',
765
1834
  { className: 'dub-row' },
766
- h('span', { className: 'dub-note' }, '未启用时只按「周一至周五」判断,中国法定节假日会被当成普通工作日计费;点「应用官方价格」会自动启用。'),
1835
+ h('span', { className: 'dub-note' }, '未启用:工作日节假日会按峰价计费 —— 点「应用官方价格」会自动启用。'),
767
1836
  ),
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
1837
  calendar?.missingYears?.length
780
1838
  ? h('div', { className: 'dub-warn' }, `日历缺少 ${calendar.missingYears.join('、')} 年的数据(尚未公布或未收录),这些日期按普通工作日处理。`)
781
1839
  : null,
782
- h(
783
- 'div',
784
- { className: 'dub-row' },
785
- h('span', { className: 'dub-note' }, '自定义日期可在 pricing.json 的 holidays.extra 中追加。'),
786
- ),
787
1840
  ),
788
1841
 
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.
1842
+ // ── what is in effect, and the rows you maintain ─────────────────────
1843
+ // The published list above is where prices come from; this is where the rows are
1844
+ // read, and where a row that the published list does not name gets written. The
1845
+ // editor only ever rewrites the row it was handed, and never invents a field.
792
1846
  h(
793
1847
  'div',
794
1848
  { className: 'dub-block' },
@@ -796,62 +1850,273 @@ window.__ModuleLoader__.load({
796
1850
  'div',
797
1851
  { className: 'dub-block-head' },
798
1852
  h('div', { className: 'dub-block-title' }, '当前生效单价'),
799
- h('span', { className: 'dub-chip' }, '只读'),
1853
+ h('span', { className: 'dub-chip' }, '可编辑'),
1854
+ h(Info, {
1855
+ text:
1856
+ '键支持 模型名 / provider|模型名 / provider|* / *|模型名 四种写法(不区分大小写),模型名末尾加 * 表示前缀匹配。' +
1857
+ '精确行永远优先于前缀行;行内可自带峰谷规则,未写则跟随表级。' +
1858
+ '\n「应用官方价格」会覆盖官方页面点名的同名行,其余行不动。' +
1859
+ (config?.paths?.cacheKind
1860
+ ? `\n缓存:${
1861
+ config.paths.cacheKind === 'sqlite'
1862
+ ? 'SQLite(Node 内建的 node:sqlite)—— 一次保存只写变化的那几天,历史留在库里,内存只装最近一年多'
1863
+ : 'JSON 文档 —— 这个运行时没有 node:sqlite,退回旧的单文件缓存(保存代价随历史增长)'
1864
+ }`
1865
+ : '') +
1866
+ (config?.paths?.dataDir
1867
+ ? `\n本插件的全部文件都在:${String(config.paths.dataDir).replace(/^.*[\\/]\.dsh[\\/]/, '~/.dsh/').replace(/\\/g, '/')}/`
1868
+ : ''),
1869
+ }),
800
1870
  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)}`)
1871
+ ? 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)}(${defaultFrom})`)
802
1872
  : null,
1873
+ h(
1874
+ 'button',
1875
+ { className: 'dub-btn', onClick: () => setEdit({ entryKey: null, row: null }), disabled: !effective, title: '新增一行覆盖价格' },
1876
+ '新增一行',
1877
+ ),
803
1878
  ),
804
1879
  !effective
805
1880
  ? 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
- ),
1881
+ : h(
1882
+ React.Fragment,
1883
+ null,
1884
+ overrides.length === 0 && config.defaultSource !== 'file'
1885
+ ? h('div', { className: 'dub-note' }, '没有覆盖行。')
1886
+ : null,
1887
+ config.defaultSource === 'file' || overrides.length > 0
1888
+ ? h(
1889
+ 'table',
1890
+ { className: 'dub-table' },
1891
+ h(
1892
+ 'thead',
1893
+ null,
1894
+ h(
1895
+ 'tr',
1896
+ null,
1897
+ h('th', null, '键'),
1898
+ h('th', null, '输入'),
1899
+ h('th', null, '缓存命中'),
1900
+ h('th', null, '输出'),
1901
+ h('th', null, '倍率'),
1902
+ // Whether a row takes part in peak/valley pricing is the thing that
1903
+ // silently changes a number, so it gets a column of its own rather
1904
+ // than being something you have to go and read the JSON for.
1905
+ h('th', null, '峰谷'),
1906
+ h('th', null, '操作'),
1907
+ ),
1908
+ ),
1909
+ h(
1910
+ 'tbody',
1911
+ null,
1912
+ // A catch-all row is listed only when the table really has one. With no
1913
+ // row the built-in template stands in for it, and rendering *that* as a
1914
+ // row made a delete that had worked look like one that did nothing.
1915
+ config.defaultSource === 'file'
1916
+ ? h(
1917
+ 'tr',
1918
+ { key: '__default' },
1919
+ h(
1920
+ 'td',
1921
+ { className: 'dub-model', title: '兜底行:没有任何行匹配的模型按它计价;倍率与峰谷是表级设置,不在这一行里' },
1922
+ 'default',
1923
+ ),
1924
+ h('td', null, defaultRow?.inputPerMillion ?? '—'),
1925
+ h('td', null, defaultRow?.cacheReadPerMillion ?? '—'),
1926
+ h('td', null, defaultRow?.outputPerMillion ?? '—'),
1927
+ h('td', null, '—'),
1928
+ h('td', null, '—'),
1929
+ h(
1930
+ 'td',
1931
+ null,
1932
+ h(
1933
+ 'span',
1934
+ { className: 'dub-table-ops' },
1935
+ h(
1936
+ 'button',
1937
+ { className: 'dub-op', onClick: () => setEdit({ entryKey: null, row: defaultRow, isDefault: true }) },
1938
+ '编辑',
1939
+ ),
1940
+ h(
1941
+ 'button',
1942
+ { className: 'dub-op', onClick: () => void deleteRow(null, true), title: '删除后回到内置模板,可再用「应用官方价格」生成' },
1943
+ '删除',
1944
+ ),
1945
+ ),
1946
+ ),
1947
+ )
1948
+ : null,
1949
+ overrides.map(([key, row]) =>
1950
+ h(
1951
+ 'tr',
1952
+ { key },
1953
+ h('td', { className: 'dub-model', title: JSON.stringify(row) }, key),
1954
+ h('td', null, row.inputPerMillion ?? '—'),
1955
+ h('td', null, row.cacheReadPerMillion ?? '—'),
1956
+ h('td', null, row.outputPerMillion ?? '—'),
1957
+ h('td', null, row.multiplier ?? '—'),
1958
+ h('td', null, touLabel(row.timeOfUse)),
1959
+ h(
1960
+ 'td',
1961
+ null,
1962
+ h(
1963
+ 'span',
1964
+ { className: 'dub-table-ops' },
1965
+ h('button', { className: 'dub-op', onClick: () => setEdit({ entryKey: key, row }) }, '编辑'),
1966
+ h(
1967
+ 'button',
1968
+ { className: 'dub-op', onClick: () => void deleteRow(key), title: '点一下即删除并写入 pricing.json' },
1969
+ '删除',
1970
+ ),
1971
+ ),
1972
+ ),
1973
+ ),
1974
+ ),
1975
+ ),
1976
+ )
1977
+ : null,
1978
+ // What stands in for a missing catch-all, and the way to add one. Only when the
1979
+ // host actually said there is none: an old host sends no `defaultSource` at all,
1980
+ // and claiming the row is missing would be a guess (and a wrong one if the file
1981
+ // has it).
1982
+ config.defaultSource === 'template'
1983
+ ? h(
1984
+ 'div',
1985
+ { className: 'dub-row' },
1986
+ h(
1987
+ 'span',
1988
+ { className: 'dub-note', style: { flex: '1 1 320px' } },
1989
+ `兜底行还没有:未匹配的模型现在按内置模板计价(输入 ${price(effective.default?.inputPerMillion, effective.default?.currency)} / ` +
1990
+ `缓存 ${price(effective.default?.cacheReadPerMillion, effective.default?.currency)} / 输出 ${price(effective.default?.outputPerMillion, effective.default?.currency)})。`,
1991
+ ),
1992
+ h(
1993
+ 'button',
1994
+ {
1995
+ className: 'dub-btn',
1996
+ // Seeded with what is in effect right now, so adding the row changes no
1997
+ // number until the user edits one — and what the form shows is exactly
1998
+ // what gets written.
1999
+ onClick: () => setEdit({ entryKey: null, row: effective.default ?? null, isDefault: true }),
2000
+ title: '按当前生效的兜底价新建一行(可改)',
2001
+ },
2002
+ '添加兜底行',
2003
+ ),
2004
+ )
2005
+ : null,
830
2006
  ),
2007
+ listState === 'saved' ? h('div', { className: 'dub-note' }, '已写入 pricing.json') : null,
2008
+ listState === 'deleted' ? h('div', { className: 'dub-note' }, '已删除并写入 pricing.json') : null,
2009
+ listState === 'deleted-default'
2010
+ ? h('div', { className: 'dub-note' }, '兜底行已删除并写入 pricing.json —— 现在回到内置模板计价;点「应用官方价格」会按官方列表第一条重新生成')
2011
+ : null,
2012
+ listState.startsWith('failed') ? h('div', { className: 'dub-warn' }, `保存失败:${listState.slice(8)}`) : null,
2013
+ // The form mounts per row, so switching rows starts from that row's values.
2014
+ edit
2015
+ ? h(PriceRowForm, {
2016
+ key: edit.isDefault ? '__default-row' : edit.entryKey ?? '__new-row',
2017
+ entryKey: edit.entryKey,
2018
+ row: edit.row,
2019
+ isDefault: Boolean(edit.isDefault),
2020
+ tableRule: effective?.timeOfUse,
2021
+ onSave: saveRow,
2022
+ onCancel: () => setEdit(null),
2023
+ onDelete: deleteRow,
2024
+ })
2025
+ : null,
2026
+ // The two switches are a label, a state, a tooltip and a button. Their explanations
2027
+ // used to run for three lines each, which is what made this panel hard to scan.
831
2028
  h(
832
2029
  'div',
833
2030
  { className: 'dub-row' },
834
- h('span', { className: 'dub-note' }, '单价由上面的官方定价决定,这里只做展示;「应用官方价格」会按官方页面覆盖同名模型的行。'),
2031
+ h('span', { className: 'dub-note' }, `前缀回退:${fallbackOff ? '已关闭' : '开启'}`),
2032
+ h(Info, {
2033
+ text: fallbackOff
2034
+ ? '已关闭:只有精确行和带 * 的家族行会命中,没有精确行的模型一律走 default 行。'
2035
+ : '开启:没有精确行的模型会退到名字最长的前缀行 —— 官方生成的 deepseek-flash 直接就能管 deepseek-flash-preview,不必再手写一行。前缀后面必须是分隔符(- _ . : / @),所以 deepseek-flash 吞不掉 deepseek-flashpreview,gpt-4 也吞不掉 gpt-4o。',
2036
+ }),
2037
+ h(
2038
+ 'button',
2039
+ {
2040
+ className: 'dub-btn',
2041
+ onClick: () => void toggleFallback(fallbackOff),
2042
+ disabled: !effective || fallbackState === 'saving',
2043
+ title: '写进本插件的 pricing.json,并重算所有历史日期',
2044
+ },
2045
+ fallbackOff ? '开启前缀回退' : '关闭前缀回退',
2046
+ ),
835
2047
  ),
836
- config?.paths?.dataDir
2048
+ fallbackState === 'on' || fallbackState === 'off'
2049
+ ? h('div', { className: 'dub-note' }, fallbackState === 'on' ? '已开启并写入 pricing.json' : '已关闭并写入 pricing.json')
2050
+ : null,
2051
+ fallbackState.startsWith('failed')
2052
+ ? h('div', { className: 'dub-warn' }, `切换失败:${fallbackState.slice(8)}`)
2053
+ : null,
2054
+ // The other half of the same question: what a model that matches nothing costs.
2055
+ h(
2056
+ 'div',
2057
+ { className: 'dub-row' },
2058
+ h(
2059
+ 'span',
2060
+ { className: 'dub-note' },
2061
+ !effective
2062
+ ? '未匹配的模型:正在读取当前配置…'
2063
+ : unmatchedOff
2064
+ ? '未匹配的模型:不计价'
2065
+ : `未匹配的模型:按 default 行计价(${defaultFrom})`,
2066
+ ),
2067
+ h(Info, {
2068
+ text: unmatchedOff
2069
+ ? '不计价:没有任何行匹配的模型只统计 token 与请求数,金额记 0;用量面板会列出是哪些模型,所以那个 ¥0 不是无声的。'
2070
+ : `按 default 行计价。当前 default 来自${defaultFrom}:` +
2071
+ `输入 ${price(effective?.default?.inputPerMillion, effective?.default?.currency)} / ` +
2072
+ `缓存 ${price(effective?.default?.cacheReadPerMillion, effective?.default?.currency)} / ` +
2073
+ `输出 ${price(effective?.default?.outputPerMillion, effective?.default?.currency)}。` +
2074
+ (config?.defaultSource === 'template'
2075
+ ? '点「添加兜底行」能自己写,或点「应用官方价格」按官方列表第一条生成。'
2076
+ : ''),
2077
+ }),
2078
+ h(
2079
+ 'button',
2080
+ {
2081
+ className: 'dub-btn',
2082
+ onClick: () => void toggleUnmatched(unmatchedOff),
2083
+ disabled: !effective || unmatchedState === 'saving',
2084
+ title: '写进本插件的 pricing.json,并重算所有历史日期',
2085
+ },
2086
+ unmatchedOff ? '改为按 default 计价' : '改为不计价',
2087
+ ),
2088
+ ),
2089
+ unmatchedState === 'on' || unmatchedState === 'off'
837
2090
  ? h(
838
2091
  '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
- ),
2092
+ { className: 'dub-note' },
2093
+ unmatchedState === 'off'
2094
+ ? '未匹配的模型已改为不计价,并写入 pricing.json'
2095
+ : '未匹配的模型已改为按 default 行计价,并写入 pricing.json',
847
2096
  )
848
2097
  : null,
2098
+ unmatchedState.startsWith('failed')
2099
+ ? h('div', { className: 'dub-warn' }, `切换失败:${unmatchedState.slice(8)}`)
2100
+ : null,
849
2101
  ),
850
2102
  )
851
2103
  }
852
2104
 
853
- function UsageDialog({ snapshot, onClose, reload, initialTab = 'usage', initialConfig = null, initialOfficial = null }) {
2105
+ function UsageDialog({
2106
+ snapshot,
2107
+ onClose,
2108
+ reload,
2109
+ initialTab = 'usage',
2110
+ initialConfig = null,
2111
+ initialOfficial = null,
2112
+ initialRange = '24h',
2113
+ initialHidden = [],
2114
+ initialYear = null,
2115
+ initialYearData = null,
2116
+ initialEdit = null,
2117
+ }) {
854
2118
  const [tab, setTab] = React.useState(initialTab)
2119
+ const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
855
2120
  const chip = bandChip(snapshot)
856
2121
 
857
2122
  React.useEffect(() => {
@@ -862,6 +2127,39 @@ window.__ModuleLoader__.load({
862
2127
  return () => window.removeEventListener('keydown', onKey)
863
2128
  }, [onClose])
864
2129
 
2130
+ // The version handshake belongs to the dialog rather than to one tab: a refreshed page
2131
+ // against an unrestarted host is the normal way to get a UI whose buttons silently do
2132
+ // nothing, and that has to be said wherever the reader happens to be looking. One read per
2133
+ // page lifetime (the config tab keeps its own reads for the table itself); if it fails the
2134
+ // banner is simply absent, because both tabs work without it.
2135
+ React.useEffect(() => {
2136
+ if (config) return undefined
2137
+ let cancelled = false
2138
+ void (async () => {
2139
+ try {
2140
+ const response = await fetch('/usage-badge/config', { cache: 'no-store' })
2141
+ if (!response.ok) return
2142
+ const body = await response.json()
2143
+ cachedConfig = body
2144
+ if (!cancelled) setConfig(body)
2145
+ } catch {
2146
+ // best effort: the banner is an explanation, not a feature
2147
+ }
2148
+ })()
2149
+ return () => {
2150
+ cancelled = true
2151
+ }
2152
+ }, [config])
2153
+
2154
+ // Shown only on a mismatch, in both directions: the host being older is the common one
2155
+ // (the browser half is re-read on every page load, the host half only when DSH restarts).
2156
+ const versionNote =
2157
+ config && config.apiVersion !== REQUIRED_HOST_API
2158
+ ? config.apiVersion === undefined || config.apiVersion < REQUIRED_HOST_API
2159
+ ? `宿主半还是旧版本(API ${config.apiVersion ?? '未知'},界面需要 ${REQUIRED_HOST_API}):本页的开关、编辑、删除与「按年」可能点了没反应或显示不全 —— 重启客户端后再试。`
2160
+ : `界面是旧版本(需要 API ${REQUIRED_HOST_API},宿主已经是 ${config.apiVersion}):刷新页面即可。`
2161
+ : null
2162
+
865
2163
  return h(
866
2164
  'div',
867
2165
  { className: 'dub-mask', onClick: (event) => event.target === event.currentTarget && onClose() },
@@ -882,6 +2180,7 @@ window.__ModuleLoader__.load({
882
2180
  : null,
883
2181
  h('button', { className: 'dub-x', onClick: onClose, title: '关闭' }, '×'),
884
2182
  ),
2183
+ versionNote ? h('div', { className: 'dub-warn dub-version' }, versionNote) : null,
885
2184
  h(
886
2185
  'div',
887
2186
  { className: 'dub-tabs dub-panetabs' },
@@ -893,11 +2192,13 @@ window.__ModuleLoader__.load({
893
2192
  ),
894
2193
  ),
895
2194
  ),
896
- // Only the active panel is mounted, so returning to 单价配置 re-fetches
897
- // the published list — which is the behavior that was asked for.
2195
+ // Only the active panel is mounted, so the config panel remounts on every return to its
2196
+ // tab — which is exactly why its two payloads are kept for the page's lifetime: they
2197
+ // render at once, and the published price list is not fetched again unless the button is
2198
+ // pressed (`cachedOfficial` doubles as the "has it been fetched" flag).
898
2199
  tab === 'usage'
899
- ? h(UsagePanel, { snapshot })
900
- : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial }),
2200
+ ? h(UsagePanel, { snapshot, initialRange, initialHidden, initialYear, initialYearData })
2201
+ : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial, initialEdit }),
901
2202
  ),
902
2203
  )
903
2204
  }
@@ -905,15 +2206,17 @@ window.__ModuleLoader__.load({
905
2206
  /**
906
2207
  * The pill and its dialog.
907
2208
  *
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.
2209
+ * The optional props exist so a headless test can render the real component
2210
+ * against a real snapshot without a browser: the slot renderer passes none of
2211
+ * them, and each defaults to the live behavior.
911
2212
  *
912
2213
  * `wide` is the sidebar's own column state, supplied by the `sidebar.footer.action`
913
2214
  * seat: the full row while expanded, a 36px square on the collapsed rail.
914
2215
  *
915
2216
  * @param {{wide?: boolean, initialSnapshot?: object|null, initialOpen?: boolean,
916
- * initialTab?: string, initialConfig?: object|null, initialOfficial?: object|null}} props
2217
+ * initialTab?: string, initialConfig?: object|null, initialOfficial?: object|null,
2218
+ * initialRange?: string, initialHidden?: string[], initialYear?: number|null,
2219
+ * initialYearData?: object|null, initialEdit?: object|null}} props
917
2220
  */
918
2221
  function UsageBadge({
919
2222
  wide = true,
@@ -922,6 +2225,11 @@ window.__ModuleLoader__.load({
922
2225
  initialTab = 'usage',
923
2226
  initialConfig = null,
924
2227
  initialOfficial = null,
2228
+ initialRange = '24h',
2229
+ initialHidden = [],
2230
+ initialYear = null,
2231
+ initialYearData = null,
2232
+ initialEdit = null,
925
2233
  } = {}) {
926
2234
  const [snapshot, setSnapshot] = React.useState(initialSnapshot)
927
2235
  const [failed, setFailed] = React.useState(false)
@@ -972,7 +2280,7 @@ window.__ModuleLoader__.load({
972
2280
  h('span', { className: 'dub-pill-amount' }, text),
973
2281
  ),
974
2282
  open && snapshot
975
- ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial })
2283
+ ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange, initialHidden, initialYear, initialYearData, initialEdit })
976
2284
  : null,
977
2285
  )
978
2286
  }