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