dsh-commandcode-quota 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +108 -2
- package/README.zh.md +109 -0
- package/cordis.patch.yml +21 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
- package/src/client.js +666 -0
- package/src/index.js +311 -0
- package/test/client.test.cjs +102 -0
- package/test/host.test.cjs +134 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
// dsh-commandcode-quota: CLIENT half — Command Code 余额额度仪表盘 (v3 可读性修复版)。
|
|
2
|
+
//
|
|
3
|
+
// 在 composer dock 槽位挂一行常驻额度状态栏; 点击展开仪表盘面板:
|
|
4
|
+
// - Hero: 余额大数字 + 订阅状态徽章
|
|
5
|
+
// - 限额: 每月 / 5小时 / 每周 进度卡片 (used vs cap + 重置时间)
|
|
6
|
+
// - 用量: 三格统计卡片 (总消耗 / 请求数 / tokens)
|
|
7
|
+
// - 底部: 账户信息 + 数据时间
|
|
8
|
+
//
|
|
9
|
+
// v3 修复: 进度条之前用 `${var}` 拼接透明度后缀产生非法 CSS 导致填充色
|
|
10
|
+
// 不渲染 (进度条看不见); 全部改用 color-mix() 派生透明度; 色板对齐 DSH
|
|
11
|
+
// 深色主题层次, 提高文字对比。每月窗口由 usage.totalMonthlyCredits(已用)
|
|
12
|
+
// + balance.monthlyCredits(剩余) 合成, 重置时间取订阅周期末。
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: "dsh-commandcode-quota",
|
|
15
|
+
factory: (require) => {
|
|
16
|
+
const React = require("react");
|
|
17
|
+
const { useCallback, useEffect, useState, useRef } = React;
|
|
18
|
+
|
|
19
|
+
const FETCH_URL = "/__dsh-commandcode-quota/dashboard";
|
|
20
|
+
// 每 3 分钟后台自动刷新一次余额(面板开/关都刷新, 打开时看到的就是最新数据)
|
|
21
|
+
const REFRESH_MS = 3 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
// ---- 格式化 --------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function fmtCurrency(value, digits = 2) {
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
27
|
+
return "$" + value.toFixed(digits);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fmtNumber(value) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
32
|
+
if (value >= 1e6) return (value / 1e6).toFixed(1) + "M";
|
|
33
|
+
if (value >= 1e3) return (value / 1e3).toFixed(1) + "K";
|
|
34
|
+
return String(Math.round(value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fmtDate(ms) {
|
|
38
|
+
const t = toMs(ms);
|
|
39
|
+
if (!t) return "—";
|
|
40
|
+
const d = new Date(t);
|
|
41
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
42
|
+
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 统一转时间戳: API 里 resetAt 是数字毫秒, createdAt/currentPeriodStart/currentPeriodEnd 是 ISO 字符串
|
|
46
|
+
function toMs(ms) {
|
|
47
|
+
if (typeof ms === "number" && Number.isFinite(ms)) return ms;
|
|
48
|
+
if (typeof ms === "string" && ms.length > 0) {
|
|
49
|
+
const t = Date.parse(ms);
|
|
50
|
+
return Number.isFinite(t) ? t : null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 短日期: 年/月/日
|
|
56
|
+
function fmtDay(ms) {
|
|
57
|
+
const t = toMs(ms);
|
|
58
|
+
if (!t) return "—";
|
|
59
|
+
const d = new Date(t);
|
|
60
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
61
|
+
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 从 fromMs 到现在经过了多久: "15 天 3 小时" / "3 小时 25 分" / "25 分钟"
|
|
65
|
+
function fmtElapsed(fromMs) {
|
|
66
|
+
const t = toMs(fromMs);
|
|
67
|
+
if (!t) return "—";
|
|
68
|
+
const diff = Date.now() - t;
|
|
69
|
+
if (diff < 0) return "未开始";
|
|
70
|
+
const days = Math.floor(diff / 86400000);
|
|
71
|
+
const hours = Math.floor((diff % 86400000) / 3600000);
|
|
72
|
+
const mins = Math.floor((diff % 3600000) / 60000);
|
|
73
|
+
if (days > 0) return `${days} 天 ${hours} 小时`;
|
|
74
|
+
if (hours > 0) return `${hours} 小时 ${mins} 分`;
|
|
75
|
+
return `${Math.max(1, mins)} 分钟`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fmtDuration(fromMs) {
|
|
79
|
+
const t = toMs(fromMs);
|
|
80
|
+
if (t === null) return "—";
|
|
81
|
+
if (t <= Date.now()) return "已重置";
|
|
82
|
+
const diff = t - Date.now();
|
|
83
|
+
const days = Math.floor(diff / 86400000);
|
|
84
|
+
const h = Math.floor((diff % 86400000) / 3600000);
|
|
85
|
+
const m = Math.floor((diff % 3600000) / 60000);
|
|
86
|
+
if (days > 0) return `${days}天${h}小时`;
|
|
87
|
+
if (h > 0) return `${h}小时${m}分`;
|
|
88
|
+
return `${m}分${Math.floor((diff % 60000) / 1000)}秒`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 到期日与今天不同时补上日期, 同时回答"还有多久"和"哪天到期"
|
|
92
|
+
function fmtResetDay(ms) {
|
|
93
|
+
const t = toMs(ms);
|
|
94
|
+
if (t === null || t <= Date.now()) return "";
|
|
95
|
+
const day = fmtDay(t);
|
|
96
|
+
return day === fmtDay(Date.now()) ? "" : `(${day})`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- 主题 token (对齐 DSH 深色主题层次) -----------------------------------
|
|
100
|
+
// 深色主题: bg-layer-1=rgb(35,35,36) / layer-2=rgb(44,44,46) /
|
|
101
|
+
// layer-3=rgb(53,54,56) / label-primary=rgb(249,250,251) /
|
|
102
|
+
// label-secondary=rgb(207,211,214) / label-tertiary=rgb(173,178,184)
|
|
103
|
+
// 浅色主题由变量自动切换; 进度条/徽章的透明色一律用 color-mix 派生,
|
|
104
|
+
// 绝不在 var() 后面拼十六进制透明度 (非法 CSS)。
|
|
105
|
+
|
|
106
|
+
const T = {
|
|
107
|
+
bg: "var(--dsw-alias-bg-layer-1, #232324)",
|
|
108
|
+
bg2: "var(--dsw-alias-bg-layer-2, #2c2c2e)",
|
|
109
|
+
bg3: "var(--dsw-alias-bg-layer-3, #353636)",
|
|
110
|
+
border: "var(--dsw-alias-border-l2, rgba(255,255,255,.14))",
|
|
111
|
+
borderL: "var(--dsw-alias-border-l1, rgba(255,255,255,.08))",
|
|
112
|
+
label: "var(--dsw-alias-label-primary, #f9fafb)",
|
|
113
|
+
label2: "var(--dsw-alias-label-secondary, #d3d5d9)",
|
|
114
|
+
label3: "var(--dsw-alias-label-tertiary, #adb2b8)",
|
|
115
|
+
// 进度条轨道在 深色主题下直接用近黑半透明 (Bar 里用 TRACK_STYLE 常量)
|
|
116
|
+
track: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.2))",
|
|
117
|
+
success: "var(--dsw-alias-state-success-primary, #22c55e)",
|
|
118
|
+
warn: "var(--dsw-alias-state-warn-primary, #f59e0b)",
|
|
119
|
+
error: "var(--dsw-alias-state-error-primary, #f25a5a)",
|
|
120
|
+
brand: "var(--dsw-alias-brand-primary, #679efe)",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// 深色主题下进度条轨道: 近黑半透明, 与填充色形成强对比
|
|
124
|
+
const TRACK_STYLE = "rgba(0, 0, 0, 0.35)";
|
|
125
|
+
|
|
126
|
+
// ---- 样式常量 -------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
// 面板: 由 shellStyle 的绝对定位容器承载, 从状态栏上方展开(不再是右下角浮层)
|
|
129
|
+
const shellStyle = {
|
|
130
|
+
width: 352,
|
|
131
|
+
maxWidth: "calc(100vw - 40px)",
|
|
132
|
+
maxHeight: "calc(100vh - 120px)",
|
|
133
|
+
overflowY: "auto",
|
|
134
|
+
pointerEvents: "auto",
|
|
135
|
+
zIndex: 9999,
|
|
136
|
+
fontFamily: "inherit",
|
|
137
|
+
color: T.label,
|
|
138
|
+
borderRadius: 16,
|
|
139
|
+
background: T.bg,
|
|
140
|
+
border: `1px solid ${T.border}`,
|
|
141
|
+
boxShadow: "0 16px 56px rgba(0,0,0,.55), 0 2px 10px rgba(0,0,0,.35)",
|
|
142
|
+
overflow: "hidden",
|
|
143
|
+
animation: "ccUsagePop .22s cubic-bezier(.2,.9,.3,1.2)",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// 额度读数: 常驻在输入框下方 dock, 刻意对齐官方 stats 药丸的规格。
|
|
147
|
+
// 官方规格见 @deepseek-ai/dsh-client-ui-chat 的 StatsPills.module.css:
|
|
148
|
+
// .iq1doa_pill{ color:label-tertiary; border-radius:999px; gap:6px; padding:1px 8px;
|
|
149
|
+
// background:0 0 } ← 平时**无底色**
|
|
150
|
+
// button.iq1doa_pill:hover,button.iq1doa_pill[aria-expanded=true]{
|
|
151
|
+
// background:var(--dsw-alias-interactive-bg-hover); color:label-secondary }
|
|
152
|
+
// 即: 灰底只在 hover / 展开时出现, 不是常驻。
|
|
153
|
+
const badgeStyle = {
|
|
154
|
+
display: "inline-flex",
|
|
155
|
+
alignItems: "center",
|
|
156
|
+
gap: 6,
|
|
157
|
+
// 间距交给父容器的 gap(官方 dock gap = 12), 这里不再额外加 margin,
|
|
158
|
+
// 否则会变成 12+12=24, 比官方胶囊之间宽一倍。
|
|
159
|
+
padding: "1px 8px",
|
|
160
|
+
borderRadius: 999,
|
|
161
|
+
pointerEvents: "auto",
|
|
162
|
+
cursor: "pointer",
|
|
163
|
+
border: "none",
|
|
164
|
+
// 平时透明, hover 时由事件处理器浮出灰底(见下方 onMouseEnter)
|
|
165
|
+
background: "transparent",
|
|
166
|
+
color: T.label3,
|
|
167
|
+
fontFamily: "inherit",
|
|
168
|
+
fontSize: 12,
|
|
169
|
+
lineHeight: "20px",
|
|
170
|
+
fontVariantNumeric: "tabular-nums",
|
|
171
|
+
whiteSpace: "nowrap",
|
|
172
|
+
transition: "background .15s ease, color .15s ease",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// 只作定位锚点: 宽度自适应(不撑满整行, 否则会挤开官方统计), 面板相对它向上展开
|
|
176
|
+
const statusBarStyle = {
|
|
177
|
+
position: "relative",
|
|
178
|
+
display: "inline-flex",
|
|
179
|
+
alignItems: "center",
|
|
180
|
+
pointerEvents: "none",
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// 由 shellStyle 承载的定位/动画补充: 从状态栏上方展开, 左边缘与读数对齐
|
|
184
|
+
const panelPosStyle = {
|
|
185
|
+
position: "absolute",
|
|
186
|
+
bottom: "calc(100% + 8px)",
|
|
187
|
+
left: 8,
|
|
188
|
+
pointerEvents: "auto",
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ---- 内联 SVG 图标 ----------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
// 空心闪电: 对齐官方 outline 图标规格 (fill:none + stroke:currentColor + strokeWidth:1, 16x16 视图盒显示 14px)。
|
|
194
|
+
// 原来是实心 fill="currentColor", 所以看起来又深又重, 和官方空心图标不是一套。
|
|
195
|
+
const IconBolt = () => React.createElement("svg", {
|
|
196
|
+
width: 14,
|
|
197
|
+
height: 14,
|
|
198
|
+
viewBox: "0 0 16 16",
|
|
199
|
+
fill: "none",
|
|
200
|
+
stroke: "currentColor",
|
|
201
|
+
strokeWidth: 1,
|
|
202
|
+
strokeLinecap: "round",
|
|
203
|
+
strokeLinejoin: "round",
|
|
204
|
+
"aria-hidden": "true",
|
|
205
|
+
},
|
|
206
|
+
React.createElement("path", { d: "M8.9 1.5 3.2 9.2h4.1l-.9 5.3 6.4-8h-4.2l.3-5z" }));
|
|
207
|
+
|
|
208
|
+
const IconRefresh = () => React.createElement("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
209
|
+
React.createElement("path", { d: "M21 12a9 9 0 1 1-2.64-6.36" }),
|
|
210
|
+
React.createElement("polyline", { points: "21 3 21 8 16 8" }));
|
|
211
|
+
|
|
212
|
+
const IconClose = () => React.createElement("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round" },
|
|
213
|
+
React.createElement("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
|
|
214
|
+
React.createElement("line", { x1: "6", y1: "6", x2: "18", y2: "18" }));
|
|
215
|
+
|
|
216
|
+
const IconClock = ({ color }) => React.createElement("svg", { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", stroke: color, strokeWidth: 2, strokeLinecap: "round" },
|
|
217
|
+
React.createElement("circle", { cx: "12", cy: "12", r: "9" }),
|
|
218
|
+
React.createElement("polyline", { points: "12 7 12 12 15 14" }));
|
|
219
|
+
|
|
220
|
+
const IconCalendar = ({ color }) => React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 24 24", fill: "none", stroke: color || "currentColor", strokeWidth: 2, strokeLinecap: "round" },
|
|
221
|
+
React.createElement("rect", { x: "3", y: "5", width: "18", height: "16", rx: "2" }),
|
|
222
|
+
React.createElement("line", { x1: "3", y1: "10", x2: "21", y2: "10" }),
|
|
223
|
+
React.createElement("line", { x1: "8", y1: "2.5", x2: "8", y2: "6" }),
|
|
224
|
+
React.createElement("line", { x1: "16", y1: "2.5", x2: "16", y2: "6" }));
|
|
225
|
+
|
|
226
|
+
// ---- 通用小组件 --------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function StatCard({ label, value, sub, accent }) {
|
|
229
|
+
return React.createElement("div", {
|
|
230
|
+
style: {
|
|
231
|
+
flex: 1,
|
|
232
|
+
minWidth: 0,
|
|
233
|
+
background: T.bg2,
|
|
234
|
+
border: `1px solid ${T.borderL}`,
|
|
235
|
+
borderRadius: 10,
|
|
236
|
+
padding: "10px 11px",
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
React.createElement("div", { style: { fontSize: 11, color: T.label3, marginBottom: 4, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, label),
|
|
240
|
+
React.createElement("div", {
|
|
241
|
+
style: {
|
|
242
|
+
fontSize: 15.5,
|
|
243
|
+
fontWeight: 700,
|
|
244
|
+
fontVariantNumeric: "tabular-nums",
|
|
245
|
+
color: accent || T.label,
|
|
246
|
+
whiteSpace: "nowrap",
|
|
247
|
+
overflow: "hidden",
|
|
248
|
+
textOverflow: "ellipsis",
|
|
249
|
+
letterSpacing: "-.2px",
|
|
250
|
+
},
|
|
251
|
+
}, value),
|
|
252
|
+
sub ? React.createElement("div", { style: { fontSize: 11, color: T.label3, marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, sub) : null);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 进度条: 轨道用深色 (与卡片背景强对比), 填充用 color-mix 渐变色
|
|
256
|
+
function Bar({ pct, color }) {
|
|
257
|
+
const clamped = Math.max(0, Math.min(100, pct || 0));
|
|
258
|
+
const fillColor = color || T.success;
|
|
259
|
+
return React.createElement("div", {
|
|
260
|
+
style: {
|
|
261
|
+
height: 10,
|
|
262
|
+
borderRadius: 99,
|
|
263
|
+
background: TRACK_STYLE,
|
|
264
|
+
border: "1px solid rgba(255,255,255,.06)",
|
|
265
|
+
overflow: "hidden",
|
|
266
|
+
padding: 1,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
React.createElement("div", {
|
|
270
|
+
style: {
|
|
271
|
+
width: clamped + "%",
|
|
272
|
+
height: "100%",
|
|
273
|
+
borderRadius: 99,
|
|
274
|
+
background: `linear-gradient(90deg, ${fillColor}, color-mix(in srgb, ${fillColor} 78%, #ffffff 22%))`,
|
|
275
|
+
boxShadow: `0 0 10px color-mix(in srgb, ${fillColor} 45%, transparent)`,
|
|
276
|
+
transition: "width .5s cubic-bezier(.3,.8,.3,1)",
|
|
277
|
+
},
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 限额卡片: 标题 + 百分比 + 进度条 + 已用/上限/剩余 + 重置时间
|
|
282
|
+
function LimitCard({ title, used, cap, resetAt }) {
|
|
283
|
+
const capSafe = cap > 0 ? cap : (used > 0 ? used : 1);
|
|
284
|
+
const pct = capSafe > 0 ? Math.min(100, (used / capSafe) * 100) : 0;
|
|
285
|
+
const color = pct >= 90 ? T.error : pct >= 70 ? T.warn : T.success;
|
|
286
|
+
const remaining = Math.max(0, capSafe - used);
|
|
287
|
+
return React.createElement("div", {
|
|
288
|
+
style: {
|
|
289
|
+
background: T.bg2,
|
|
290
|
+
border: `1px solid ${T.borderL}`,
|
|
291
|
+
borderRadius: 12,
|
|
292
|
+
padding: "12px 14px",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 9 } },
|
|
296
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, fontWeight: 600, color: T.label } },
|
|
297
|
+
React.createElement(IconClock, { color }),
|
|
298
|
+
title),
|
|
299
|
+
React.createElement("span", { style: { fontSize: 16, fontWeight: 800, fontVariantNumeric: "tabular-nums", color } },
|
|
300
|
+
(Math.round(pct * 10) / 10) + "%")),
|
|
301
|
+
React.createElement(Bar, { pct, color }),
|
|
302
|
+
React.createElement("div", { style: { display: "flex", justifyContent: "space-between", marginTop: 9, fontSize: 11.5, color: T.label2, fontVariantNumeric: "tabular-nums" } },
|
|
303
|
+
React.createElement("span", null, `已用 ${fmtCurrency(used)}`),
|
|
304
|
+
React.createElement("span", null, `上限 ${fmtCurrency(capSafe)}`)),
|
|
305
|
+
React.createElement("div", { style: { marginTop: 3, fontSize: 11, color: T.label3 } },
|
|
306
|
+
`剩余 ${fmtCurrency(remaining)} · 重置 ${fmtDuration(resetAt)}${fmtResetDay(resetAt)}`));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- 面板内容 ----------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
function DashboardBody({ data, loading, error }) {
|
|
312
|
+
if (error) {
|
|
313
|
+
return React.createElement("div", { style: { padding: "16px 16px 20px" } },
|
|
314
|
+
React.createElement("div", {
|
|
315
|
+
style: {
|
|
316
|
+
background: "color-mix(in srgb, var(--dsw-alias-state-error-primary, #f25a5a) 14%, transparent)",
|
|
317
|
+
border: "1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #f25a5a) 38%, transparent)",
|
|
318
|
+
borderRadius: 12,
|
|
319
|
+
padding: "12px 14px",
|
|
320
|
+
fontSize: 12.5,
|
|
321
|
+
color: T.error,
|
|
322
|
+
lineHeight: 1.5,
|
|
323
|
+
},
|
|
324
|
+
}, String(error)));
|
|
325
|
+
}
|
|
326
|
+
if (loading && !data) {
|
|
327
|
+
return React.createElement("div", { style: { padding: "24px 16px", textAlign: "center", fontSize: 12.5, color: T.label3 } },
|
|
328
|
+
React.createElement("div", { style: { width: 22, height: 22, margin: "0 auto 10px", border: "2px solid rgba(255,255,255,.18)", borderTopColor: T.brand, borderRadius: "50%", animation: "ccUsageSpin .8s linear infinite" } }),
|
|
329
|
+
"正在获取余额…(Command Code 接口较慢,约需 10~20 秒)");
|
|
330
|
+
}
|
|
331
|
+
if (!data || !data.balance) return null;
|
|
332
|
+
|
|
333
|
+
const { balance, windowLimits, usage, subscription, user } = data;
|
|
334
|
+
|
|
335
|
+
// 订阅开通时间: 优先 createdAt, 回退到当前周期开始
|
|
336
|
+
const subSince = subscription && (subscription.createdAt || subscription.currentPeriodStart) || null;
|
|
337
|
+
|
|
338
|
+
// 本次请求里订阅接口是否失败(接口本身慢/超时) —— 用于区分"没这条数据"和"没取到"
|
|
339
|
+
const subFailed = Array.isArray(data.errors)
|
|
340
|
+
&& data.errors.some((e) => e && e.endpoint === "subscriptions");
|
|
341
|
+
const staleList = Array.isArray(data.stale) ? data.stale : [];
|
|
342
|
+
|
|
343
|
+
// 月度窗口: usage.totalMonthlyCredits(本周期已用月度积分) +
|
|
344
|
+
// balance.monthlyCredits(剩余月度积分) = 周期总额度
|
|
345
|
+
const monthUsed = usage && typeof usage.totalMonthlyCredits === "number" ? usage.totalMonthlyCredits : null;
|
|
346
|
+
const monthCap = monthUsed !== null && balance.monthlyCredits !== undefined
|
|
347
|
+
? monthUsed + balance.monthlyCredits
|
|
348
|
+
: null;
|
|
349
|
+
const monthReset = subscription && subscription.currentPeriodEnd ? subscription.currentPeriodEnd : null;
|
|
350
|
+
|
|
351
|
+
const rows = [];
|
|
352
|
+
|
|
353
|
+
// 数据一致性告警 (典型: 两个账号解析到同一个 API key, 余额自然相同)
|
|
354
|
+
const warnings = Array.isArray(data.warnings) ? data.warnings : [];
|
|
355
|
+
if (warnings.length > 0) {
|
|
356
|
+
rows.push(React.createElement("div", {
|
|
357
|
+
key: "warnings",
|
|
358
|
+
style: {
|
|
359
|
+
margin: "12px 16px 0",
|
|
360
|
+
padding: "10px 12px",
|
|
361
|
+
borderRadius: 10,
|
|
362
|
+
background: "color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 15%, transparent)",
|
|
363
|
+
border: "1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 45%, transparent)",
|
|
364
|
+
color: T.warn,
|
|
365
|
+
fontSize: 11.5,
|
|
366
|
+
lineHeight: 1.55,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
React.createElement("div", { style: { fontWeight: 700, marginBottom: 4 } }, "⚠ 凭据告警"),
|
|
370
|
+
warnings.map((w, i) => React.createElement("div", { key: i, style: { marginTop: i > 0 ? 4 : 0 } }, w))));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 缓存兜底提示: 接口超时, 当前显示的是上次成功获取的数据
|
|
374
|
+
if (staleList.length > 0) {
|
|
375
|
+
const FIELD_NAMES = { credits: "余额", usage: "用量", subscriptions: "订阅", whoami: "账户" };
|
|
376
|
+
rows.push(React.createElement("div", {
|
|
377
|
+
key: "stale",
|
|
378
|
+
style: {
|
|
379
|
+
margin: "10px 16px 0",
|
|
380
|
+
fontSize: 11,
|
|
381
|
+
color: T.label3,
|
|
382
|
+
lineHeight: 1.55,
|
|
383
|
+
},
|
|
384
|
+
}, `⏱ ${staleList.map((s) => FIELD_NAMES[s.endpoint] || s.endpoint).join("、")} 本次请求超时,显示的是上次成功获取的数据(点右上角 ⟳ 重试)`));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Hero: 余额大数字
|
|
388
|
+
rows.push(React.createElement("div", {
|
|
389
|
+
key: "hero",
|
|
390
|
+
style: {
|
|
391
|
+
padding: "18px 16px 16px",
|
|
392
|
+
background: `linear-gradient(150deg, ${T.bg3}, ${T.bg2})`,
|
|
393
|
+
borderBottom: `1px solid ${T.borderL}`,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "flex-start" } },
|
|
397
|
+
React.createElement("div", null,
|
|
398
|
+
React.createElement("div", { style: { fontSize: 11, fontWeight: 600, color: T.label3, letterSpacing: ".7px", textTransform: "uppercase", marginBottom: 4 } }, "可用余额 Balance"),
|
|
399
|
+
React.createElement("div", {
|
|
400
|
+
style: {
|
|
401
|
+
fontSize: 34,
|
|
402
|
+
fontWeight: 800,
|
|
403
|
+
lineHeight: 1.05,
|
|
404
|
+
fontVariantNumeric: "tabular-nums",
|
|
405
|
+
letterSpacing: "-1px",
|
|
406
|
+
color: T.label,
|
|
407
|
+
},
|
|
408
|
+
}, fmtCurrency(balance.monthlyCredits))),
|
|
409
|
+
React.createElement("div", {
|
|
410
|
+
style: {
|
|
411
|
+
display: "flex",
|
|
412
|
+
alignItems: "center",
|
|
413
|
+
gap: 6,
|
|
414
|
+
background: "color-mix(in srgb, var(--dsw-alias-state-success-primary, #22c55e) 15%, transparent)",
|
|
415
|
+
border: "1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary, #22c55e) 40%, transparent)",
|
|
416
|
+
color: T.success,
|
|
417
|
+
borderRadius: 99,
|
|
418
|
+
padding: "4px 11px",
|
|
419
|
+
fontSize: 11.5,
|
|
420
|
+
fontWeight: 600,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
React.createElement("span", { style: { width: 6, height: 6, borderRadius: "50%", background: T.success, display: "inline-block" } }),
|
|
424
|
+
(subscription && subscription.status) ? subscription.status : "active")),
|
|
425
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 10, fontSize: 11.5, color: T.label2 } },
|
|
426
|
+
React.createElement(IconCalendar, { color: T.brand }),
|
|
427
|
+
React.createElement("span", null, subSince
|
|
428
|
+
? `开通于 ${fmtDay(subSince)} · 已使用 ${fmtElapsed(subSince)}`
|
|
429
|
+
: (subFailed ? "订阅接口本次未响应(点右上角 ⟳ 重试)" : "订阅信息暂不可用"))),
|
|
430
|
+
React.createElement("div", { style: { display: "flex", gap: 8, marginTop: 12 } },
|
|
431
|
+
React.createElement(StatCard, { label: "总消耗", value: fmtCurrency(usage && usage.totalCost) }),
|
|
432
|
+
React.createElement(StatCard, { label: "Tokens", value: fmtNumber(usage && usage.totalTokens) }),
|
|
433
|
+
React.createElement(StatCard, { label: "成功率", value: usage ? usage.successRate + "%" : "—" }))));
|
|
434
|
+
|
|
435
|
+
// 限额卡片: 5小时 / 每周 / 每月
|
|
436
|
+
const limitCards = [];
|
|
437
|
+
if (windowLimits && windowLimits.fiveHour) {
|
|
438
|
+
limitCards.push(React.createElement(LimitCard, {
|
|
439
|
+
key: "5h",
|
|
440
|
+
title: "5 小时窗口",
|
|
441
|
+
used: windowLimits.fiveHour.used,
|
|
442
|
+
cap: windowLimits.fiveHour.cap,
|
|
443
|
+
resetAt: windowLimits.fiveHour.resetAt,
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
if (windowLimits && windowLimits.weekly) {
|
|
447
|
+
limitCards.push(React.createElement(LimitCard, {
|
|
448
|
+
key: "week",
|
|
449
|
+
title: "每周窗口",
|
|
450
|
+
used: windowLimits.weekly.used,
|
|
451
|
+
cap: windowLimits.weekly.cap,
|
|
452
|
+
resetAt: windowLimits.weekly.resetAt,
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
if (monthCap !== null) {
|
|
456
|
+
limitCards.push(React.createElement(LimitCard, {
|
|
457
|
+
key: "month",
|
|
458
|
+
title: "每月窗口",
|
|
459
|
+
used: monthUsed,
|
|
460
|
+
cap: monthCap,
|
|
461
|
+
resetAt: monthReset,
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
if (limitCards.length > 0) {
|
|
465
|
+
rows.push(React.createElement("div", { key: "limits", style: { padding: "14px 16px 4px" } },
|
|
466
|
+
React.createElement("div", { style: { fontSize: 11, fontWeight: 700, color: T.label3, letterSpacing: ".7px", textTransform: "uppercase", marginBottom: 10 } },
|
|
467
|
+
"窗口限额 Usage Limits"),
|
|
468
|
+
React.createElement("div", { style: { display: "grid", gap: 10 } }, limitCards)));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 底部: 账户 + 更新时间
|
|
472
|
+
rows.push(React.createElement("div", {
|
|
473
|
+
key: "foot",
|
|
474
|
+
style: {
|
|
475
|
+
display: "flex",
|
|
476
|
+
justifyContent: "space-between",
|
|
477
|
+
alignItems: "center",
|
|
478
|
+
padding: "12px 16px 14px",
|
|
479
|
+
marginTop: 6,
|
|
480
|
+
borderTop: `1px solid ${T.borderL}`,
|
|
481
|
+
fontSize: 11.5,
|
|
482
|
+
color: T.label3,
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
React.createElement("span", null, user ? user.name : ""),
|
|
486
|
+
React.createElement("span", null, data.fetchedAt ? fmtDate(data.fetchedAt) : "")));
|
|
487
|
+
|
|
488
|
+
return React.createElement("div", null, rows);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---- 主组件 -------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
function UsageBadge() {
|
|
494
|
+
const [readoutOpen, setReadoutOpen] = useState(false);
|
|
495
|
+
const [data, setData] = useState(null);
|
|
496
|
+
const [loading, setLoading] = useState(false);
|
|
497
|
+
const [error, setError] = useState(null);
|
|
498
|
+
const timerRef = useRef(null);
|
|
499
|
+
|
|
500
|
+
const refresh = useCallback(async () => {
|
|
501
|
+
const fullUrl = FETCH_URL;
|
|
502
|
+
// Command Code 的用量/订阅接口要 10~25 秒, 余额接口 1~5 秒。
|
|
503
|
+
// 先只取余额(带上次缓存的用量/订阅)让面板立刻出内容, 再拉完整数据覆盖。
|
|
504
|
+
const quickUrl = fullUrl + "?scope=quick";
|
|
505
|
+
|
|
506
|
+
const pull = (url) => fetch(url, { cache: "no-store" }).then((res) => {
|
|
507
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
508
|
+
return res.json();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
setLoading(true);
|
|
512
|
+
try {
|
|
513
|
+
const quick = await pull(quickUrl);
|
|
514
|
+
if (quick) {
|
|
515
|
+
setData(quick);
|
|
516
|
+
}
|
|
517
|
+
const full = await pull(fullUrl);
|
|
518
|
+
if (full) {
|
|
519
|
+
setData(full);
|
|
520
|
+
setError(full.ok === false && full.error ? full.error : null);
|
|
521
|
+
}
|
|
522
|
+
} catch (reason) {
|
|
523
|
+
setError(String(reason));
|
|
524
|
+
} finally {
|
|
525
|
+
setLoading(false);
|
|
526
|
+
}
|
|
527
|
+
}, []);
|
|
528
|
+
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
refresh();
|
|
531
|
+
// 每 3 分钟自动查询一次余额(面板打开时也刷新)
|
|
532
|
+
timerRef.current = setInterval(() => refresh(), REFRESH_MS);
|
|
533
|
+
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
|
534
|
+
}, [refresh]);
|
|
535
|
+
|
|
536
|
+
function pctOf(win) {
|
|
537
|
+
if (!win || typeof win.used !== "number" || typeof win.cap !== "number" || win.cap <= 0) return null;
|
|
538
|
+
return Math.round((win.used / win.cap) * 100);
|
|
539
|
+
}
|
|
540
|
+
const mainWindows = data && data.windowLimits ? data.windowLimits : null;
|
|
541
|
+
// 命名避开上面 ProjectionBody 里解构的同名 balance/usage (作用域不同, 但同名易混)
|
|
542
|
+
const balView = data && data.balance ? data.balance : null;
|
|
543
|
+
const usgView = data && data.usage ? data.usage : null;
|
|
544
|
+
|
|
545
|
+
// 月度占比: 本周期已用月度积分 / (已用 + 剩余月度积分)
|
|
546
|
+
const monthUsed = usgView && typeof usgView.totalMonthlyCredits === "number" ? usgView.totalMonthlyCredits : null;
|
|
547
|
+
const monthRemaining = balView && typeof balView.monthlyCredits === "number" ? balView.monthlyCredits : null;
|
|
548
|
+
const monthTotal = monthUsed !== null && monthRemaining !== null ? monthUsed + monthRemaining : null;
|
|
549
|
+
|
|
550
|
+
// 读数只报占比: 5 小时 / 周 / 月 (不显示金额)
|
|
551
|
+
const parts = [];
|
|
552
|
+
if (mainWindows && mainWindows.fiveHour) {
|
|
553
|
+
const p = pctOf(mainWindows.fiveHour);
|
|
554
|
+
if (p !== null) parts.push({ key: "5h", label: "5h " + p + "%" });
|
|
555
|
+
}
|
|
556
|
+
if (mainWindows && mainWindows.weekly) {
|
|
557
|
+
const p = pctOf(mainWindows.weekly);
|
|
558
|
+
if (p !== null) parts.push({ key: "wk", label: "周 " + p + "%" });
|
|
559
|
+
}
|
|
560
|
+
if (monthTotal !== null && monthTotal > 0 && monthUsed !== null) {
|
|
561
|
+
parts.push({ key: "mo", label: "月 " + Math.round((monthUsed / monthTotal) * 100) + "%" });
|
|
562
|
+
}
|
|
563
|
+
const readout = data
|
|
564
|
+
? (parts.length > 0 ? parts : [{ key: "wait", label: "额度查询中…" }])
|
|
565
|
+
: [{ key: "idle", label: "Command Code" }];
|
|
566
|
+
|
|
567
|
+
// 状态栏本体: 空心图标 + 三个占比读数。不加粗, 颜色沿用官方三级文字色。
|
|
568
|
+
const badgeButton = React.createElement("button", {
|
|
569
|
+
style: badgeStyle,
|
|
570
|
+
title: readoutOpen ? "收起 Command Code 额度占比" : "展开 Command Code 额度占比",
|
|
571
|
+
"aria-label": "Command Code 额度占比",
|
|
572
|
+
"aria-expanded": readoutOpen,
|
|
573
|
+
onClick: () => setReadoutOpen(!readoutOpen),
|
|
574
|
+
// 官方口径: hover / 展开时才浮出灰底并提亮文字(平时透明)
|
|
575
|
+
onMouseEnter: (e) => {
|
|
576
|
+
e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover)";
|
|
577
|
+
e.currentTarget.style.color = T.label2;
|
|
578
|
+
},
|
|
579
|
+
onMouseLeave: (e) => {
|
|
580
|
+
e.currentTarget.style.background = "transparent";
|
|
581
|
+
e.currentTarget.style.color = T.label3;
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
React.createElement(IconBolt),
|
|
585
|
+
readout.map((p, i) => React.createElement(React.Fragment, { key: p.key },
|
|
586
|
+
i > 0 ? React.createElement("span", { style: { color: T.label3, opacity: .45 } }, "·") : null,
|
|
587
|
+
React.createElement("span", null, p.label))));
|
|
588
|
+
|
|
589
|
+
const iconBtn = {
|
|
590
|
+
display: "flex",
|
|
591
|
+
alignItems: "center",
|
|
592
|
+
justifyContent: "center",
|
|
593
|
+
width: 26,
|
|
594
|
+
height: 26,
|
|
595
|
+
borderRadius: 8,
|
|
596
|
+
border: "none",
|
|
597
|
+
background: "transparent",
|
|
598
|
+
color: T.label3,
|
|
599
|
+
cursor: "pointer",
|
|
600
|
+
transition: "background .15s, color .15s",
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const iconBtnHover = {
|
|
604
|
+
background: "rgba(255,255,255,.1)",
|
|
605
|
+
color: T.label,
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
return React.createElement("div", { style: statusBarStyle },
|
|
609
|
+
badgeButton,
|
|
610
|
+
readoutOpen ? React.createElement("div", { style: { ...shellStyle, ...panelPosStyle } },
|
|
611
|
+
React.createElement("style", null, [
|
|
612
|
+
"@keyframes ccUsagePop{from{opacity:0;transform:translateY(8px) scale(.97)}to{opacity:1;transform:translateY(0) scale(1)}}",
|
|
613
|
+
"@keyframes ccUsageSpin{to{transform:rotate(360deg)}}",
|
|
614
|
+
].join("")),
|
|
615
|
+
React.createElement("div", {
|
|
616
|
+
style: {
|
|
617
|
+
display: "flex",
|
|
618
|
+
justifyContent: "space-between",
|
|
619
|
+
alignItems: "center",
|
|
620
|
+
padding: "10px 10px 10px 16px",
|
|
621
|
+
borderBottom: `1px solid ${T.borderL}`,
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
React.createElement("span", { style: { fontSize: 13, fontWeight: 700, display: "flex", alignItems: "center", gap: 8 } },
|
|
625
|
+
React.createElement("span", { style: { width: 8, height: 8, borderRadius: "50%", background: T.brand, boxShadow: `0 0 8px ${T.brand}` } }),
|
|
626
|
+
"Command Code"),
|
|
627
|
+
React.createElement("div", { style: { display: "flex", gap: 2 } },
|
|
628
|
+
React.createElement("button", {
|
|
629
|
+
style: iconBtn,
|
|
630
|
+
title: "刷新",
|
|
631
|
+
"aria-label": "刷新",
|
|
632
|
+
disabled: loading,
|
|
633
|
+
onClick: refresh,
|
|
634
|
+
onMouseEnter: (e) => Object.assign(e.currentTarget.style, iconBtnHover),
|
|
635
|
+
onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; },
|
|
636
|
+
}, React.createElement(IconRefresh)),
|
|
637
|
+
React.createElement("button", {
|
|
638
|
+
style: iconBtn,
|
|
639
|
+
title: "关闭",
|
|
640
|
+
"aria-label": "关闭",
|
|
641
|
+
onClick: () => setReadoutOpen(false),
|
|
642
|
+
onMouseEnter: (e) => Object.assign(e.currentTarget.style, iconBtnHover),
|
|
643
|
+
onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; },
|
|
644
|
+
}, React.createElement(IconClose)))),
|
|
645
|
+
React.createElement(DashboardBody, { data, loading, error })) : null);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---- apply -----------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
function apply(ctx) {
|
|
651
|
+
// 挂在输入框下方的 composer dock (官方推荐的"有分配空间"插槽),
|
|
652
|
+
// 而不是 shell.overlay 浮层 —— 这样额度是常驻状态栏, 不遮挡界面, 也不和右下角浮动球重叠。
|
|
653
|
+
//
|
|
654
|
+
// order 决定它在这一行里的次序: 官方 stats 药丸是 order 0, 这里用 -1 排在它前面
|
|
655
|
+
// (即整行最左)。改回 50 就排到官方那组后面。
|
|
656
|
+
ctx.slots.inject("conversation.composer.dock", () =>
|
|
657
|
+
ctx.slots.register({
|
|
658
|
+
name: "conversation.composer.dock",
|
|
659
|
+
id: "dsh-commandcode-quota-dashboard",
|
|
660
|
+
order: -1,
|
|
661
|
+
}, UsageBadge));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return { apply, inject: ["slots"] };
|
|
665
|
+
},
|
|
666
|
+
});
|