dsh-usage-badge 0.1.2 → 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
@@ -178,6 +274,12 @@ window.__ModuleLoader__.load({
178
274
  /** The local `YYYY-MM-DD` key of a Date. */
179
275
  const dayKeyOf = (date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
180
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
+
181
283
  /**
182
284
  * The last `count` calendar days ending at `endKey`, oldest first.
183
285
  *
@@ -187,8 +289,7 @@ window.__ModuleLoader__.load({
187
289
  * showing them. A quiet Saturday is still a day.
188
290
  */
189
291
  function lastDays(endKey, count) {
190
- const [year, month, day] = endKey.split('-').map(Number)
191
- const cursor = new Date(year, month - 1, day)
292
+ const cursor = dateOfKey(endKey)
192
293
  const out = []
193
294
  for (let index = 0; index < count; index++) {
194
295
  out.unshift(dayKeyOf(cursor))
@@ -214,27 +315,62 @@ window.__ModuleLoader__.load({
214
315
  }
215
316
 
216
317
  /**
217
- * Build the chart points and totals for one range under one provider filter.
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).
218
320
  *
219
- * Each point carries all four plotted metrics, mirroring the desktop shell's
220
- * chart (amount, token volume, request count and cache-hit rate). Every range
221
- * answers from the same snapshot, so switching a tab or a provider never issues
222
- * another request.
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.
223
323
  */
224
- function buildSeries(snapshot, range, provider) {
225
- /** One point from any of the shapes the host sends. */
226
- const point = (label, title, src, dayClass) => ({
227
- label,
228
- title,
229
- dayClass,
324
+ function metricsOf(src) {
325
+ return {
230
326
  amount: Number(src.amount) || 0,
231
327
  tokens: totalTokens(src),
232
328
  requests: Number(src.requests) || 0,
233
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),
234
340
  // Raw input-side buckets, so a range total can compute one exact hit rate.
235
341
  input: (Number(src.input) || 0) + (Number(src.cacheRead) || 0) + (Number(src.cacheWrite) || 0),
236
342
  cacheRead: Number(src.cacheRead) || 0,
237
- })
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
238
374
 
239
375
  if (range === '24h') {
240
376
  const today = snapshot.today
@@ -338,16 +474,76 @@ window.__ModuleLoader__.load({
338
474
 
339
475
  /**
340
476
  * The usage chart: four smoothed series over a shared index axis, with a
341
- * hover tooltip that reports every series at the hovered index — the same
342
- * `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.
343
486
  */
344
- function UsageChart({ points }) {
487
+ function UsageChart({ points, initialHidden = [] }) {
345
488
  const [hover, setHover] = React.useState(null)
489
+ const [hidden, setHidden] = React.useState(() => new Set(initialHidden))
346
490
  const count = points.length
347
- const hasData = count > 0 && points.some((p) => SERIES.some((s) => (p[s.key] || 0) > 0))
348
- 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))
494
+
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
+ }
349
545
 
350
- const plotted = SERIES.map((series) => {
546
+ const plotted = shown.map((series) => {
351
547
  const max = series.max ?? Math.max(...points.map((p) => p[series.key] || 0), 0)
352
548
  const coords = points.map((p, index) => ({ x: chartX(index, count), y: chartY(p[series.key] || 0, max || 1) }))
353
549
  return { series, coords, max }
@@ -355,7 +551,22 @@ window.__ModuleLoader__.load({
355
551
 
356
552
  const gridY = [0, 0.25, 0.5, 0.75, 1].map((ratio) => CHART.top + ratio * (CHART.height - CHART.top - CHART.bottom))
357
553
  const baseline = CHART.height - CHART.bottom
358
- 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
+ }
359
570
 
360
571
  /** Nearest index for a pointer position, from the viewBox x it maps to. */
361
572
  const indexAt = (event) => {
@@ -372,18 +583,7 @@ window.__ModuleLoader__.load({
372
583
  return h(
373
584
  'div',
374
585
  { className: 'dub-chartwrap' },
375
- h(
376
- 'div',
377
- { className: 'dub-legend' },
378
- SERIES.map((series) =>
379
- h(
380
- 'span',
381
- { className: 'dub-legend-item', key: series.key },
382
- h('span', { className: 'dub-legend-swatch', style: { background: series.color } }),
383
- series.label,
384
- ),
385
- ),
386
- ),
586
+ legend,
387
587
  h(
388
588
  'div',
389
589
  { className: 'dub-plot' },
@@ -420,11 +620,14 @@ window.__ModuleLoader__.load({
420
620
  : null,
421
621
  ),
422
622
  // The amount series is filled, matching the shell's filled first dataset.
423
- h('path', {
424
- d: `${smoothPath(amountSeries.coords)}L${amountSeries.coords.at(-1)?.x ?? CHART.side},${baseline}L${amountSeries.coords[0]?.x ?? CHART.side},${baseline}Z`,
425
- fill: amountSeries.series.fill,
426
- stroke: 'none',
427
- }),
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,
428
631
  ...plotted.map(({ series, coords }) =>
429
632
  h('path', {
430
633
  key: series.key,
@@ -482,7 +685,8 @@ window.__ModuleLoader__.load({
482
685
  style: { left: `clamp(86px, ${(chartX(hover, count) / CHART.width) * 100}%, calc(100% - 86px))` },
483
686
  },
484
687
  h('div', { className: 'dub-tip-title' }, hovered.title),
485
- SERIES.map((series) =>
688
+ // Only the visible series, so the card agrees with the plot it belongs to.
689
+ shown.map((series) =>
486
690
  h(
487
691
  'div',
488
692
  { className: 'dub-tip-row', key: series.key },
@@ -497,6 +701,330 @@ window.__ModuleLoader__.load({
497
701
  )
498
702
  }
499
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
+
500
1028
  /** The period badge: the holiday calendar outranks the peak/valley window. */
501
1029
  function bandChip(snapshot) {
502
1030
  const calendar = snapshot.calendar
@@ -513,12 +1041,71 @@ window.__ModuleLoader__.load({
513
1041
  return h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, label), h('div', { className: 'dub-stat-v' }, value))
514
1042
  }
515
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
+
516
1055
  /** The 用量 panel: ranges, provider filter, totals and the chart. */
517
- function UsagePanel({ snapshot, initialRange = '24h' }) {
1056
+ function UsagePanel({ snapshot, initialRange = '24h', initialHidden = [], initialYear = null, initialYearData = null }) {
518
1057
  const [range, setRange] = React.useState(initialRange)
519
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
520
1076
  const providers = snapshot.today.providers || []
521
- 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)
522
1109
  // The stat cards describe exactly what the chart plots, so they are summed
523
1110
  // from the same points rather than read from a separate total. Summing the
524
1111
  // raw token buckets keeps the aggregate hit rate exact instead of averaging
@@ -548,6 +1135,31 @@ window.__ModuleLoader__.load({
548
1135
  item.label,
549
1136
  ),
550
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,
551
1163
  providers.length > 1 ? h('span', { className: 'dub-sep' }) : null,
552
1164
  providers.length > 1
553
1165
  ? [
@@ -581,10 +1193,50 @@ window.__ModuleLoader__.load({
581
1193
  h(Stat, { label: 'Token', value: tokens(totals.tokens) }),
582
1194
  h(Stat, { label: '缓存命中率', value: `${hitPercent.toFixed(1)}%` }),
583
1195
  ),
584
- 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 }),
585
1216
  series.marks
586
1217
  ? h('div', { className: 'dub-note', style: { paddingTop: '8px' } }, '绿色背景色带标示法定节假日(全天按空闲时段计价)')
587
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 }),
588
1240
  ),
589
1241
  )
590
1242
  }
@@ -595,11 +1247,253 @@ window.__ModuleLoader__.load({
595
1247
  * Switching tabs unmounts this panel, so without a cache every return to the tab
596
1248
  * starts from the loading placeholder and swaps its contents in a moment later —
597
1249
  * a visible flicker. Holding the last answer lets the panel render it straight
598
- * away and refresh behind it.
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.
599
1255
  */
600
1256
  let cachedConfig = null
601
1257
  let cachedOfficial = null
602
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 }, '保存'),
1493
+ ),
1494
+ )
1495
+ }
1496
+
603
1497
  /**
604
1498
  * The 单价配置 panel.
605
1499
  *
@@ -608,21 +1502,25 @@ window.__ModuleLoader__.load({
608
1502
  * host coalesces requests that land within a few seconds of each other so
609
1503
  * reopening the panel cannot hammer the docs site.
610
1504
  */
611
- function ConfigPanel({ snapshot, reload, initialConfig = null, initialOfficial = null }) {
612
- const [source, setSource] = React.useState('zh-cn')
1505
+ function ConfigPanel({ snapshot, reload, initialConfig = null, initialOfficial = null, initialEdit = null }) {
613
1506
  const [official, setOfficial] = React.useState(initialOfficial ?? cachedOfficial)
614
1507
  const [officialState, setOfficialState] = React.useState(
615
1508
  (initialOfficial ?? cachedOfficial) ? 'ready' : 'loading',
616
1509
  )
617
1510
  const [applyState, setApplyState] = React.useState('idle')
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')
618
1516
  const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
619
1517
 
620
- const loadOfficial = React.useCallback(async (which) => {
1518
+ const loadOfficial = React.useCallback(async () => {
621
1519
  // Only show the placeholder when there is nothing to show: flipping to
622
1520
  // 'loading' with a table already on screen blanks it for a frame.
623
1521
  if (!cachedOfficial) setOfficialState('loading')
624
1522
  try {
625
- const response = await fetch(`/usage-badge/official-pricing?refresh=1&source=${which}`, { cache: 'no-store' })
1523
+ const response = await fetch(`/usage-badge/official-pricing?refresh=1&source=${OFFICIAL_SOURCE}`, { cache: 'no-store' })
626
1524
  const body = await response.json()
627
1525
  if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
628
1526
  cachedOfficial = body
@@ -649,8 +1547,13 @@ window.__ModuleLoader__.load({
649
1547
  }, [])
650
1548
 
651
1549
  React.useEffect(() => {
652
- void loadOfficial(source)
653
- }, [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])
654
1557
 
655
1558
  React.useEffect(() => {
656
1559
  void loadConfig()
@@ -662,11 +1565,14 @@ window.__ModuleLoader__.load({
662
1565
  const response = await fetch('/usage-badge/official-pricing/apply', {
663
1566
  method: 'POST',
664
1567
  headers: { 'content-type': 'application/json' },
665
- body: JSON.stringify({ source }),
1568
+ body: JSON.stringify({ source: OFFICIAL_SOURCE }),
666
1569
  })
667
1570
  const body = await response.json()
668
1571
  if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
669
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})补上` : '')
670
1576
  await loadConfig()
671
1577
  reload()
672
1578
  } catch (error) {
@@ -674,15 +1580,114 @@ window.__ModuleLoader__.load({
674
1580
  }
675
1581
  }
676
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
+
677
1668
  const effective = config?.effective
678
1669
  const calendar = config?.calendar ?? snapshot.calendar
679
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
+ : '正在读取…'
680
1686
  const overrides = Object.entries(effective?.overrides ?? {})
681
- const rowsWithMultiplier = overrides.some(([, row]) => row?.multiplier != null && Number(row.multiplier) !== 1)
682
1687
 
683
1688
  const policy = official?.policy
684
- const windowsSource = policy ? policy.peakRangesSource.map(([s, e]) => `${String(s).padStart(2, '0')}:00–${String(e).padStart(2, '0')}:00`).join('、') : ''
685
- 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) : ''
686
1691
 
687
1692
  return h(
688
1693
  'div',
@@ -695,12 +1700,10 @@ window.__ModuleLoader__.load({
695
1700
  'div',
696
1701
  { className: 'dub-block-head' },
697
1702
  h('div', { className: 'dub-block-title' }, '官方定价'),
698
- h(
699
- 'select',
700
- { value: source, onChange: (event) => setSource(event.target.value), title: '官方页面语言(决定币种)' },
701
- OFFICIAL_SOURCES.map((item) => h('option', { key: item.id, value: item.id }, item.label)),
702
- ),
703
- 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' }, '重新获取'),
704
1707
  h(
705
1708
  'button',
706
1709
  {
@@ -714,13 +1717,8 @@ window.__ModuleLoader__.load({
714
1717
  ),
715
1718
  officialState === 'loading' ? h('div', { className: 'dub-note' }, '正在获取官方定价…') : null,
716
1719
  officialState.startsWith('failed') ? h('div', { className: 'dub-warn' }, `获取失败:${officialState.slice(8)}`) : null,
717
- applyState === 'applied' ? h('div', { className: 'dub-note' }, '已应用并写入 pricing.json') : null,
1720
+ applyState === 'applied' ? h('div', { className: 'dub-note' }, `已应用并写入 pricing.json${applyNote}`) : null,
718
1721
  applyState.startsWith('failed') ? h('div', { className: 'dub-warn' }, `应用失败:${applyState.slice(8)}`) : null,
719
- h(
720
- 'div',
721
- { className: 'dub-row' },
722
- h('span', { className: 'dub-note' }, '这是唯一的单价来源:汇率、币种、倍率都不需要配置(官方中文页即人民币,徽标也始终是人民币)。'),
723
- ),
724
1722
  official
725
1723
  ? [
726
1724
  h(
@@ -762,7 +1760,7 @@ window.__ModuleLoader__.load({
762
1760
  h(
763
1761
  'div',
764
1762
  { className: 'dub-row', key: 'meta' },
765
- 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)})` : ''}`),
766
1764
  ),
767
1765
  official.aliases?.names?.length
768
1766
  ? h(
@@ -773,13 +1771,18 @@ window.__ModuleLoader__.load({
773
1771
  : null,
774
1772
  h(
775
1773
  'div',
776
- { className: 'dub-policy', key: 'policy' },
777
- h('div', null, `高峰时段:${policy.sourceZone ? `${policy.sourceZone.name} ` : ''}${windowsSource}`),
778
- policy.peakRangesLocal.join() !== policy.peakRangesSource.join()
779
- ? h('div', null, `本机时区换算后:${windowsLocal}`)
780
- : null,
781
- h('div', null, policy.offPeakIsHalfOfPeak ? '空闲时段价格为高峰时段价格的一半。' : ''),
782
- 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
+ }),
783
1786
  ),
784
1787
  ]
785
1788
  : null,
@@ -793,19 +1796,23 @@ window.__ModuleLoader__.load({
793
1796
  'div',
794
1797
  { className: 'dub-block-head' },
795
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
+ }),
796
1807
  h('span', { className: `dub-chip${calendar?.enabled ? ' dub-chip-holiday' : ''}` }, calendar?.enabled ? '已启用' : '未启用'),
797
1808
  ),
798
- // What the bundled table covers is shown whether or not the calendar is
799
- // 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.
800
1811
  bundled
801
1812
  ? h(
802
1813
  'div',
803
1814
  { className: 'dub-row' },
804
- h(
805
- 'span',
806
- { className: 'dub-note' },
807
- `内置日历覆盖 ${bundled.years.join('、')}:${bundled.holidayCount} 个法定节假日(另收录 ${bundled.workdayCount} 个调休上班日,按官方口径不影响计价)。`,
808
- ),
1815
+ h('span', { className: 'dub-note' }, `覆盖 ${bundled.years.join('、')}:${bundled.holidayCount} 个法定节假日`),
809
1816
  )
810
1817
  : null,
811
1818
  calendar?.enabled
@@ -825,32 +1832,17 @@ window.__ModuleLoader__.load({
825
1832
  : h(
826
1833
  'div',
827
1834
  { className: 'dub-row' },
828
- h('span', { className: 'dub-note' }, '未启用时只按「周一至周五」判断,中国法定节假日会被当成普通工作日计费;点「应用官方价格」会自动启用。'),
1835
+ h('span', { className: 'dub-note' }, '未启用:工作日节假日会按峰价计费 —— 点「应用官方价格」会自动启用。'),
829
1836
  ),
830
- bundled?.source && Object.keys(bundled.source).length
831
- ? h(
832
- 'div',
833
- { className: 'dub-row' },
834
- h(
835
- 'span',
836
- { className: 'dub-note' },
837
- `数据来源:${Object.entries(bundled.source).map(([year, meta]) => `${year} ${meta.document}`).join(';')}`,
838
- ),
839
- )
840
- : null,
841
1837
  calendar?.missingYears?.length
842
1838
  ? h('div', { className: 'dub-warn' }, `日历缺少 ${calendar.missingYears.join('、')} 年的数据(尚未公布或未收录),这些日期按普通工作日处理。`)
843
1839
  : null,
844
- h(
845
- 'div',
846
- { className: 'dub-row' },
847
- h('span', { className: 'dub-note' }, '自定义日期可在 pricing.json 的 holidays.extra 中追加。'),
848
- ),
849
1840
  ),
850
1841
 
851
- // ── what is in effect ────────────────────────────────────────────────
852
- // Read-only on purpose: prices come from the published list above, so
853
- // 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.
854
1846
  h(
855
1847
  'div',
856
1848
  { className: 'dub-block' },
@@ -858,62 +1850,273 @@ window.__ModuleLoader__.load({
858
1850
  'div',
859
1851
  { className: 'dub-block-head' },
860
1852
  h('div', { className: 'dub-block-title' }, '当前生效单价'),
861
- 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
+ }),
862
1870
  effective
863
- ? 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})`)
864
1872
  : null,
1873
+ h(
1874
+ 'button',
1875
+ { className: 'dub-btn', onClick: () => setEdit({ entryKey: null, row: null }), disabled: !effective, title: '新增一行覆盖价格' },
1876
+ '新增一行',
1877
+ ),
865
1878
  ),
866
1879
  !effective
867
1880
  ? h('div', { className: 'dub-note' }, '正在读取当前配置…')
868
- : overrides.length === 0
869
- ? h('div', { className: 'dub-note' }, '没有覆盖行,全部模型按 default 计价。')
870
- : h(
871
- 'table',
872
- { className: 'dub-table' },
873
- h('thead', null, h('tr', null, h('th', null, '键'), h('th', null, '输入'), h('th', null, '缓存命中'), h('th', null, '输出'), rowsWithMultiplier ? h('th', null, '倍率') : null)),
874
- h(
875
- 'tbody',
876
- null,
877
- overrides.map(([key, row]) =>
878
- h(
879
- 'tr',
880
- { key },
881
- h('td', { className: 'dub-model' }, key),
882
- h('td', null, row.inputPerMillion ?? '—'),
883
- h('td', null, row.cacheReadPerMillion ?? '—'),
884
- h('td', null, row.outputPerMillion ?? '—'),
885
- // The multiplier column only appears when a row actually
886
- // carries one, so a pure official-price setup stays clean but
887
- // a factor that changes the number is never invisible.
888
- rowsWithMultiplier ? h('td', null, row.multiplier ?? '—') : null,
889
- ),
890
- ),
891
- ),
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,
892
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.
893
2028
  h(
894
2029
  'div',
895
2030
  { className: 'dub-row' },
896
- 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
+ ),
2047
+ ),
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
+ ),
897
2088
  ),
898
- config?.paths?.dataDir
2089
+ unmatchedState === 'on' || unmatchedState === 'off'
899
2090
  ? h(
900
2091
  'div',
901
- { className: 'dub-row' },
902
- h(
903
- 'span',
904
- { className: 'dub-note' },
905
- `本插件的全部文件都在:${String(config.paths.dataDir)
906
- .replace(/^.*[\\/]\.dsh[\\/]/, '~/.dsh/')
907
- .replace(/\\/g, '/')}/`,
908
- ),
2092
+ { className: 'dub-note' },
2093
+ unmatchedState === 'off'
2094
+ ? '未匹配的模型已改为不计价,并写入 pricing.json'
2095
+ : '未匹配的模型已改为按 default 行计价,并写入 pricing.json',
909
2096
  )
910
2097
  : null,
2098
+ unmatchedState.startsWith('failed')
2099
+ ? h('div', { className: 'dub-warn' }, `切换失败:${unmatchedState.slice(8)}`)
2100
+ : null,
911
2101
  ),
912
2102
  )
913
2103
  }
914
2104
 
915
- function UsageDialog({ snapshot, onClose, reload, initialTab = 'usage', initialConfig = null, initialOfficial = null, initialRange = '24h' }) {
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
+ }) {
916
2118
  const [tab, setTab] = React.useState(initialTab)
2119
+ const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
917
2120
  const chip = bandChip(snapshot)
918
2121
 
919
2122
  React.useEffect(() => {
@@ -924,6 +2127,39 @@ window.__ModuleLoader__.load({
924
2127
  return () => window.removeEventListener('keydown', onKey)
925
2128
  }, [onClose])
926
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
+
927
2163
  return h(
928
2164
  'div',
929
2165
  { className: 'dub-mask', onClick: (event) => event.target === event.currentTarget && onClose() },
@@ -944,6 +2180,7 @@ window.__ModuleLoader__.load({
944
2180
  : null,
945
2181
  h('button', { className: 'dub-x', onClick: onClose, title: '关闭' }, '×'),
946
2182
  ),
2183
+ versionNote ? h('div', { className: 'dub-warn dub-version' }, versionNote) : null,
947
2184
  h(
948
2185
  'div',
949
2186
  { className: 'dub-tabs dub-panetabs' },
@@ -955,11 +2192,13 @@ window.__ModuleLoader__.load({
955
2192
  ),
956
2193
  ),
957
2194
  ),
958
- // Only the active panel is mounted, so returning to 单价配置 re-fetches the
959
- // published list — from the cache it renders at once, then refreshes.
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).
960
2199
  tab === 'usage'
961
- ? h(UsagePanel, { snapshot, initialRange })
962
- : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial }),
2200
+ ? h(UsagePanel, { snapshot, initialRange, initialHidden, initialYear, initialYearData })
2201
+ : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial, initialEdit }),
963
2202
  ),
964
2203
  )
965
2204
  }
@@ -967,16 +2206,17 @@ window.__ModuleLoader__.load({
967
2206
  /**
968
2207
  * The pill and its dialog.
969
2208
  *
970
- * The two optional props exist so a headless test can render the real
971
- * component against a real snapshot without a browser: the slot renderer
972
- * 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.
973
2212
  *
974
2213
  * `wide` is the sidebar's own column state, supplied by the `sidebar.footer.action`
975
2214
  * seat: the full row while expanded, a 36px square on the collapsed rail.
976
2215
  *
977
2216
  * @param {{wide?: boolean, initialSnapshot?: object|null, initialOpen?: boolean,
978
2217
  * initialTab?: string, initialConfig?: object|null, initialOfficial?: object|null,
979
- * initialRange?: string}} props
2218
+ * initialRange?: string, initialHidden?: string[], initialYear?: number|null,
2219
+ * initialYearData?: object|null, initialEdit?: object|null}} props
980
2220
  */
981
2221
  function UsageBadge({
982
2222
  wide = true,
@@ -986,6 +2226,10 @@ window.__ModuleLoader__.load({
986
2226
  initialConfig = null,
987
2227
  initialOfficial = null,
988
2228
  initialRange = '24h',
2229
+ initialHidden = [],
2230
+ initialYear = null,
2231
+ initialYearData = null,
2232
+ initialEdit = null,
989
2233
  } = {}) {
990
2234
  const [snapshot, setSnapshot] = React.useState(initialSnapshot)
991
2235
  const [failed, setFailed] = React.useState(false)
@@ -1036,7 +2280,7 @@ window.__ModuleLoader__.load({
1036
2280
  h('span', { className: 'dub-pill-amount' }, text),
1037
2281
  ),
1038
2282
  open && snapshot
1039
- ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange })
2283
+ ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange, initialHidden, initialYear, initialYearData, initialEdit })
1040
2284
  : null,
1041
2285
  )
1042
2286
  }