dsh-commandcode-quota 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/README.zh.md +13 -3
- package/package.json +1 -1
- package/src/client.js +478 -164
- package/src/index.js +90 -9
- package/test/client.test.cjs +11 -8
- package/test/host.test.cjs +14 -0
package/README.md
CHANGED
|
@@ -6,7 +6,12 @@ The plugin renders a compact status row directly underneath the DSH composer:
|
|
|
6
6
|
|
|
7
7
|
- **5-hour, weekly and monthly usage windows** as live percentages
|
|
8
8
|
- **Available balance**
|
|
9
|
-
- Click the row to expand the full breakdown: used / limit / remaining / reset time, total spend, tokens,
|
|
9
|
+
- Click the row to expand the full breakdown: used / limit / remaining / reset time, total spend, tokens, daily spend, subscription and account information
|
|
10
|
+
- **Three status lights** — normal / low balance / exhausted. The current one lights up and the others dim, driven by the official `belowThreshold` and `exceeded` fields; hover any light for an explanation
|
|
11
|
+
- **The bar color follows usage**: cool while usage is low, warming up past 55%, red near the limit, with an internal gradient
|
|
12
|
+
- **Six bar palettes** to choose from in the gear settings; the choice is remembered
|
|
13
|
+
- **Overrun warning**: based on this period's daily average, the monthly card adds a line like “~N days until empty” when the remaining credit will not last until reset
|
|
14
|
+
- A skeleton screen shows on first load so the layout does not jump when data arrives
|
|
10
15
|
|
|
11
16
|
It is **single-account by design**. There is no account switcher, and your API key never reaches the browser.
|
|
12
17
|
|
|
@@ -76,12 +81,14 @@ The package has two halves:
|
|
|
76
81
|
- **Host half** (`src/index.js`) resolves the key through the DSH credentials service and registers a read-only HTTP endpoint that fans out to the Command Code API, then aggregates the responses into a single payload. The key is read on every request and never serialized to the client.
|
|
77
82
|
- **Client half** (`src/client.js`) registers a status row into the composer dock slot, polls the host endpoint, and renders it with DSH theme tokens so light and dark themes both work.
|
|
78
83
|
|
|
84
|
+
Polling is tiered: balance every 3 minutes (cheap), usage and subscriptions every 30 minutes (expensive). Polling pauses while the page is hidden and refreshes fully when it comes back.
|
|
85
|
+
|
|
79
86
|
Data comes from four Command Code endpoints, each called with `Authorization: Bearer <key>`:
|
|
80
87
|
|
|
81
88
|
| Endpoint | Used for |
|
|
82
89
|
|---|---|
|
|
83
90
|
| `GET /alpha/billing/credits` | Balance and the 5-hour / weekly limits |
|
|
84
|
-
| `GET /alpha/usage/summary` | Spend, tokens and
|
|
91
|
+
| `GET /alpha/usage/summary` | Spend, tokens and the period start (for daily spend) |
|
|
85
92
|
| `GET /alpha/billing/subscriptions` | Subscription status and period boundaries |
|
|
86
93
|
| `GET /alpha/whoami` | Account information |
|
|
87
94
|
|
|
@@ -91,8 +98,11 @@ The monthly window is composed from the usage total plus the remaining credits,
|
|
|
91
98
|
|
|
92
99
|
- The four requests run in parallel, so total latency is the slowest one rather than the sum.
|
|
93
100
|
- A connection-level failure is retried once; a deliberate timeout is not retried.
|
|
94
|
-
- On failure the panel falls back to the last successful value (kept
|
|
101
|
+
- On failure the panel falls back to the last successful value (kept for 6 hours) and reports it as stale.
|
|
102
|
+
- That snapshot is also written to `$DSH_HOME/dsh-commandcode-quota-cache.json`, so it **survives a restart**. It holds quota fields only — no account details, and never the key itself.
|
|
103
|
+
- The snapshot is keyed by a fingerprint of the API key, so switching keys never reuses another account's data.
|
|
95
104
|
- An HTTP 401 is never masked by cached data, because it means the credential itself is wrong.
|
|
105
|
+
- When **all four** endpoints fail, the response carries a readable reason and the panel shows an error rather than a blank box.
|
|
96
106
|
|
|
97
107
|
## Development
|
|
98
108
|
|
package/README.zh.md
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
- **5 小时、每周、每月**三个额度窗口的实时百分比
|
|
8
8
|
- **可用余额**
|
|
9
|
-
- 点击状态栏展开完整明细:已用 / 上限 / 剩余 / 重置时间、总消耗、Tokens
|
|
9
|
+
- 点击状态栏展开完整明细:已用 / 上限 / 剩余 / 重置时间、总消耗、Tokens、日均消耗,以及订阅和账户信息
|
|
10
|
+
- **三档状态灯**:正常 / 余额低 / 已用尽,当前档亮起、其余压暗(依据官方 `belowThreshold` 与 `exceeded` 字段),悬停每个灯有解释
|
|
11
|
+
- **进度条颜色随用量变化**:低用量保持所选冷色,55% 之后逐渐转暖,接近上限转红;条形内部也带一段渐变
|
|
12
|
+
- **六种进度配色可选**:在齿轮设置里切换,选择会记住
|
|
13
|
+
- **超额预警**:按本周期日均推算,如果会在重置前用光,月窗口会多一行「按当前日均,约 N 天后用完」
|
|
14
|
+
- 首次加载显示骨架屏,数据回来时版面不会跳一下
|
|
10
15
|
|
|
11
16
|
**刻意做成单账号**:没有账号切换,API key 也不会进入浏览器。
|
|
12
17
|
|
|
@@ -76,12 +81,14 @@ New-Item -ItemType Junction `
|
|
|
76
81
|
- **宿主端**(`src/index.js`)通过 DSH 凭据服务解析 key,注册一个只读 HTTP 端点,并发请求 Command Code API 后聚合成一个响应。key 每次请求现读,绝不序列化给客户端。
|
|
77
82
|
- **客户端**(`src/client.js`)往输入框的 dock 槽位注册状态栏,轮询宿主端点,并全部使用 DSH 主题 token 渲染,自动适配深色/浅色主题。
|
|
78
83
|
|
|
84
|
+
轮询是分层的:余额 3 分钟一次(轻量),用量与订阅 30 分钟一次(重查询);页面不可见时暂停,切回前台立即补一次完整查询。
|
|
85
|
+
|
|
79
86
|
数据来自四个 Command Code 接口,均带 `Authorization: Bearer <key>`:
|
|
80
87
|
|
|
81
88
|
| 接口 | 用途 |
|
|
82
89
|
|---|---|
|
|
83
90
|
| `GET /alpha/billing/credits` | 余额,以及 5 小时 / 每周限额 |
|
|
84
|
-
| `GET /alpha/usage/summary` | 消耗、Tokens
|
|
91
|
+
| `GET /alpha/usage/summary` | 消耗、Tokens、周期起始时间(用于日均消耗) |
|
|
85
92
|
| `GET /alpha/billing/subscriptions` | 订阅状态与周期起止 |
|
|
86
93
|
| `GET /alpha/whoami` | 账户信息 |
|
|
87
94
|
|
|
@@ -91,8 +98,11 @@ New-Item -ItemType Junction `
|
|
|
91
98
|
|
|
92
99
|
- 四个请求并发发出,总耗时等于最慢的一个,而不是四者之和。
|
|
93
100
|
- 连接级失败重试一次;主动超时不重试。
|
|
94
|
-
-
|
|
101
|
+
- 请求失败时回退到上次成功的值(保留 6 小时),并标记为陈旧数据。
|
|
102
|
+
- 这份快照同时落盘到 `$DSH_HOME/dsh-commandcode-quota-cache.json`,**重启后依然可用**;只保存额度相关字段,不含账户信息,也不含 key 本身。
|
|
103
|
+
- 快照按 API key 的指纹隔离:换 key 之后不会把上一个账号的数据当成兜底值。
|
|
95
104
|
- HTTP 401 不会被缓存掩盖——它意味着凭据本身有问题,必须让用户看到。
|
|
105
|
+
- 四个接口**全部**失败时,响应会带出可读的失败原因,面板显示错误而不是一块空白。
|
|
96
106
|
|
|
97
107
|
## 开发
|
|
98
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-commandcode-quota",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Command Code balance and quota panel for DeepSeek Harness: a status row under the composer showing balance and the 5-hour, weekly and monthly windows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/client.js
CHANGED
|
@@ -18,8 +18,13 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
|
|
19
19
|
const FETCH_URL = "/__dsh-commandcode-quota/dashboard";
|
|
20
20
|
const CREDENTIALS_URL = "/__dsh-commandcode-quota/credentials";
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// 分层轮询: credits(余额)轻量, 3 分钟一次; usage/subscriptions 是重查询, 30 分钟一次。
|
|
22
|
+
// 页面不可见时整轮跳过, 切回前台立即补一次 —— 免得后台白打上游接口。
|
|
23
|
+
const QUICK_REFRESH_MS = 3 * 60 * 1000;
|
|
24
|
+
const FULL_REFRESH_MS = 30 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
// 接口名 → 中文, 用于把宿主端返回的 errors 展示成人能读懂的话
|
|
27
|
+
const ENDPOINT_NAMES = { credits: "余额", usage: "用量", subscriptions: "订阅", whoami: "账户" };
|
|
23
28
|
|
|
24
29
|
// ---- 格式化 --------------------------------------------------------------
|
|
25
30
|
|
|
@@ -28,6 +33,7 @@ window.__ModuleLoader__.load({
|
|
|
28
33
|
return "$" + value.toFixed(digits);
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
// 不设 G 档: token 计量惯例是 K/M, 2.56e9 应显示为 2560.5M 而不是 2.56G。
|
|
31
37
|
function fmtNumber(value) {
|
|
32
38
|
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
33
39
|
if (value >= 1e6) return (value / 1e6).toFixed(1) + "M";
|
|
@@ -86,7 +92,8 @@ window.__ModuleLoader__.load({
|
|
|
86
92
|
const m = Math.floor((diff % 3600000) / 60000);
|
|
87
93
|
if (days > 0) return `${days}天${h}小时`;
|
|
88
94
|
if (h > 0) return `${h}小时${m}分`;
|
|
89
|
-
|
|
95
|
+
// 不足 1 小时时精确到秒没有意义, 让人读到"47 分钟"就够了
|
|
96
|
+
return `${Math.max(1, m)} 分钟`;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
// 到期日与今天不同时补上日期, 同时回答"还有多久"和"哪天到期"
|
|
@@ -106,23 +113,33 @@ window.__ModuleLoader__.load({
|
|
|
106
113
|
|
|
107
114
|
const T = {
|
|
108
115
|
bg: "var(--dsw-alias-bg-layer-1, #232324)",
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
// 不要用 --dsw-alias-brand-primary: 它只是个 neutral 色阶别名,
|
|
117
|
+
// 卡片底色: 从 layer-1 只混 6% 主文字色 —— 混多了是一块突兀的亮灰,
|
|
118
|
+
// 照搬 layer-2 又可能换肤后和 layer-1 同色糊成一片。6% + 1px 边框刚好。
|
|
119
|
+
bg2: "color-mix(in srgb, var(--dsw-alias-bg-layer-1, #232324) 96%, var(--dsw-alias-label-primary, #f9fafb) 4%)",
|
|
111
120
|
border: "var(--dsw-alias-border-l2, rgba(255,255,255,.14))",
|
|
112
121
|
borderL: "var(--dsw-alias-border-l1, rgba(255,255,255,.08))",
|
|
122
|
+
// 卡片描边自己派生: 浅色主题下主题的 border-l1 偏重(实测 #c4c5c6),
|
|
123
|
+
// 1px 就能把面板描得发脏; 10% 主文字色在两种主题下都刚好是一条淡线。
|
|
124
|
+
hairline: "color-mix(in srgb, var(--dsw-alias-label-primary, #f9fafb) 10%, transparent)",
|
|
113
125
|
label: "var(--dsw-alias-label-primary, #f9fafb)",
|
|
114
126
|
label2: "var(--dsw-alias-label-secondary, #d3d5d9)",
|
|
115
127
|
label3: "var(--dsw-alias-label-tertiary, #adb2b8)",
|
|
116
|
-
// 进度条轨道在 深色主题下直接用近黑半透明 (Bar 里用 TRACK_STYLE 常量)
|
|
117
|
-
track: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.2))",
|
|
118
128
|
success: "var(--dsw-alias-state-success-primary, #22c55e)",
|
|
119
129
|
warn: "var(--dsw-alias-state-warn-primary, #f59e0b)",
|
|
120
130
|
error: "var(--dsw-alias-state-error-primary, #f25a5a)",
|
|
121
|
-
|
|
131
|
+
// 进度色用 Anthropic 官方柔和蓝 #6A9BCC: 低饱和, 深色底上不刺眼,
|
|
132
|
+
// 又天然和告警的琥珀 / 红分开。不能用 --dsw-alias-brand-primary
|
|
133
|
+
// (它只是中性色阶别名, 换肤后会整条变黑)。
|
|
134
|
+
brand: "#6A9BCC",
|
|
122
135
|
};
|
|
123
136
|
|
|
124
|
-
//
|
|
125
|
-
|
|
137
|
+
// 进度条轨道: 从主文字色派生 10% 透明度 —— 深色主题得浅色轨道, 浅色主题得深色轨道,
|
|
138
|
+
// 不依赖 interactive-bg-hover(换肤后可能和背景同色, 轨道直接消失)。
|
|
139
|
+
const TRACK_STYLE = "color-mix(in srgb, var(--dsw-alias-label-primary, #f9fafb) 10%, transparent)";
|
|
140
|
+
|
|
141
|
+
// 原生 select 的下拉箭头替身(SVG data URI), 盖掉浏览器默认控件外观
|
|
142
|
+
const SELECT_CHEVRON = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23adb2b8' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\")";
|
|
126
143
|
|
|
127
144
|
// ---- 样式常量 -------------------------------------------------------------
|
|
128
145
|
|
|
@@ -131,16 +148,16 @@ window.__ModuleLoader__.load({
|
|
|
131
148
|
width: 352,
|
|
132
149
|
maxWidth: "calc(100vw - 40px)",
|
|
133
150
|
maxHeight: "calc(100vh - 120px)",
|
|
134
|
-
overflowY: "auto",
|
|
135
151
|
pointerEvents: "auto",
|
|
136
152
|
zIndex: 9999,
|
|
137
153
|
fontFamily: "inherit",
|
|
138
154
|
color: T.label,
|
|
139
|
-
borderRadius:
|
|
155
|
+
borderRadius: 14,
|
|
140
156
|
background: T.bg,
|
|
141
157
|
border: `1px solid ${T.border}`,
|
|
142
|
-
boxShadow: "0
|
|
143
|
-
|
|
158
|
+
boxShadow: "0 18px 48px rgba(0,0,0,.5), 0 0 0 1px rgba(0,0,0,.25)",
|
|
159
|
+
overflowY: "auto",
|
|
160
|
+
overflowX: "hidden",
|
|
144
161
|
animation: "ccUsagePop .22s cubic-bezier(.2,.9,.3,1.2)",
|
|
145
162
|
};
|
|
146
163
|
|
|
@@ -214,6 +231,11 @@ window.__ModuleLoader__.load({
|
|
|
214
231
|
React.createElement("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
|
|
215
232
|
React.createElement("line", { x1: "6", y1: "6", x2: "18", y2: "18" }));
|
|
216
233
|
|
|
234
|
+
// 齿轮: 凭据设置入口(默认收起, 不占主面板)
|
|
235
|
+
const IconGear = () => React.createElement("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
236
|
+
React.createElement("circle", { cx: 12, cy: 12, r: 3 }),
|
|
237
|
+
React.createElement("path", { d: "M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.12.38.4.7.78.86H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.55 1z" }));
|
|
238
|
+
|
|
217
239
|
const IconClock = ({ color }) => React.createElement("svg", { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", stroke: color, strokeWidth: 2, strokeLinecap: "round" },
|
|
218
240
|
React.createElement("circle", { cx: "12", cy: "12", r: "9" }),
|
|
219
241
|
React.createElement("polyline", { points: "12 7 12 12 15 14" }));
|
|
@@ -253,58 +275,159 @@ window.__ModuleLoader__.load({
|
|
|
253
275
|
sub ? React.createElement("div", { style: { fontSize: 11, color: T.label3, marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, sub) : null);
|
|
254
276
|
}
|
|
255
277
|
|
|
256
|
-
//
|
|
257
|
-
|
|
278
|
+
// 三档状态灯: 当前档亮起, 其余压暗。
|
|
279
|
+
// 数据来自官方字段 credits.belowThreshold / usage.windowLimits.*.exceeded;
|
|
280
|
+
// 这三个人话名字是给那两条数据起的, 不是官方术语 —— 悬停每个灯都有解释。
|
|
281
|
+
const STATUS_LEVELS = [
|
|
282
|
+
{ key: "ok", label: "正常", hint: "余额充足,三个额度窗口都没用满", color: T.success },
|
|
283
|
+
{ key: "low", label: "余额低", hint: "余额已低于你设定的告警阈值(threshold)", color: T.warn },
|
|
284
|
+
{ key: "out", label: "已用尽", hint: "有额度窗口已经用满,等它按 5 小时 / 每周 / 每月重置", color: T.error },
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
// 可选配色: 只改"低用量起点色"; 用量一高, 仍然统一走向暖色和红。
|
|
288
|
+
const PALETTES = [
|
|
289
|
+
{ key: "mist", label: "雾蓝", hex: "#6A9BCC" },
|
|
290
|
+
{ key: "indigo", label: "靛蓝", hex: "#5E6AD2" },
|
|
291
|
+
{ key: "steel", label: "灰蓝", hex: "#7C9CBF" },
|
|
292
|
+
{ key: "teal", label: "青绿", hex: "#2E9E8A" },
|
|
293
|
+
{ key: "lilac", label: "丁香", hex: "#8B7BB8" },
|
|
294
|
+
{ key: "clay", label: "陶土", hex: "#D97757" },
|
|
295
|
+
];
|
|
296
|
+
const PALETTE_STORAGE_KEY = "dsh-commandcode-quota:palette";
|
|
297
|
+
|
|
298
|
+
function hexToRgb(hex) {
|
|
299
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex));
|
|
300
|
+
if (!m) return [106, 155, 204];
|
|
301
|
+
const n = parseInt(m[1], 16);
|
|
302
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 用量 → 颜色。起点由所选配色决定; 55% 之前保持冷静, 之后升温, 100% 收在红。 */
|
|
306
|
+
function usageColor(pct, brandHex) {
|
|
307
|
+
const base = hexToRgb(brandHex);
|
|
308
|
+
const stops = [
|
|
309
|
+
[0, base],
|
|
310
|
+
[55, base],
|
|
311
|
+
[70, [217, 164, 65]],
|
|
312
|
+
[85, [217, 119, 87]],
|
|
313
|
+
[100, [201, 79, 79]],
|
|
314
|
+
];
|
|
315
|
+
const p = Math.max(0, Math.min(100, pct));
|
|
316
|
+
for (let i = 1; i < stops.length; i++) {
|
|
317
|
+
const [p1, c1] = stops[i];
|
|
318
|
+
if (p <= p1) {
|
|
319
|
+
const [p0, c0] = stops[i - 1];
|
|
320
|
+
const t = p1 === p0 ? 0 : (p - p0) / (p1 - p0);
|
|
321
|
+
const rgb = c0.map((v, k) => Math.round(v + (c1[k] - v) * t));
|
|
322
|
+
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return "rgb(201, 79, 79)";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 进度条: 颜色随用量走, 条形内部还有一段 40 个百分点的渐变 —
|
|
329
|
+
// 高用量时能直接看出"冷 → 暖"的推进。消耗 > 0 时至少露出 3px。
|
|
330
|
+
function Bar({ pct, height = 8, forceColor, brand }) {
|
|
258
331
|
const clamped = Math.max(0, Math.min(100, pct || 0));
|
|
259
|
-
const
|
|
332
|
+
const end = forceColor || usageColor(clamped, brand);
|
|
333
|
+
const start = forceColor || usageColor(Math.max(0, clamped - 40), brand);
|
|
260
334
|
return React.createElement("div", {
|
|
261
335
|
style: {
|
|
262
|
-
height
|
|
336
|
+
height,
|
|
263
337
|
borderRadius: 99,
|
|
264
338
|
background: TRACK_STYLE,
|
|
265
|
-
border: "1px solid rgba(255,255,255,.06)",
|
|
266
339
|
overflow: "hidden",
|
|
267
|
-
|
|
340
|
+
boxShadow: "inset 0 1px 2px rgba(0,0,0,.12)",
|
|
268
341
|
},
|
|
269
342
|
},
|
|
270
343
|
React.createElement("div", {
|
|
271
344
|
style: {
|
|
272
|
-
width: clamped
|
|
345
|
+
width: clamped > 0 ? `max(${clamped}%, 3px)` : "0%",
|
|
273
346
|
height: "100%",
|
|
274
347
|
borderRadius: 99,
|
|
275
|
-
background: `linear-gradient(90deg, ${
|
|
276
|
-
|
|
277
|
-
transition: "width .5s cubic-bezier(.3,.8,.3,1)",
|
|
348
|
+
background: `linear-gradient(90deg, ${start}, ${end})`,
|
|
349
|
+
transition: "width .45s cubic-bezier(.3,.8,.3,1)",
|
|
278
350
|
},
|
|
279
351
|
}));
|
|
280
352
|
}
|
|
281
353
|
|
|
282
|
-
//
|
|
283
|
-
|
|
354
|
+
// 状态徽章: 颜色由服务端告警位决定, 不再永远绿色 active。
|
|
355
|
+
const BADGE_TONES = { success: T.success, warn: T.warn, error: T.error };
|
|
356
|
+
function StatusBadge({ tone, label }) {
|
|
357
|
+
const color = BADGE_TONES[tone] ?? T.success;
|
|
358
|
+
return React.createElement("div", {
|
|
359
|
+
style: {
|
|
360
|
+
display: "flex",
|
|
361
|
+
alignItems: "center",
|
|
362
|
+
gap: 6,
|
|
363
|
+
background: `color-mix(in srgb, ${color} 15%, transparent)`,
|
|
364
|
+
border: `1px solid color-mix(in srgb, ${color} 40%, transparent)`,
|
|
365
|
+
color,
|
|
366
|
+
borderRadius: 99,
|
|
367
|
+
padding: "4px 11px",
|
|
368
|
+
fontSize: 11.5,
|
|
369
|
+
fontWeight: 600,
|
|
370
|
+
whiteSpace: "nowrap",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
React.createElement("span", { style: { width: 6, height: 6, borderRadius: "50%", background: color, display: "inline-block" } }),
|
|
374
|
+
label);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 统一错误卡片: 顶部错误态与「余额字段缺失」态共用,
|
|
378
|
+
// 后者以前直接 return null, 于是查询失败被渲染成一块空白面板。
|
|
379
|
+
function ErrorBox({ text }) {
|
|
380
|
+
return React.createElement("div", {
|
|
381
|
+
style: {
|
|
382
|
+
margin: "16px",
|
|
383
|
+
padding: "12px 14px",
|
|
384
|
+
whiteSpace: "pre-line",
|
|
385
|
+
background: "color-mix(in srgb, var(--dsw-alias-state-error-primary, #f25a5a) 14%, transparent)",
|
|
386
|
+
border: "1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #f25a5a) 38%, transparent)",
|
|
387
|
+
borderRadius: 12,
|
|
388
|
+
fontSize: 12.5,
|
|
389
|
+
color: T.error,
|
|
390
|
+
lineHeight: 1.6,
|
|
391
|
+
},
|
|
392
|
+
}, String(text));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 一个额度窗口 = 一张卡: 名称 + 百分比 / 进度条 / 一行明细。
|
|
396
|
+
// 明细压成一行(已用/上限 · 剩余 · 重置), 信息不丢但高度减半。
|
|
397
|
+
function QuotaRow({ title, used, cap, resetAt, exceeded, brand, note }) {
|
|
284
398
|
const capSafe = cap > 0 ? cap : (used > 0 ? used : 1);
|
|
285
399
|
const pct = capSafe > 0 ? Math.min(100, (used / capSafe) * 100) : 0;
|
|
286
|
-
const
|
|
400
|
+
const exhausted = exceeded === true;
|
|
401
|
+
// 颜色交给进度条表达(随用量连续变化), 数字保持主文字色, 只在用尽时转红 ——
|
|
402
|
+
// 浅色主题下黄/橙数字对比度不够, 读起来会糊。
|
|
403
|
+
const pctColor = exhausted ? T.error : T.label;
|
|
287
404
|
const remaining = Math.max(0, capSafe - used);
|
|
288
405
|
return React.createElement("div", {
|
|
289
406
|
style: {
|
|
290
407
|
background: T.bg2,
|
|
291
|
-
border: `1px solid ${T.borderL}`,
|
|
292
408
|
borderRadius: 12,
|
|
293
|
-
padding: "
|
|
409
|
+
padding: "10px 13px 11px",
|
|
410
|
+
// 几乎同色的底 + 一条极细边框: 靠线和间距分组, 而不是靠一块亮灰
|
|
411
|
+
border: `1px solid ${T.hairline}`,
|
|
294
412
|
},
|
|
295
413
|
},
|
|
296
|
-
React.createElement("div", { style: { display: "flex", justifyContent: "space-between",
|
|
297
|
-
React.createElement("
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
React.createElement("span", { style: { fontSize:
|
|
414
|
+
React.createElement("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8, fontSize: 12, color: T.label2 } },
|
|
415
|
+
React.createElement("span", { style: { fontWeight: 600 } },
|
|
416
|
+
title,
|
|
417
|
+
exhausted ? React.createElement("span", { style: { marginLeft: 6, fontSize: 10.5, fontWeight: 700, color: T.error } }, "已用尽") : null),
|
|
418
|
+
React.createElement("span", { style: { fontSize: 15, fontWeight: 700, fontVariantNumeric: "tabular-nums", color: pctColor, letterSpacing: "-.3px" } },
|
|
301
419
|
(Math.round(pct * 10) / 10) + "%")),
|
|
302
|
-
React.createElement(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
React.createElement("div", { style: {
|
|
307
|
-
|
|
420
|
+
React.createElement("div", { style: { margin: "8px 0 7px" } },
|
|
421
|
+
React.createElement(Bar, { pct, brand, forceColor: exhausted ? T.error : null })),
|
|
422
|
+
// 固定两行: 一行放"已用/上限", 一行放"剩余/重置"。
|
|
423
|
+
// 挤成一行时日期会被断到下一行, 比多一行更难看。
|
|
424
|
+
React.createElement("div", { style: { fontSize: 11, color: T.label3, lineHeight: 1.55, fontVariantNumeric: "tabular-nums" } },
|
|
425
|
+
`已用 ${fmtCurrency(used)} / 上限 ${fmtCurrency(capSafe)}`),
|
|
426
|
+
React.createElement("div", { style: { fontSize: 11, color: T.label3, lineHeight: 1.55, fontVariantNumeric: "tabular-nums" } },
|
|
427
|
+
`剩余 ${fmtCurrency(remaining)} · `
|
|
428
|
+
+ (exhausted ? `${fmtDuration(resetAt)}后重置` : `重置 ${fmtDuration(resetAt)}${fmtResetDay(resetAt)}`)),
|
|
429
|
+
// 超额预警: 只有"按当前日均撑不到重置"时才多出这一行
|
|
430
|
+
note ? React.createElement("div", { style: { marginTop: 4, fontSize: 11, color: T.warn, lineHeight: 1.5 } }, `⚠ ${note}`) : null);
|
|
308
431
|
}
|
|
309
432
|
|
|
310
433
|
// ---- 凭据条目选择 ------------------------------------------------------------
|
|
@@ -333,7 +456,7 @@ window.__ModuleLoader__.load({
|
|
|
333
456
|
display: "flex",
|
|
334
457
|
alignItems: "center",
|
|
335
458
|
gap: 8,
|
|
336
|
-
padding: "
|
|
459
|
+
padding: "8px 16px 9px",
|
|
337
460
|
borderBottom: `1px solid ${T.borderL}`,
|
|
338
461
|
fontSize: 11.5,
|
|
339
462
|
color: T.label3,
|
|
@@ -348,13 +471,18 @@ window.__ModuleLoader__.load({
|
|
|
348
471
|
style: {
|
|
349
472
|
flex: 1,
|
|
350
473
|
minWidth: 0,
|
|
351
|
-
padding: "4px
|
|
474
|
+
padding: "4px 24px 4px 8px",
|
|
352
475
|
fontSize: 11.5,
|
|
353
476
|
fontFamily: "inherit",
|
|
354
477
|
color: T.label2,
|
|
355
478
|
background: T.bg2,
|
|
356
|
-
|
|
357
|
-
|
|
479
|
+
backgroundImage: SELECT_CHEVRON,
|
|
480
|
+
backgroundRepeat: "no-repeat",
|
|
481
|
+
backgroundPosition: "right 7px center",
|
|
482
|
+
appearance: "none",
|
|
483
|
+
WebkitAppearance: "none",
|
|
484
|
+
border: `1px solid ${T.borderL}`,
|
|
485
|
+
borderRadius: 7,
|
|
358
486
|
cursor: safe.saving ? "default" : "pointer",
|
|
359
487
|
},
|
|
360
488
|
},
|
|
@@ -366,33 +494,70 @@ window.__ModuleLoader__.load({
|
|
|
366
494
|
|
|
367
495
|
// ---- 面板内容 ----------------------------------------------------------------
|
|
368
496
|
|
|
369
|
-
function DashboardBody({ data, loading, error }) {
|
|
370
|
-
if (error) {
|
|
371
|
-
|
|
372
|
-
|
|
497
|
+
function DashboardBody({ data, loading, error, palette }) {
|
|
498
|
+
if (error) return React.createElement(ErrorBox, { text: error });
|
|
499
|
+
if (loading && !data) {
|
|
500
|
+
// 骨架屏: 按真实布局占位(余额块 + 三张卡), 数据回来时不会整块"跳一下"
|
|
501
|
+
const bone = (key, w, h, mb) => React.createElement("div", {
|
|
502
|
+
key,
|
|
503
|
+
// 占位条要比卡片底色更实一点, 否则和卡片同色会"消失"
|
|
504
|
+
style: { width: w, height: h, borderRadius: 6, background: T.hairline, marginBottom: mb },
|
|
505
|
+
});
|
|
506
|
+
return React.createElement("div", {
|
|
507
|
+
key: "skeleton",
|
|
508
|
+
style: { padding: "14px 16px 12px" },
|
|
509
|
+
"aria-busy": true,
|
|
510
|
+
"aria-label": "正在获取额度",
|
|
511
|
+
},
|
|
512
|
+
bone("b1", "34%", 9, 10),
|
|
513
|
+
bone("b2", "48%", 26, 12),
|
|
514
|
+
bone("b3", "72%", 9, 6),
|
|
515
|
+
bone("b4", "58%", 9, 6),
|
|
516
|
+
bone("b5", "64%", 9, 14),
|
|
517
|
+
[0, 1, 2].map((i) => React.createElement("div", {
|
|
518
|
+
key: "sk" + i,
|
|
373
519
|
style: {
|
|
374
|
-
background:
|
|
375
|
-
border: "1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #f25a5a) 38%, transparent)",
|
|
520
|
+
background: T.bg2,
|
|
376
521
|
borderRadius: 12,
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
lineHeight: 1.5,
|
|
522
|
+
border: `1px solid ${T.hairline}`,
|
|
523
|
+
padding: "12px 13px",
|
|
524
|
+
marginBottom: i === 2 ? 0 : 9,
|
|
381
525
|
},
|
|
382
|
-
},
|
|
526
|
+
},
|
|
527
|
+
bone("s" + i + "a", "40%", 9, 10),
|
|
528
|
+
bone("s" + i + "b", "100%", 8, 9),
|
|
529
|
+
bone("s" + i + "c", "76%", 9, 5),
|
|
530
|
+
bone("s" + i + "d", "52%", 9, 0))),
|
|
531
|
+
React.createElement("div", { style: { marginTop: 12, textAlign: "center", fontSize: 11, color: T.label3 } }, "正在获取额度…"));
|
|
383
532
|
}
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
533
|
+
if (!data) return null;
|
|
534
|
+
// 余额字段缺失(最常见的是 credits 单接口 401/500)时必须说清原因,
|
|
535
|
+
// 不能再 return null —— 那会把「查询失败」伪装成一块空白面板。
|
|
536
|
+
if (!data.balance) {
|
|
537
|
+
const failed = Array.isArray(data.errors) ? data.errors : [];
|
|
538
|
+
const detail = failed.length > 0
|
|
539
|
+
? failed.map((e) => `${ENDPOINT_NAMES[e.endpoint] ?? e.endpoint}: ${e.message}`).join("\n")
|
|
540
|
+
: "服务端没有返回余额数据(点右上角 ⟳ 重试)";
|
|
541
|
+
return React.createElement(ErrorBox, { text: `额度查询失败\n${detail}` });
|
|
388
542
|
}
|
|
389
|
-
if (!data || !data.balance) return null;
|
|
390
543
|
|
|
391
544
|
const { balance, windowLimits, usage, subscription, user } = data;
|
|
392
545
|
|
|
393
546
|
// 订阅开通时间: 优先 createdAt, 回退到当前周期开始
|
|
394
547
|
const subSince = subscription && (subscription.createdAt || subscription.currentPeriodStart) || null;
|
|
395
548
|
|
|
549
|
+
// ---- 服务端告警位 --------------------------------------------------------
|
|
550
|
+
// API 已经把「余额低于阈值 / 窗口已用尽 / 订阅已取消续费」判好了, 之前一个都没用,
|
|
551
|
+
// 于是额度真的用尽时面板还是那个绿色 active 徽章 —— 在最该报警的时刻显得一切正常。
|
|
552
|
+
const belowThreshold = balance.belowThreshold === true;
|
|
553
|
+
const exhaustedNames = [];
|
|
554
|
+
if (windowLimits && windowLimits.fiveHour && windowLimits.fiveHour.exceeded === true) exhaustedNames.push("5 小时");
|
|
555
|
+
if (windowLimits && windowLimits.weekly && windowLimits.weekly.exceeded === true) exhaustedNames.push("每周");
|
|
556
|
+
if (windowLimits && windowLimits.exceeded === true && exhaustedNames.length === 0) exhaustedNames.push("额度");
|
|
557
|
+
// 状态档位: 任一窗口用尽 > 余额低于阈值 > 正常
|
|
558
|
+
const statusLevel = exhaustedNames.length > 0 ? "out" : belowThreshold ? "low" : "ok";
|
|
559
|
+
const brandHex = (palette && palette.hex) || PALETTES[0].hex;
|
|
560
|
+
|
|
396
561
|
// 本次请求里订阅接口是否失败(接口本身慢/超时) —— 用于区分"没这条数据"和"没取到"
|
|
397
562
|
const subFailed = Array.isArray(data.errors)
|
|
398
563
|
&& data.errors.some((e) => e && e.endpoint === "subscriptions");
|
|
@@ -406,31 +571,28 @@ window.__ModuleLoader__.load({
|
|
|
406
571
|
: null;
|
|
407
572
|
const monthReset = subscription && subscription.currentPeriodEnd ? subscription.currentPeriodEnd : null;
|
|
408
573
|
|
|
409
|
-
|
|
574
|
+
// 日均消耗: 本周期已用 ÷ 本周期已过天数(periodStart 由 usage 接口给出)。
|
|
575
|
+
// 取代原来的「成功率」格子 —— 那是整期累计的运维指标, 对「今天还该不该放开用」没有决策价值。
|
|
576
|
+
const periodStart = usage && usage.periodStart ? toMs(usage.periodStart) : null;
|
|
577
|
+
const periodDays = periodStart !== null ? Math.max(0, (Date.now() - periodStart) / 86400000) : null;
|
|
578
|
+
const dailyAvg = monthUsed !== null && periodDays !== null && periodDays > 0 ? monthUsed / periodDays : null;
|
|
410
579
|
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
border: "1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 45%, transparent)",
|
|
422
|
-
color: T.warn,
|
|
423
|
-
fontSize: 11.5,
|
|
424
|
-
lineHeight: 1.55,
|
|
425
|
-
},
|
|
426
|
-
},
|
|
427
|
-
React.createElement("div", { style: { fontWeight: 700, marginBottom: 4 } }, "⚠ 凭据告警"),
|
|
428
|
-
warnings.map((w, i) => React.createElement("div", { key: i, style: { marginTop: i > 0 ? 4 : 0 } }, w))));
|
|
580
|
+
// 超额预警: 用本周期日均推算"剩余额度够不够撑到重置"。
|
|
581
|
+
// 进度条只说现在用了多少; 这条只在"按这个速度会在重置前用光"时出现。
|
|
582
|
+
let etaNote = null;
|
|
583
|
+
if (monthCap !== null && monthUsed !== null && dailyAvg !== null && dailyAvg > 0 && monthReset) {
|
|
584
|
+
const resetMs = toMs(monthReset);
|
|
585
|
+
const daysLeft = resetMs === null ? null : (resetMs - Date.now()) / 86400000;
|
|
586
|
+
const daysToEmpty = (monthCap - monthUsed) / dailyAvg;
|
|
587
|
+
if (daysLeft !== null && daysLeft > 0.5 && daysToEmpty > 0 && daysToEmpty < daysLeft) {
|
|
588
|
+
etaNote = `按当前日均,约 ${Math.max(1, Math.round(daysToEmpty))} 天后用完`;
|
|
589
|
+
}
|
|
429
590
|
}
|
|
430
591
|
|
|
592
|
+
const rows = [];
|
|
593
|
+
|
|
431
594
|
// 缓存兜底提示: 接口超时, 当前显示的是上次成功获取的数据
|
|
432
595
|
if (staleList.length > 0) {
|
|
433
|
-
const FIELD_NAMES = { credits: "余额", usage: "用量", subscriptions: "订阅", whoami: "账户" };
|
|
434
596
|
rows.push(React.createElement("div", {
|
|
435
597
|
key: "stale",
|
|
436
598
|
style: {
|
|
@@ -439,91 +601,115 @@ window.__ModuleLoader__.load({
|
|
|
439
601
|
color: T.label3,
|
|
440
602
|
lineHeight: 1.55,
|
|
441
603
|
},
|
|
442
|
-
}, `⏱ ${staleList.map((s) =>
|
|
604
|
+
}, `⏱ ${staleList.map((s) => ENDPOINT_NAMES[s.endpoint] || s.endpoint).join("、")} 本次请求超时,显示的是上次成功获取的数据(点右上角 ⟳ 重试)`));
|
|
443
605
|
}
|
|
444
606
|
|
|
445
|
-
//
|
|
607
|
+
// 余额 + 账户信息: 状态灯与「本月剩余」同排, 其余信息独占整行 ——
|
|
608
|
+
// 灯放右边单独占一行会把"订阅开通"挤到换行。
|
|
446
609
|
rows.push(React.createElement("div", {
|
|
447
|
-
key: "
|
|
448
|
-
style: {
|
|
449
|
-
padding: "18px 16px 16px",
|
|
450
|
-
background: `linear-gradient(150deg, ${T.bg3}, ${T.bg2})`,
|
|
451
|
-
borderBottom: `1px solid ${T.borderL}`,
|
|
452
|
-
},
|
|
610
|
+
key: "balance",
|
|
611
|
+
style: { padding: "14px 16px 13px" },
|
|
453
612
|
},
|
|
454
|
-
React.createElement("div", { style: { display: "flex", justifyContent: "space-between",
|
|
455
|
-
React.createElement("
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
613
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: 3 } },
|
|
614
|
+
React.createElement("span", { style: { fontSize: 11, color: T.label3 } }, "本月剩余"),
|
|
615
|
+
// 三档状态灯: 当前档亮起, 其余压暗 —— 比单独一个 "active" 更能说明处在哪一档。
|
|
616
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4 } },
|
|
617
|
+
STATUS_LEVELS.map((level) => {
|
|
618
|
+
const active = level.key === statusLevel;
|
|
619
|
+
return React.createElement("span", {
|
|
620
|
+
key: level.key,
|
|
621
|
+
title: level.hint,
|
|
622
|
+
style: {
|
|
623
|
+
display: "inline-flex",
|
|
624
|
+
alignItems: "center",
|
|
625
|
+
gap: 4,
|
|
626
|
+
fontSize: 10.5,
|
|
627
|
+
fontWeight: active ? 700 : 500,
|
|
628
|
+
color: active ? level.color : T.label3,
|
|
629
|
+
background: active ? `color-mix(in srgb, ${level.color} 14%, transparent)` : "transparent",
|
|
630
|
+
border: `1px solid ${active ? `color-mix(in srgb, ${level.color} 38%, transparent)` : "transparent"}`,
|
|
631
|
+
borderRadius: 99,
|
|
632
|
+
padding: "2px 6px",
|
|
633
|
+
opacity: active ? 1 : 0.5,
|
|
634
|
+
whiteSpace: "nowrap",
|
|
635
|
+
},
|
|
636
|
+
}, React.createElement("span", {
|
|
637
|
+
style: {
|
|
638
|
+
width: 5,
|
|
639
|
+
height: 5,
|
|
640
|
+
borderRadius: "50%",
|
|
641
|
+
background: active ? level.color : T.label3,
|
|
642
|
+
display: "inline-block",
|
|
643
|
+
},
|
|
644
|
+
}), level.label);
|
|
645
|
+
}))),
|
|
646
|
+
React.createElement("div", {
|
|
647
|
+
style: {
|
|
648
|
+
fontSize: 28,
|
|
649
|
+
fontWeight: 700,
|
|
650
|
+
lineHeight: 1.1,
|
|
651
|
+
letterSpacing: "-.8px",
|
|
652
|
+
fontVariantNumeric: "tabular-nums",
|
|
653
|
+
color: T.label,
|
|
480
654
|
},
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
655
|
+
}, fmtCurrency(balance.monthlyCredits)),
|
|
656
|
+
React.createElement("div", { style: { marginTop: 6, fontSize: 11, color: T.label3, lineHeight: 1.6, fontVariantNumeric: "tabular-nums" } },
|
|
657
|
+
`总消耗 ${fmtCurrency(usage && usage.totalCost)} Tokens ${fmtNumber(usage && usage.totalTokens)}`),
|
|
658
|
+
React.createElement("div", { style: { fontSize: 11, color: T.label3, lineHeight: 1.6, fontVariantNumeric: "tabular-nums" } },
|
|
659
|
+
`日均 ${fmtCurrency(dailyAvg)}` + (periodDays !== null && periodDays >= 1 ? ` 本周期 ${Math.floor(periodDays)} 天` : "")),
|
|
660
|
+
React.createElement("div", { style: { marginTop: 4, fontSize: 11, color: T.label3, lineHeight: 1.6 } },
|
|
661
|
+
subSince
|
|
662
|
+
? `订阅开通 ${fmtDay(subSince)},已 ${fmtElapsed(subSince)}`
|
|
663
|
+
: (subFailed ? "订阅接口本次未响应(点右上角 ⟳ 重试)" : "订阅信息暂不可用"))));
|
|
664
|
+
|
|
665
|
+
// 订阅取消续费提示
|
|
666
|
+
if (subscription && subscription.cancelAtPeriodEnd === true) {
|
|
667
|
+
rows.push(React.createElement("div", {
|
|
668
|
+
key: "cancel",
|
|
669
|
+
style: { padding: "0 16px 10px", fontSize: 11, color: T.warn, lineHeight: 1.55 },
|
|
670
|
+
}, `⚠ 订阅将于 ${fmtDay(subscription.currentPeriodEnd)} 到期,不再续费`));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 三个窗口: 三张卡, 统一放在一个网格容器里
|
|
674
|
+
const windowCards = [];
|
|
495
675
|
if (windowLimits && windowLimits.fiveHour) {
|
|
496
|
-
|
|
676
|
+
windowCards.push(React.createElement(QuotaRow, {
|
|
497
677
|
key: "5h",
|
|
498
678
|
title: "5 小时窗口",
|
|
499
679
|
used: windowLimits.fiveHour.used,
|
|
500
680
|
cap: windowLimits.fiveHour.cap,
|
|
501
681
|
resetAt: windowLimits.fiveHour.resetAt,
|
|
682
|
+
exceeded: windowLimits.fiveHour.exceeded,
|
|
683
|
+
brand: brandHex,
|
|
502
684
|
}));
|
|
503
685
|
}
|
|
504
686
|
if (windowLimits && windowLimits.weekly) {
|
|
505
|
-
|
|
687
|
+
windowCards.push(React.createElement(QuotaRow, {
|
|
506
688
|
key: "week",
|
|
507
689
|
title: "每周窗口",
|
|
508
690
|
used: windowLimits.weekly.used,
|
|
509
691
|
cap: windowLimits.weekly.cap,
|
|
510
692
|
resetAt: windowLimits.weekly.resetAt,
|
|
693
|
+
exceeded: windowLimits.weekly.exceeded,
|
|
694
|
+
brand: brandHex,
|
|
511
695
|
}));
|
|
512
696
|
}
|
|
513
697
|
if (monthCap !== null) {
|
|
514
|
-
|
|
698
|
+
windowCards.push(React.createElement(QuotaRow, {
|
|
515
699
|
key: "month",
|
|
516
700
|
title: "每月窗口",
|
|
517
701
|
used: monthUsed,
|
|
518
702
|
cap: monthCap,
|
|
519
703
|
resetAt: monthReset,
|
|
704
|
+
brand: brandHex,
|
|
705
|
+
note: etaNote,
|
|
520
706
|
}));
|
|
521
707
|
}
|
|
522
|
-
if (
|
|
523
|
-
rows.push(React.createElement("div", {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
708
|
+
if (windowCards.length > 0) {
|
|
709
|
+
rows.push(React.createElement("div", {
|
|
710
|
+
key: "windows",
|
|
711
|
+
style: { padding: "0 12px 12px", display: "grid", gap: 9 },
|
|
712
|
+
}, windowCards));
|
|
527
713
|
}
|
|
528
714
|
|
|
529
715
|
// 底部: 账户 + 更新时间
|
|
@@ -546,6 +732,51 @@ window.__ModuleLoader__.load({
|
|
|
546
732
|
return React.createElement("div", null, rows);
|
|
547
733
|
}
|
|
548
734
|
|
|
735
|
+
// 配色选择: 六个颜色分两行排列, 只影响低用量起点色, 高用量仍统一走向暖色。
|
|
736
|
+
function PalettePicker({ value, onChange }) {
|
|
737
|
+
return React.createElement("div", {
|
|
738
|
+
style: {
|
|
739
|
+
display: "flex",
|
|
740
|
+
alignItems: "center",
|
|
741
|
+
gap: 8,
|
|
742
|
+
padding: "8px 16px 9px",
|
|
743
|
+
borderBottom: `1px solid ${T.borderL}`,
|
|
744
|
+
fontSize: 11.5,
|
|
745
|
+
color: T.label3,
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
React.createElement("span", { style: { whiteSpace: "nowrap" } }, "进度色"),
|
|
749
|
+
React.createElement("div", { style: { display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 6, flex: 1 } },
|
|
750
|
+
PALETTES.map((p) => {
|
|
751
|
+
const on = Boolean(value) && value.key === p.key;
|
|
752
|
+
return React.createElement("button", {
|
|
753
|
+
key: p.key,
|
|
754
|
+
type: "button",
|
|
755
|
+
title: p.label,
|
|
756
|
+
"aria-pressed": on,
|
|
757
|
+
onClick: () => onChange(p),
|
|
758
|
+
style: {
|
|
759
|
+
display: "inline-flex",
|
|
760
|
+
alignItems: "center",
|
|
761
|
+
justifyContent: "center",
|
|
762
|
+
gap: 5,
|
|
763
|
+
padding: "3px 6px",
|
|
764
|
+
borderRadius: 8,
|
|
765
|
+
cursor: "pointer",
|
|
766
|
+
fontSize: 11,
|
|
767
|
+
fontFamily: "inherit",
|
|
768
|
+
color: on ? T.label : T.label3,
|
|
769
|
+
background: on ? T.bg2 : "transparent",
|
|
770
|
+
border: `1px solid ${on ? T.hairline : "transparent"}`,
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
React.createElement("span", {
|
|
774
|
+
style: { width: 8, height: 8, borderRadius: "50%", background: p.hex, display: "inline-block", flexShrink: 0 },
|
|
775
|
+
}),
|
|
776
|
+
p.label);
|
|
777
|
+
})));
|
|
778
|
+
}
|
|
779
|
+
|
|
549
780
|
// ---- 主组件 -------------------------------------------------------------------
|
|
550
781
|
|
|
551
782
|
function UsageBadge() {
|
|
@@ -556,33 +787,53 @@ window.__ModuleLoader__.load({
|
|
|
556
787
|
const timerRef = useRef(null);
|
|
557
788
|
// 凭据条目:与 data / error 完全解耦 —— 这样面板报「没配 key」时依然能切换
|
|
558
789
|
const [creds, setCreds] = useState({ entries: [], apiKeyEnv: null, saving: false, error: null });
|
|
790
|
+
// 凭据设置默认收起(见头部齿轮按钮)
|
|
791
|
+
const [gearOpen, setGearOpen] = useState(false);
|
|
792
|
+
// 进度色: 默认 Claude 蓝, 记住上次选择
|
|
793
|
+
const [palette, setPalette] = useState(() => {
|
|
794
|
+
try {
|
|
795
|
+
if (typeof localStorage === "undefined") return PALETTES[0];
|
|
796
|
+
const saved = localStorage.getItem(PALETTE_STORAGE_KEY);
|
|
797
|
+
return PALETTES.find((p) => p.key === saved) || PALETTES[0];
|
|
798
|
+
} catch {
|
|
799
|
+
return PALETTES[0];
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
// 外层容器引用:用来判断点击是否落在「状态栏 + 面板」之外
|
|
803
|
+
const rootRef = useRef(null);
|
|
559
804
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
805
|
+
// options.full=false → 只刷余额(轻量); options.silent=true → 后台轮询, 不动 loading 态
|
|
806
|
+
const refresh = useCallback(async (options = {}) => {
|
|
807
|
+
const wantFull = options.full !== false;
|
|
808
|
+
const silent = options.silent === true;
|
|
809
|
+
// Command Code 的用量/订阅接口比余额接口慢得多, 所以两段式:
|
|
810
|
+
// 先只取余额让面板立刻出内容, 再拉完整数据覆盖。
|
|
811
|
+
const quickUrl = FETCH_URL + "?scope=quick";
|
|
565
812
|
|
|
566
813
|
const pull = (url) => fetch(url, { cache: "no-store" }).then((res) => {
|
|
567
814
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
568
815
|
return res.json();
|
|
569
816
|
});
|
|
570
817
|
|
|
571
|
-
setLoading(true);
|
|
818
|
+
if (!silent) setLoading(true);
|
|
572
819
|
try {
|
|
573
820
|
const quick = await pull(quickUrl);
|
|
574
821
|
if (quick) {
|
|
575
822
|
setData(quick);
|
|
823
|
+
// 余额拿到了就清掉上次的陈旧错误; 拿不到则让面板显示具体失败原因
|
|
824
|
+
if (quick.balance) setError(null);
|
|
576
825
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
826
|
+
if (wantFull) {
|
|
827
|
+
const full = await pull(FETCH_URL);
|
|
828
|
+
if (full) {
|
|
829
|
+
setData(full);
|
|
830
|
+
setError(full.ok === false && full.error ? full.error : null);
|
|
831
|
+
}
|
|
581
832
|
}
|
|
582
833
|
} catch (reason) {
|
|
583
834
|
setError(String(reason));
|
|
584
835
|
} finally {
|
|
585
|
-
setLoading(false);
|
|
836
|
+
if (!silent) setLoading(false);
|
|
586
837
|
}
|
|
587
838
|
}, []);
|
|
588
839
|
|
|
@@ -602,6 +853,15 @@ window.__ModuleLoader__.load({
|
|
|
602
853
|
}
|
|
603
854
|
}, []);
|
|
604
855
|
|
|
856
|
+
const choosePalette = useCallback((next) => {
|
|
857
|
+
setPalette(next);
|
|
858
|
+
try {
|
|
859
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(PALETTE_STORAGE_KEY, next.key);
|
|
860
|
+
} catch {
|
|
861
|
+
/* 隐私模式 / 存储不可用, 忽略 */
|
|
862
|
+
}
|
|
863
|
+
}, []);
|
|
864
|
+
|
|
605
865
|
const pickCredential = useCallback(async (name) => {
|
|
606
866
|
setCreds((prev) => ({ ...prev, saving: true, error: null }));
|
|
607
867
|
try {
|
|
@@ -622,11 +882,47 @@ window.__ModuleLoader__.load({
|
|
|
622
882
|
useEffect(() => {
|
|
623
883
|
loadCredentials();
|
|
624
884
|
refresh();
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
885
|
+
// 分层轮询: 余额 3 分钟 / 用量+订阅 30 分钟。页面不可见时整轮跳过,
|
|
886
|
+
// 切回前台立刻补一次完整查询 —— 后台不白打上游接口。
|
|
887
|
+
const doc = typeof document !== "undefined" ? document : null;
|
|
888
|
+
const visible = () => !doc || doc.hidden !== true;
|
|
889
|
+
const quickTimer = setInterval(() => { if (visible()) refresh({ full: false, silent: true }); }, QUICK_REFRESH_MS);
|
|
890
|
+
const fullTimer = setInterval(() => { if (visible()) refresh({ silent: true }); }, FULL_REFRESH_MS);
|
|
891
|
+
const onVisibility = () => { if (visible()) refresh({ silent: true }); };
|
|
892
|
+
if (doc && typeof doc.addEventListener === "function") doc.addEventListener("visibilitychange", onVisibility);
|
|
893
|
+
timerRef.current = { quickTimer, fullTimer };
|
|
894
|
+
return () => {
|
|
895
|
+
clearInterval(quickTimer);
|
|
896
|
+
clearInterval(fullTimer);
|
|
897
|
+
if (doc && typeof doc.removeEventListener === "function") doc.removeEventListener("visibilitychange", onVisibility);
|
|
898
|
+
};
|
|
628
899
|
}, [loadCredentials, refresh]);
|
|
629
900
|
|
|
901
|
+
// 点击面板外部或按 Esc 关闭 —— 与 DSH 其它浮层的交互保持一致。
|
|
902
|
+
// 之前只有再点一次徽章才关得掉, 点别处没反应。
|
|
903
|
+
useEffect(() => {
|
|
904
|
+
if (!readoutOpen) return undefined;
|
|
905
|
+
const doc = typeof document !== "undefined" ? document : null;
|
|
906
|
+
if (!doc) return undefined;
|
|
907
|
+
const onPointerDown = (event) => {
|
|
908
|
+
const root = rootRef.current;
|
|
909
|
+
if (!root) return;
|
|
910
|
+
const target = event.target;
|
|
911
|
+
const inside = typeof Node !== "undefined" && target instanceof Node && root.contains(target);
|
|
912
|
+
if (!inside) setReadoutOpen(false);
|
|
913
|
+
};
|
|
914
|
+
const onKeyDown = (event) => {
|
|
915
|
+
if (event.key === "Escape") setReadoutOpen(false);
|
|
916
|
+
};
|
|
917
|
+
// capture 阶段监听: 面板内元素即使 stopPropagation 也能正确判定
|
|
918
|
+
doc.addEventListener("pointerdown", onPointerDown, true);
|
|
919
|
+
doc.addEventListener("keydown", onKeyDown);
|
|
920
|
+
return () => {
|
|
921
|
+
doc.removeEventListener("pointerdown", onPointerDown, true);
|
|
922
|
+
doc.removeEventListener("keydown", onKeyDown);
|
|
923
|
+
};
|
|
924
|
+
}, [readoutOpen]);
|
|
925
|
+
|
|
630
926
|
function pctOf(win) {
|
|
631
927
|
if (!win || typeof win.used !== "number" || typeof win.cap !== "number" || win.cap <= 0) return null;
|
|
632
928
|
return Math.round((win.used / win.cap) * 100);
|
|
@@ -654,9 +950,14 @@ window.__ModuleLoader__.load({
|
|
|
654
950
|
if (monthTotal !== null && monthTotal > 0 && monthUsed !== null) {
|
|
655
951
|
parts.push({ key: "mo", label: "月 " + Math.round((monthUsed / monthTotal) * 100) + "%" });
|
|
656
952
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
953
|
+
// 状态栏也要能说人话: 查询失败时别再挂着「额度查询中…」骗人。
|
|
954
|
+
const readout = !data
|
|
955
|
+
? [{ key: "idle", label: "Command Code" }]
|
|
956
|
+
: parts.length > 0
|
|
957
|
+
? parts
|
|
958
|
+
: (Array.isArray(data.errors) && data.errors.length > 0)
|
|
959
|
+
? [{ key: "err", label: "额度查询失败" }]
|
|
960
|
+
: [{ key: "wait", label: "额度查询中…" }];
|
|
660
961
|
|
|
661
962
|
// 状态栏本体: 空心图标 + 三个占比读数。不加粗, 颜色沿用官方三级文字色。
|
|
662
963
|
const badgeButton = React.createElement("button", {
|
|
@@ -699,7 +1000,7 @@ window.__ModuleLoader__.load({
|
|
|
699
1000
|
color: T.label,
|
|
700
1001
|
};
|
|
701
1002
|
|
|
702
|
-
return React.createElement("div", { style: statusBarStyle },
|
|
1003
|
+
return React.createElement("div", { style: statusBarStyle, ref: rootRef },
|
|
703
1004
|
badgeButton,
|
|
704
1005
|
readoutOpen ? React.createElement("div", { style: { ...shellStyle, ...panelPosStyle } },
|
|
705
1006
|
React.createElement("style", null, [
|
|
@@ -711,22 +1012,33 @@ window.__ModuleLoader__.load({
|
|
|
711
1012
|
display: "flex",
|
|
712
1013
|
justifyContent: "space-between",
|
|
713
1014
|
alignItems: "center",
|
|
714
|
-
padding: "
|
|
1015
|
+
padding: "9px 10px 9px 16px",
|
|
715
1016
|
borderBottom: `1px solid ${T.borderL}`,
|
|
716
1017
|
},
|
|
717
1018
|
},
|
|
718
1019
|
React.createElement("span", { style: { fontSize: 13, fontWeight: 700, display: "flex", alignItems: "center", gap: 8 } },
|
|
719
|
-
|
|
1020
|
+
// 品牌点: 去掉发光效果(原来像"正在录音"), 改成 6px 方块
|
|
1021
|
+
React.createElement("span", { style: { width: 6, height: 6, borderRadius: 2, background: T.brand, display: "inline-block" } }),
|
|
720
1022
|
"Command Code"),
|
|
721
|
-
React.createElement("div", { style: { display: "flex", gap:
|
|
1023
|
+
React.createElement("div", { style: { display: "flex", gap: 1 } },
|
|
722
1024
|
React.createElement("button", {
|
|
723
|
-
style: iconBtn,
|
|
724
|
-
title: "
|
|
1025
|
+
style: gearOpen ? { ...iconBtn, ...iconBtnHover } : iconBtn,
|
|
1026
|
+
title: gearOpen ? "收起凭据设置" : "凭据设置",
|
|
1027
|
+
"aria-label": "凭据设置",
|
|
1028
|
+
"aria-expanded": gearOpen,
|
|
1029
|
+
onClick: () => setGearOpen(!gearOpen),
|
|
1030
|
+
onMouseEnter: (e) => { if (!gearOpen) Object.assign(e.currentTarget.style, iconBtnHover); },
|
|
1031
|
+
onMouseLeave: (e) => { if (!gearOpen) { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; } },
|
|
1032
|
+
}, React.createElement(IconGear)),
|
|
1033
|
+
React.createElement("button", {
|
|
1034
|
+
style: loading ? { ...iconBtn, opacity: 0.45, cursor: "default" } : iconBtn,
|
|
1035
|
+
title: loading ? "刷新中…" : "刷新",
|
|
725
1036
|
"aria-label": "刷新",
|
|
1037
|
+
"aria-busy": loading,
|
|
726
1038
|
disabled: loading,
|
|
727
|
-
onClick: refresh,
|
|
728
|
-
onMouseEnter: (e) => Object.assign(e.currentTarget.style, iconBtnHover),
|
|
729
|
-
onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; },
|
|
1039
|
+
onClick: () => refresh(),
|
|
1040
|
+
onMouseEnter: (e) => { if (!loading) Object.assign(e.currentTarget.style, iconBtnHover); },
|
|
1041
|
+
onMouseLeave: (e) => { if (!loading) { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; } },
|
|
730
1042
|
}, React.createElement(IconRefresh)),
|
|
731
1043
|
React.createElement("button", {
|
|
732
1044
|
style: iconBtn,
|
|
@@ -736,8 +1048,10 @@ window.__ModuleLoader__.load({
|
|
|
736
1048
|
onMouseEnter: (e) => Object.assign(e.currentTarget.style, iconBtnHover),
|
|
737
1049
|
onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; },
|
|
738
1050
|
}, React.createElement(IconClose)))),
|
|
739
|
-
|
|
740
|
-
React.createElement(
|
|
1051
|
+
// 凭据选择默认收起: 它是配置项, 不该常驻在数据面板的主视觉里
|
|
1052
|
+
gearOpen ? React.createElement(CredentialPicker, { state: creds, onPick: pickCredential }) : null,
|
|
1053
|
+
gearOpen ? React.createElement(PalettePicker, { value: palette, onChange: choosePalette }) : null,
|
|
1054
|
+
React.createElement(DashboardBody, { data, loading, error, palette })) : null);
|
|
741
1055
|
}
|
|
742
1056
|
|
|
743
1057
|
// ---- apply -----------------------------------------------------------------------
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* ESM module format (cordis bundle rule): named exports apply/inject/name.
|
|
26
26
|
*/
|
|
27
27
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
28
29
|
import { homedir } from "node:os";
|
|
29
30
|
import { dirname, join } from "node:path";
|
|
30
31
|
|
|
@@ -36,6 +37,14 @@ const DEFAULT_API_KEY_ENV = "COMMANDCODE_API_KEY";
|
|
|
36
37
|
// Command Code 的 usage / subscriptions / whoami 实测要 8~20s, 8s 超时必然误杀。
|
|
37
38
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
38
39
|
|
|
40
|
+
// 落盘缓存文件名(与凭据选择文件同目录)。只存额度相关字段:
|
|
41
|
+
// whoami 带邮箱等个人信息, 没有落盘价值, 不写进去。
|
|
42
|
+
const CACHE_FILE_NAME = "dsh-commandcode-quota-cache.json";
|
|
43
|
+
const PERSISTED_ENDPOINTS = ["credits", "usage", "subscriptions"];
|
|
44
|
+
|
|
45
|
+
// 接口名 → 中文, 用于把失败原因写成一句人能看懂的话。
|
|
46
|
+
const ENDPOINT_LABELS = { credits: "余额", usage: "用量", subscriptions: "订阅", whoami: "账户" };
|
|
47
|
+
|
|
39
48
|
// --- HTTP helpers ------------------------------------------------------------
|
|
40
49
|
|
|
41
50
|
function sendJson(res, status, obj) {
|
|
@@ -71,7 +80,7 @@ async function ccFetch(baseURL, path, apiKey, timeoutMs) {
|
|
|
71
80
|
headers: {
|
|
72
81
|
authorization: `Bearer ${apiKey}`,
|
|
73
82
|
accept: "application/json",
|
|
74
|
-
"user-agent": "dsh-commandcode-quota/0.
|
|
83
|
+
"user-agent": "dsh-commandcode-quota/0.3.0"
|
|
75
84
|
},
|
|
76
85
|
signal: controller.signal
|
|
77
86
|
});
|
|
@@ -142,17 +151,77 @@ const HANDLERS = {
|
|
|
142
151
|
*/
|
|
143
152
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
144
153
|
const lastGood = new Map();
|
|
154
|
+
// 已从磁盘灌入过缓存的 key 指纹, 避免每次请求都读盘
|
|
155
|
+
let hydratedFor = null;
|
|
156
|
+
|
|
157
|
+
/** 缓存键带上 key 指纹: 换 key 后不会把上一个账号的数据当成兜底值。 */
|
|
158
|
+
function cacheKey(fingerprint, endpoint) {
|
|
159
|
+
return `${fingerprint}|${endpoint}`;
|
|
160
|
+
}
|
|
145
161
|
|
|
146
|
-
function cacheGet(
|
|
147
|
-
const hit = lastGood.get(
|
|
162
|
+
function cacheGet(fingerprint, endpoint) {
|
|
163
|
+
const hit = lastGood.get(cacheKey(fingerprint, endpoint));
|
|
148
164
|
if (!hit) return null;
|
|
149
165
|
if (Date.now() - hit.at > CACHE_TTL_MS) return null;
|
|
150
166
|
return hit;
|
|
151
167
|
}
|
|
152
168
|
|
|
153
|
-
function cacheSet(
|
|
169
|
+
function cacheSet(fingerprint, endpoint, value) {
|
|
154
170
|
if (value === undefined || value === null) return;
|
|
155
|
-
lastGood.set(
|
|
171
|
+
lastGood.set(cacheKey(fingerprint, endpoint), { value, at: Date.now() });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** key 的短指纹: 落盘只存指纹, 绝不写原始 key。 */
|
|
175
|
+
function keyFingerprint(apiKey) {
|
|
176
|
+
return createHash("sha256").update(String(apiKey)).digest("hex").slice(0, 16);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cacheFile() {
|
|
180
|
+
return join(dshHome(), CACHE_FILE_NAME);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 冷启动兜底: 把上次进程落盘的额度数据读回内存缓存。
|
|
185
|
+
*
|
|
186
|
+
* 没有这一步时, DSH 一重启内存缓存就空 —— 此时 credits 单接口失败就没有旧值可退,
|
|
187
|
+
* 面板会整块空白。只认同一把 key(指纹相同), 过期的条目直接丢弃。
|
|
188
|
+
*/
|
|
189
|
+
function hydrateCache(fingerprint) {
|
|
190
|
+
if (!fingerprint || hydratedFor === fingerprint) return;
|
|
191
|
+
hydratedFor = fingerprint;
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(readFileSync(cacheFile(), "utf8"));
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!parsed || parsed.fingerprint !== fingerprint) return;
|
|
199
|
+
const entries = parsed.entries;
|
|
200
|
+
if (!entries || typeof entries !== "object") return;
|
|
201
|
+
for (const endpoint of PERSISTED_ENDPOINTS) {
|
|
202
|
+
const hit = entries[endpoint];
|
|
203
|
+
if (!hit || typeof hit.at !== "number") continue;
|
|
204
|
+
if (Date.now() - hit.at > CACHE_TTL_MS) continue;
|
|
205
|
+
if (lastGood.has(cacheKey(fingerprint, endpoint))) continue;
|
|
206
|
+
lastGood.set(cacheKey(fingerprint, endpoint), { value: hit.value, at: hit.at });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 把当前指纹下仍然新鲜的额度快照落盘; 失败静默, 不能影响查询本身。 */
|
|
211
|
+
function persistCache(fingerprint) {
|
|
212
|
+
const entries = {};
|
|
213
|
+
for (const endpoint of PERSISTED_ENDPOINTS) {
|
|
214
|
+
const hit = cacheGet(fingerprint, endpoint);
|
|
215
|
+
if (hit) entries[endpoint] = hit;
|
|
216
|
+
}
|
|
217
|
+
if (Object.keys(entries).length === 0) return;
|
|
218
|
+
try {
|
|
219
|
+
const file = cacheFile();
|
|
220
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
221
|
+
writeFileSync(file, JSON.stringify({ fingerprint, entries }, null, 2) + "\n", "utf8");
|
|
222
|
+
} catch {
|
|
223
|
+
/* 落盘失败不影响查询 */
|
|
224
|
+
}
|
|
156
225
|
}
|
|
157
226
|
|
|
158
227
|
/**
|
|
@@ -166,6 +235,8 @@ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
|
|
|
166
235
|
const errors = [];
|
|
167
236
|
const stale = [];
|
|
168
237
|
const out = { ok: true, fetchedAt: Date.now(), errors, stale };
|
|
238
|
+
const fingerprint = keyFingerprint(cfg.apiKey);
|
|
239
|
+
hydrateCache(fingerprint);
|
|
169
240
|
|
|
170
241
|
const list = Array.isArray(onlyKeys) && onlyKeys.length > 0
|
|
171
242
|
? ENDPOINTS.filter(([key]) => onlyKeys.includes(key))
|
|
@@ -184,14 +255,14 @@ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
|
|
|
184
255
|
const handler = HANDLERS[r.key];
|
|
185
256
|
if (!r.error && r.status === 200 && r.json) {
|
|
186
257
|
handler.apply(r.json, out);
|
|
187
|
-
cacheSet(r.key, handler.snapshot(out));
|
|
258
|
+
cacheSet(fingerprint, r.key, handler.snapshot(out));
|
|
188
259
|
continue;
|
|
189
260
|
}
|
|
190
261
|
const message = r.error
|
|
191
262
|
? `请求失败: ${r.error}`
|
|
192
263
|
: (r.status === 401 ? "API key 无效或无权限 (HTTP 401)" : `HTTP ${r.status}`);
|
|
193
264
|
// 401 是凭据问题, 不能拿旧数据掩盖; 其余失败回退上次成功值。
|
|
194
|
-
const hit = r.status === 401 ? null : cacheGet(r.key);
|
|
265
|
+
const hit = r.status === 401 ? null : cacheGet(fingerprint, r.key);
|
|
195
266
|
if (hit) {
|
|
196
267
|
handler.restore(hit.value, out);
|
|
197
268
|
stale.push({ endpoint: r.key, message, at: hit.at });
|
|
@@ -205,14 +276,24 @@ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
|
|
|
205
276
|
if (skipped.length > 0) {
|
|
206
277
|
out.cached = [];
|
|
207
278
|
for (const [key] of skipped) {
|
|
208
|
-
const hit = cacheGet(key);
|
|
279
|
+
const hit = cacheGet(fingerprint, key);
|
|
209
280
|
if (!hit) continue;
|
|
210
281
|
HANDLERS[key].restore(hit.value, out);
|
|
211
282
|
out.cached.push(key);
|
|
212
283
|
}
|
|
213
284
|
}
|
|
214
285
|
|
|
215
|
-
|
|
286
|
+
// 落盘: 下次进程启动时 hydrateCache 能把这份快照读回来
|
|
287
|
+
persistCache(fingerprint);
|
|
288
|
+
|
|
289
|
+
if (errors.length >= list.length) {
|
|
290
|
+
out.ok = false;
|
|
291
|
+
// 全部失败时必须把具体原因带出去。否则客户端只收到 ok:false、拿不到任何文案,
|
|
292
|
+
// 面板会被渲染成一块空白(2026-10-29 key 失效时就这么空了一整片)。
|
|
293
|
+
out.error = `查询失败: ${errors
|
|
294
|
+
.map((e) => `${ENDPOINT_LABELS[e.endpoint] ?? e.endpoint} ${e.message}`)
|
|
295
|
+
.join(";")}`;
|
|
296
|
+
}
|
|
216
297
|
return out;
|
|
217
298
|
}
|
|
218
299
|
|
package/test/client.test.cjs
CHANGED
|
@@ -13,9 +13,9 @@ const fixture = {
|
|
|
13
13
|
// 月度窗口的重置时间取自订阅周期末,是 ISO 字符串而非毫秒数
|
|
14
14
|
subscription: { status: "active", currentPeriodEnd: new Date(Date.now() + 20 * 86400000).toISOString() },
|
|
15
15
|
};
|
|
16
|
-
function mount({ open = true, data = fixture, failed = false, creds = null } = {}) {
|
|
16
|
+
function mount({ open = true, data = fixture, failed = false, creds = null, gearOpen = false } = {}) {
|
|
17
17
|
let registration, component, slot, stateIndex = 0;
|
|
18
|
-
const values = [open, data, false, null, creds || { entries: [], apiKeyEnv: null, saving: false, error: null }];
|
|
18
|
+
const values = [open, data, false, null, creds || { entries: [], apiKeyEnv: null, saving: false, error: null }, gearOpen, null];
|
|
19
19
|
const updates = [], callbacks = [], effects = [], requests = [], timers = [];
|
|
20
20
|
const React = {
|
|
21
21
|
Fragment: "fragment",
|
|
@@ -61,8 +61,8 @@ function mount({ open = true, data = fixture, failed = false, creds = null } = {
|
|
|
61
61
|
async function main() {
|
|
62
62
|
const view = mount();
|
|
63
63
|
const buttons = view.nodes.filter(node => node.type === "button");
|
|
64
|
-
assert.equal(buttons.length,
|
|
65
|
-
assert.equal(view.stateIndex,
|
|
64
|
+
assert.equal(buttons.length, 4, "状态栏 + 齿轮 + 刷新 + 关闭,不应存在账号切换按钮");
|
|
65
|
+
assert.equal(view.stateIndex, 7, "状态:面板开合 / 数据 / 加载 / 错误 / 凭据条目 / 凭据设置 / 配色");
|
|
66
66
|
assert.equal(view.callbacks[0].deps.length, 0);
|
|
67
67
|
for (const label of ["5h 10%", "周 20%", "月 30%", "$70.00", "5 小时窗口", "每周窗口", "每月窗口"]) {
|
|
68
68
|
assert.ok(view.texts.includes(label), `保留额度展示:${label}`);
|
|
@@ -107,7 +107,7 @@ async function main() {
|
|
|
107
107
|
["报错态", { failed: true }],
|
|
108
108
|
["无数据态", { data: null }],
|
|
109
109
|
]) {
|
|
110
|
-
const view2 = mount({ ...options, creds: pickerState });
|
|
110
|
+
const view2 = mount({ ...options, creds: pickerState, gearOpen: true });
|
|
111
111
|
const selects = view2.nodes.filter((n) => n.type === "select");
|
|
112
112
|
assert.equal(selects.length, 1, `${label}下应能看到凭据条目下拉`);
|
|
113
113
|
const opts = view2.nodes.filter((n) => n.type === "option");
|
|
@@ -126,13 +126,13 @@ async function main() {
|
|
|
126
126
|
assert.equal(selects[0].props.value, "COMMANDCODE2_API_KEY", `${label}下应选中当前条目`);
|
|
127
127
|
}
|
|
128
128
|
// 宿主端读不到供应商时会退化成纯字符串数组,也必须照常工作
|
|
129
|
-
const plain = mount({ creds: { entries: ["BAILIAN_API_KEY"], apiKeyEnv: null, saving: false, error: null } });
|
|
129
|
+
const plain = mount({ creds: { entries: ["BAILIAN_API_KEY"], apiKeyEnv: null, saving: false, error: null }, gearOpen: true });
|
|
130
130
|
assert.deepEqual(
|
|
131
131
|
plain.nodes.filter((n) => n.type === "option").map((o) => o.children[0]),
|
|
132
132
|
["(未设置)", "BAILIAN_API_KEY"],
|
|
133
133
|
);
|
|
134
134
|
// 配置指向一个尚未添加的条目时也要出现在候选里,否则下拉会显示成空白
|
|
135
|
-
const orphan = mount({ creds: { entries: [{ ref: "OTHER_KEY", provider: null }], apiKeyEnv: "MISSING_KEY", saving: false, error: null } });
|
|
135
|
+
const orphan = mount({ creds: { entries: [{ ref: "OTHER_KEY", provider: null }], apiKeyEnv: "MISSING_KEY", saving: false, error: null }, gearOpen: true });
|
|
136
136
|
assert.deepEqual(
|
|
137
137
|
orphan.nodes.filter((n) => n.type === "option").map((o) => o.props.value),
|
|
138
138
|
["", "MISSING_KEY", "OTHER_KEY"],
|
|
@@ -140,9 +140,12 @@ async function main() {
|
|
|
140
140
|
const timerView = mount({ open: false, data: null });
|
|
141
141
|
assert.equal(timerView.nodes.filter(node => node.type === "button").length, 1);
|
|
142
142
|
const cleanup = timerView.effects[0]();
|
|
143
|
+
// 分层轮询: 余额 3 分钟一次, 用量/订阅 30 分钟一次(重查询不该跟余额同频)
|
|
143
144
|
assert.equal(timerView.timers[0].ms, 3 * 60 * 1000);
|
|
145
|
+
assert.equal(timerView.timers[1].ms, 30 * 60 * 1000);
|
|
144
146
|
cleanup();
|
|
145
|
-
assert.equal(timerView.timers[
|
|
147
|
+
assert.equal(timerView.timers[2].cleared, 1);
|
|
148
|
+
assert.equal(timerView.timers[3].cleared, 1);
|
|
146
149
|
const failure = mount({ failed: true });
|
|
147
150
|
await failure.callbacks[0].fn();
|
|
148
151
|
assert.ok(failure.updates.some(update => update.index === 3 && update.value.includes("HTTP 503")));
|
package/test/host.test.cjs
CHANGED
|
@@ -138,11 +138,22 @@ async function main() {
|
|
|
138
138
|
const stale = await app.request();
|
|
139
139
|
assert.equal(stale.body.balance.monthlyCredits, 70);
|
|
140
140
|
assert.equal(stale.body.stale.length, 4);
|
|
141
|
+
// 落盘缓存: 模拟 DSH 重启(新 context, 内存缓存为空)后 credits 失败,
|
|
142
|
+
// 应该能从磁盘快照回退余额, 而不是让面板空白。
|
|
143
|
+
const restarted = load();
|
|
144
|
+
restarted.respond(async () => ({ status: 503, json: async () => ({}) }));
|
|
145
|
+
const restored = await mount(restarted, config, { PRIMARY_TEST_KEY: "fixture-primary" })
|
|
146
|
+
.request(DASHBOARD + "?scope=quick");
|
|
147
|
+
assert.equal(restored.body.balance.monthlyCredits, 70, "重启后应能从落盘缓存回退余额");
|
|
148
|
+
assert.equal(restored.body.stale.length, 1);
|
|
141
149
|
runtime.respond(async () => ({ status: 401, json: async () => ({}) }));
|
|
142
150
|
const unauthorized = await app.request();
|
|
143
151
|
assert.equal(unauthorized.body.ok, false);
|
|
144
152
|
assert.equal(unauthorized.body.balance, undefined);
|
|
145
153
|
assert.equal(unauthorized.body.errors.length, 4);
|
|
154
|
+
// 全失败必须带出可读原因, 否则客户端只拿到 ok:false, 面板会渲染成空白
|
|
155
|
+
assert.ok(unauthorized.body.error.includes("查询失败"), "全失败时应带出可读原因");
|
|
156
|
+
assert.ok(unauthorized.body.error.includes("余额"), "失败原因应标明是哪个接口");
|
|
146
157
|
runtime.requests.length = 0;
|
|
147
158
|
assert.equal((await app.request(undefined, "POST")).status, 405);
|
|
148
159
|
assert.equal(runtime.requests.length, 0);
|
|
@@ -172,6 +183,9 @@ async function main() {
|
|
|
172
183
|
});
|
|
173
184
|
assert.equal((await mount(retryRuntime, config, { PRIMARY_TEST_KEY: "fixture-primary" }).request(DASHBOARD + "?scope=quick")).body.ok, true);
|
|
174
185
|
assert.equal(attempts, 2);
|
|
186
|
+
// 超时用例要求「冷启动且无任何可用缓存」: 先清掉落盘快照,
|
|
187
|
+
// 否则 hydrateCache 会让 credits 兜底成功, 就测不到 ok:false 了。
|
|
188
|
+
fs.rmSync(join(fakeHome, "dsh-commandcode-quota-cache.json"), { force: true });
|
|
175
189
|
const timeoutRuntime = load();
|
|
176
190
|
timeoutRuntime.respond((url, options) => new Promise((resolve, reject) => {
|
|
177
191
|
options.signal.addEventListener("abort", () => reject(new Error("模拟超时")), { once: true });
|