dsh-account-pool 0.1.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/lib/client.js ADDED
@@ -0,0 +1,2049 @@
1
+ // dsh-account-pool client bundle:设置页「WorkBuddy」。
2
+ //
3
+ // 手写 __ModuleLoader__ 工厂(无构建步骤)。唯一外部依赖是 react,
4
+ // 由 loader 的模块表提供。样式全部走宿主主题变量,自动跟随明暗主题。
5
+ window.__ModuleLoader__.load({ id: "dsh-account-pool", factory: (require) => {
6
+
7
+ var module = { exports: {} };
8
+ var exports = module.exports;
9
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
10
+ const react = require("react");
11
+ const h = react.createElement;
12
+ const { useState, useEffect, useCallback, useRef } = react;
13
+
14
+ const name = "account-pool";
15
+ const inject = ["slots"];
16
+
17
+ // 登录轮询间隔与上限:每 2 秒问一次,最多 5 分钟。
18
+ const POLL_INTERVAL_MS = 2000;
19
+
20
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000;
21
+
22
+ // ---------------------------------------------------------------------
23
+ // 样式(走宿主主题变量,不写死颜色)
24
+ // ---------------------------------------------------------------------
25
+
26
+ const CARD_STYLE = {
27
+ border: "1px solid var(--dsw-alias-border-l1)",
28
+ borderRadius: "8px",
29
+ padding: "14px 16px",
30
+ marginBottom: "12px",
31
+ background: "var(--dsw-alias-bg-layer-1)",
32
+ };
33
+
34
+ const BUTTON_STYLE = {
35
+ padding: "6px 14px",
36
+ borderRadius: "6px",
37
+ border: "1px solid var(--dsw-alias-border-l1)",
38
+ background: "transparent",
39
+ color: "var(--dsw-alias-label-primary)",
40
+ cursor: "pointer",
41
+ fontSize: "13px",
42
+ };
43
+
44
+ const PRIMARY_BUTTON_STYLE = Object.assign({}, BUTTON_STYLE, {
45
+ background: "var(--dsw-alias-brand-primary)",
46
+ borderColor: "var(--dsw-alias-brand-primary)",
47
+ color: "#fff",
48
+ });
49
+
50
+ const TABLE_STYLE = {
51
+ width: "100%",
52
+ borderCollapse: "collapse",
53
+ fontSize: "13px",
54
+ };
55
+
56
+ const TH_STYLE = {
57
+ textAlign: "left",
58
+ padding: "6px 8px",
59
+ borderBottom: "1px solid var(--dsw-alias-border-l1)",
60
+ color: "var(--dsw-alias-label-secondary)",
61
+ fontWeight: 500,
62
+ whiteSpace: "nowrap",
63
+ };
64
+
65
+ const TD_STYLE = {
66
+ padding: "7px 8px",
67
+ borderBottom: "1px solid var(--dsw-alias-border-l1)",
68
+ color: "var(--dsw-alias-label-primary)",
69
+ };
70
+
71
+ const INPUT_STYLE = {
72
+ padding: "6px 10px",
73
+ borderRadius: "6px",
74
+ border: "1px solid var(--dsw-alias-border-l1)",
75
+ background: "transparent",
76
+ color: "var(--dsw-alias-label-primary)",
77
+ fontSize: "13px",
78
+ minWidth: "260px",
79
+ };
80
+
81
+ // ---------------------------------------------------------------------
82
+ // 纯格式化函数(无副作用,便于复用)
83
+ // ---------------------------------------------------------------------
84
+
85
+ /** 时间戳 → 「还剩 1 小时 20 分」这种相对描述。 */
86
+ function formatRemaining(untilMs) {
87
+ if (!untilMs || untilMs <= Date.now()) return "已结束";
88
+ const seconds = Math.round((untilMs - Date.now()) / 1000);
89
+ // 不足一分钟时按秒显示:显示「0 分钟」会让人以为坏了
90
+ if (seconds < 60) return seconds + " 秒";
91
+ const minutes = Math.round(seconds / 60);
92
+ if (minutes < 60) return minutes + " 分钟";
93
+ const hours = Math.floor(minutes / 60);
94
+ const rest = minutes % 60;
95
+ return rest === 0 ? hours + " 小时" : hours + " 小时 " + rest + " 分";
96
+ }
97
+
98
+ /**
99
+ * 时间戳 → 本地日期时间。
100
+ *
101
+ * 必须带日期:凭证到期动辄几十天后,只显示「15:05:01」会被误读成今天。
102
+ * 同一天只显示时分,其余显示月-日 + 时分,兼顾紧凑与准确。
103
+ */
104
+ function formatClock(ms) {
105
+ if (!ms) return "—";
106
+ const d = new Date(ms);
107
+ const now = new Date();
108
+ const pad = (n) => String(n).padStart(2, "0");
109
+ const time = pad(d.getHours()) + ":" + pad(d.getMinutes());
110
+ const sameDay = d.getFullYear() === now.getFullYear()
111
+ && d.getMonth() === now.getMonth()
112
+ && d.getDate() === now.getDate();
113
+ if (sameDay) return "今天 " + time;
114
+ return (d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + time;
115
+ }
116
+
117
+ /** 成功率展示;没打过请求就给中性描述。 */
118
+ function formatRate(successCount, failCount) {
119
+ const total = (successCount || 0) + (failCount || 0);
120
+ if (total === 0) return "—";
121
+ return ((successCount / total) * 100).toFixed(1) + "%";
122
+ }
123
+
124
+ // ---------------------------------------------------------------------
125
+ // 状态判定:把散落字段折叠成单一状态标签
126
+ // 这是唯一的判定入口,避免各处各写一遍判断逻辑
127
+ // ---------------------------------------------------------------------
128
+
129
+ /**
130
+ * @returns {{label:string, color:string}} 状态标签与颜色变量
131
+ */
132
+ /**
133
+ * 账号状态标签。
134
+ *
135
+ * 用后端给的 stopReason 判定,不再靠「连续失败次数 >= 3」猜——
136
+ * 熔断阈值是可配置的,写死 3 会在阈值改成别的值时显示错。
137
+ *
138
+ * 文案要说清「为什么停」和「还有多久恢复」,不然用户只看到一个
139
+ * 数字不知道什么意思。
140
+ */
141
+ function statusOf(account) {
142
+ if (!account.cooling) {
143
+ return { label: "可用", color: "var(--dsw-alias-state-success-primary)" };
144
+ }
145
+ const left = formatRemaining(account.cooldownUntilMs);
146
+
147
+ // 冷却已结束但状态还没刷新:别说「剩 已结束」这种自相矛盾的话
148
+ if (left === "已结束") {
149
+ return { label: "恢复中", color: "var(--dsw-alias-state-warn-primary)" };
150
+ }
151
+
152
+ switch (account.stopReason) {
153
+ case "breaker":
154
+ return { label: "熔断 · " + left + "后重试", color: "var(--dsw-alias-state-error-primary)" };
155
+ case "hard_credit":
156
+ return { label: "积分耗尽 · " + left + "后重试", color: "var(--dsw-alias-state-error-primary)" };
157
+ case "soft_rate":
158
+ return { label: "限流 · " + left + "后重试", color: "var(--dsw-alias-state-warn-primary)" };
159
+ case "session_dead":
160
+ return { label: "登录失效 · " + left + "后重试", color: "var(--dsw-alias-state-warn-primary)" };
161
+ default:
162
+ return { label: "冷却 · " + left + "后重试", color: "var(--dsw-alias-state-warn-primary)" };
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------
167
+ // 展示组件(只吃 props,不自己取数据)
168
+ // ---------------------------------------------------------------------
169
+
170
+ function StatusBadge({ account }) {
171
+ const status = statusOf(account);
172
+ return h("span", { style: { color: status.color, fontSize: "12px", whiteSpace: "nowrap" } }, status.label);
173
+ }
174
+
175
+ /**
176
+ * 积分单元格:显示剩余;有总量数据时悬停可看「总量 / 已用 / 剩余」。
177
+ *
178
+ * 已用 = 总量 − 剩余(两家上游都不直接给「已用」的聚合值,
179
+ * 但总量和剩余都有,差值就是消耗进度)。
180
+ */
181
+ function creditCell(account) {
182
+ const hasCredits = typeof account.credits === "number";
183
+ const hasSize = typeof account.creditsSize === "number" && account.creditsSize > 0;
184
+ const value = hasCredits ? formatNumber(account.credits) : "—";
185
+ if (!hasSize || !hasCredits) return h("span", null, value);
186
+ // 上游数据偶尔会不一致(比如包过期被剔除后总量没同步),
187
+ // 剩余大于总量时按「无构成信息」处理,不显示误导性的负数或 >100%。
188
+ if (account.credits > account.creditsSize) return h("span", null, value);
189
+ const used = account.creditsSize - account.credits;
190
+ const percent = Math.round((account.credits / account.creditsSize) * 100);
191
+ return h("span", {
192
+ title: "总量 " + formatNumber(account.creditsSize)
193
+ + " · 已用 " + formatNumber(used)
194
+ + " · 剩余 " + formatNumber(account.credits)
195
+ + "(" + percent + "%)",
196
+ style: { cursor: "help" },
197
+ }, value);
198
+ }
199
+
200
+ function AccountRow({ account, region, onRevive, onRelogin, onRemove, busy }) {
201
+ return h("tr", null,
202
+ h("td", { style: TD_STYLE }, account.nickname || account.uid || account.id.slice(0, 8)),
203
+ h("td", { style: TD_STYLE }, h(StatusBadge, { account })),
204
+ // 积分 0 是合法值(真的用光了),不能像 undefined 那样显示 —;
205
+ // 用 typeof 区分「没数据」和「数据是 0」。
206
+ // 悬停展示「总量 / 已用 / 剩余」构成——只看剩余看不出消耗进度。
207
+ h("td", { style: Object.assign({}, TD_STYLE, NUM_STYLE) },
208
+ creditCell(account)),
209
+ h("td", { style: TD_STYLE }, formatRate(account.successCount, account.failCount)),
210
+ h("td", { style: TD_STYLE }, formatClock(account.expiresAtMs)),
211
+ h("td", { style: Object.assign({}, TD_STYLE, { whiteSpace: "nowrap" }) },
212
+ // 按状态给对应操作:登录失效的账号「解冻」帮不上忙,
213
+ // 它真正需要的是重新走一遍 OAuth 换新凭证。
214
+ account.stopReason === "session_dead"
215
+ ? h("button", {
216
+ style: Object.assign({}, PRIMARY_BUTTON_STYLE, { padding: "3px 9px", fontSize: "12px", marginRight: "6px", color: "#fff" }),
217
+ disabled: busy,
218
+ onClick: () => onRelogin(region),
219
+ title: "凭证已失效,需要重新登录换新凭证",
220
+ }, "重新登录")
221
+ : h("button", {
222
+ style: Object.assign({}, BUTTON_STYLE, {
223
+ padding: "3px 9px", fontSize: "12px", marginRight: "6px",
224
+ // 可用账号没有需要解冻的东西,禁用而不是隐藏——
225
+ // 保持按钮位置稳定,避免整行跳动。
226
+ opacity: account.cooling ? 1 : 0.4,
227
+ }),
228
+ disabled: busy || !account.cooling,
229
+ onClick: () => onRevive(region, account.id),
230
+ title: account.cooling
231
+ ? "立刻清除该账号的冷却与失败计数,让它马上重新参与选号"
232
+ : "账号当前可用,无需解冻",
233
+ }, "解冻"),
234
+ h("button", {
235
+ style: Object.assign({}, BUTTON_STYLE, { padding: "3px 9px", fontSize: "12px" }),
236
+ disabled: busy,
237
+ onClick: () => onRemove(region, account.id),
238
+ title: "从账号池删除,并删掉本机凭证文件(不可恢复)",
239
+ }, "移除"),
240
+ ),
241
+ );
242
+ }
243
+
244
+ function RegionPanel({ region, data, onRefresh, onRefreshCredits, onAddAccount, onImport, onTraeLogin, onRevive, onRemove, busy, notice }) {
245
+ // onAddAccount 同时充当「重新登录」:都是走同一套 OAuth 设备流。
246
+ const accounts = data.accounts || [];
247
+ return h("div", { style: CARD_STYLE },
248
+ h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" } },
249
+ h("div", null,
250
+ h("span", { style: { fontWeight: 600, fontSize: "14px" } }, data.displayName || region),
251
+ h("span", { style: { marginLeft: "10px", fontSize: "12px", opacity: 0.7 } },
252
+ (data.models || 0) + " 个模型"),
253
+ ),
254
+ h("div", { style: { display: "flex", gap: "8px", alignItems: "center" } },
255
+ // Trae 的凭证格式与 WorkBuddy 完全不同(Cloud-IDE-JWT + 设备标识),
256
+ // Trae 的凭证在桌面端的 storage.json 里(加密),
257
+ // 所以走「导入凭证」而不是网页登录。
258
+ region === "trae"
259
+ ? h("button", {
260
+ style: Object.assign({}, PRIMARY_BUTTON_STYLE, { opacity: busy ? 0.6 : 1 }),
261
+ disabled: busy,
262
+ onClick: onTraeLogin,
263
+ }, "导入凭证")
264
+ : h("button", {
265
+ // 导入用主色突出:已有凭证的人第一步就是它,比「添加账号」更常用。
266
+ style: Object.assign({}, PRIMARY_BUTTON_STYLE, { opacity: busy ? 0.6 : 1 }),
267
+ disabled: busy,
268
+ onClick: () => onImport(region),
269
+ }, "导入"),
270
+ region === "trae"
271
+ ? null
272
+ : h("button", {
273
+ style: BUTTON_STYLE,
274
+ disabled: busy,
275
+ onClick: () => onAddAccount(region),
276
+ }, "登录"),
277
+ h("button", {
278
+ style: BUTTON_STYLE,
279
+ disabled: busy,
280
+ onClick: () => onRefreshCredits(region),
281
+ }, "刷新积分"),
282
+ h("button", {
283
+ style: BUTTON_STYLE,
284
+ disabled: busy,
285
+ onClick: () => onRefresh(region),
286
+ }, "刷新模型"),
287
+ ),
288
+ ),
289
+
290
+ notice ? h("p", {
291
+ style: {
292
+ margin: "0 0 10px 0",
293
+ fontSize: "12px",
294
+ color: notice.kind === "error" ? "var(--dsw-alias-state-error-primary)" : "var(--dsw-alias-state-success-primary)",
295
+ },
296
+ }, notice.text) : null,
297
+
298
+ accounts.length === 0
299
+ ? h("p", { style: { margin: 0, fontSize: "13px", opacity: 0.7 } },
300
+ region === "trae"
301
+ ? "还没有 Trae 账号。点「导入凭证」,把 Trae 桌面端的 storage.json 内容粘过来。"
302
+ : "还没有账号。点「登录」用浏览器完成授权,或点「导入」粘贴已有凭证。")
303
+ : h("table", { style: TABLE_STYLE },
304
+ h("thead", null, h("tr", null,
305
+ h("th", { style: TH_STYLE }, "账号"),
306
+ h("th", { style: TH_STYLE }, "状态"),
307
+ h("th", { style: TH_STYLE }, "积分"),
308
+ h("th", { style: TH_STYLE }, "成功率"),
309
+ h("th", { style: TH_STYLE }, "凭证到期"),
310
+ h("th", { style: TH_STYLE }, "操作"),
311
+ )),
312
+ h("tbody", null, accounts.map(account =>
313
+ h(AccountRow, {
314
+ key: account.id,
315
+ account,
316
+ region,
317
+ busy,
318
+ onRevive,
319
+ onRelogin: onAddAccount,
320
+ onRemove,
321
+ })
322
+ )),
323
+ ),
324
+ );
325
+ }
326
+
327
+ /** 大数字加千位分隔;token 量级动辄百万,不分隔读不出来。 */
328
+ function formatNumber(n) {
329
+ if (!n) return "0";
330
+ return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
331
+ }
332
+
333
+ /**
334
+ * 大数字紧凑显示:1234567 → 1.23M。
335
+ * 指标卡上空间有限,完整千分位会撑破布局;完整值放到 title 里。
336
+ */
337
+ function formatCompact(n) {
338
+ const v = Number(n) || 0;
339
+ if (v < 1000) return String(v);
340
+ if (v < 1e6) return (v / 1e3).toFixed(v < 1e4 ? 1 : 0) + "K";
341
+ if (v < 1e9) return (v / 1e6).toFixed(v < 1e7 ? 2 : 1) + "M";
342
+ return (v / 1e9).toFixed(2) + "B";
343
+ }
344
+
345
+ /** 等宽数字:表格里数字列对齐才读得下去。 */
346
+ const NUM_STYLE = {
347
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
348
+ fontVariantNumeric: "tabular-nums",
349
+ };
350
+
351
+ /** 字节数 → 人类可读。用量文件通常几十 KB,超过 1MB 就该关注了。 */
352
+ function formatBytes(n) {
353
+ if (!n) return "0 B";
354
+ if (n < 1024) return n + " B";
355
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
356
+ return (n / 1024 / 1024).toFixed(2) + " MB";
357
+ }
358
+
359
+ /** 延迟/速率取整展示。 */
360
+ function formatMs(ms) {
361
+ if (!ms) return "—";
362
+ return Math.round(ms) + " ms";
363
+ }
364
+
365
+ /** 页签切换条。 */
366
+ function TabBar({ tabs, active, onChange }) {
367
+ return h("div", {
368
+ style: {
369
+ display: "flex", gap: "4px", marginBottom: "14px",
370
+ borderBottom: "1px solid var(--dsw-alias-border-l1)",
371
+ },
372
+ }, tabs.map(tab => h("button", {
373
+ key: tab.id,
374
+ style: {
375
+ padding: "7px 16px",
376
+ border: "none",
377
+ borderBottom: active === tab.id
378
+ ? "2px solid var(--dsw-alias-brand-primary)"
379
+ : "2px solid transparent",
380
+ background: "transparent",
381
+ color: "var(--dsw-alias-label-primary)",
382
+ opacity: active === tab.id ? 1 : 0.6,
383
+ cursor: "pointer",
384
+ fontSize: "13px",
385
+ fontWeight: active === tab.id ? 600 : 400,
386
+ marginBottom: "-1px",
387
+ },
388
+ onClick: () => onChange(tab.id),
389
+ }, tab.label)));
390
+ }
391
+
392
+ /**
393
+ * 一个任务的完成情况:已完成的账号数 / 总账号数。
394
+ * 「全部完成」时数字用成功色,部分完成用警告色。
395
+ */
396
+ function TaskProgressCell({ done, total }) {
397
+ if (total === 0) return h("span", { style: { opacity: 0.5 } }, "—");
398
+ const all = done === total;
399
+ return h("span", {
400
+ style: {
401
+ fontWeight: 600,
402
+ color: all
403
+ ? "var(--dsw-alias-state-success-primary)"
404
+ : done > 0
405
+ ? "var(--dsw-alias-state-warn-primary)"
406
+ : "var(--dsw-alias-label-secondary)",
407
+ },
408
+ }, done + "/" + total, all ? h("span", { style: { marginLeft: "6px", fontSize: "11px", opacity: 0.8 } }, "已完成") : null);
409
+ }
410
+
411
+ /**
412
+ * 每日任务视图:先列任务清单与完成数量,再列各账号明细。
413
+ *
414
+ * 完成情况怎么来的:
415
+ * - 只读字段能判断的(旅行 daily_limit_reached、抽奖次数、连登天数)直接算
416
+ * - 签到没有只读接口,用**上次执行结果**;从没执行过就显示「未执行」
417
+ * (打开页面不会偷偷签到——执行是你点按钮触发的)
418
+ */
419
+ function DailyTasksView({ region, data, busy, setBusy, setNotice, notice, lastRun, reload, onRunDone }) {
420
+ const accounts = data.accounts || [];
421
+ const total = accounts.length;
422
+
423
+ // 每个任务的完成数
424
+ const count = (predicate) => accounts.filter(predicate).length;
425
+
426
+ // 签到:来自上次执行结果(无只读接口)
427
+ const checkinResults = lastRun
428
+ ? accounts.filter(a => {
429
+ const r = lastRun[a.accountId];
430
+ return r && r.checkin && r.checkin.status !== 'failed';
431
+ }).length
432
+ : undefined;
433
+
434
+ // 旅行:daily_limit_reached 或已在途,都算今天做过了
435
+ const travelDone = count(a => a.travel && (a.travel.dailyLimitReached || a.travel.state === 'traveling'));
436
+
437
+ // 连登兑换:用上游给的 redeemable(已解锁档位)判定,不自己比天数。
438
+ // 「完成」的含义是「该账号当前没有待兑换的东西」。
439
+ const redeemPending = accounts.filter(a => (a.streak?.redeemable?.length ?? 0) > 0).length;
440
+ const redeemDone = total - redeemPending;
441
+
442
+ // 抽奖:有次数就是待办
443
+ const lotteryPending = count(a => a.lottery && a.lottery.chances > 0);
444
+ const lotteryDone = total - lotteryPending;
445
+
446
+ /** 任务清单的一行。 */
447
+ const taskRow = (name, done, todo, note) => h("tr", { key: name },
448
+ h("td", { style: TD_STYLE }, name),
449
+ h("td", { style: TD_STYLE },
450
+ done === undefined
451
+ ? h("span", { style: { opacity: 0.5 } }, "未执行")
452
+ : h(TaskProgressCell, { done, total })),
453
+ h("td", { style: Object.assign({}, TD_STYLE, { fontSize: "12px", opacity: 0.7 }) }, note || ""),
454
+ );
455
+
456
+ return h("div", null,
457
+ h("div", { style: CARD_STYLE },
458
+ h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" } },
459
+ h("div", null,
460
+ h("span", { style: { fontWeight: 600, fontSize: "14px" } }, "每日任务"),
461
+ h("span", { style: { marginLeft: "10px", fontSize: "12px", opacity: 0.7 } },
462
+ total + " 个账号"),
463
+ ),
464
+ h("button", {
465
+ style: Object.assign({}, PRIMARY_BUTTON_STYLE, { opacity: busy ? 0.6 : 1 }),
466
+ disabled: busy || total === 0,
467
+ onClick: async () => {
468
+ setBusy(true);
469
+ try {
470
+ const res = await fetch("/account-pool/tasks/daily", {
471
+ method: "POST",
472
+ headers: { "content-type": "application/json" },
473
+ body: JSON.stringify({ region }),
474
+ });
475
+ const out = await res.json();
476
+ if (!out.ok) throw new Error(out.error || "执行失败");
477
+ // 保存执行结果:签到没有只读接口,清单里的完成数靠它。
478
+ // 注意 setLastRun 属于主组件,这里只能通过 props 回调,
479
+ // 直接调用会 ReferenceError(跨作用域)。
480
+ const map = {};
481
+ for (const r of out.results || []) map[r.accountId] = r;
482
+ onRunDone(map);
483
+ setNotice(region, "ok", "已完成 " + (out.results || []).length + " 个账号的每日任务");
484
+ // 重新拉只读状态,让旅行/抽奖/连登的完成数同步更新
485
+ await reload();
486
+ } catch (error) {
487
+ setNotice(region, "error", error.message);
488
+ } finally {
489
+ setBusy(false);
490
+ }
491
+ },
492
+ }, busy ? "执行中…" : "全部执行"),
493
+ ),
494
+
495
+ notice ? h("p", {
496
+ style: {
497
+ margin: "0 0 10px 0", fontSize: "12px",
498
+ color: notice.kind === "error"
499
+ ? "var(--dsw-alias-state-error-primary)"
500
+ : "var(--dsw-alias-state-success-primary)",
501
+ },
502
+ }, notice.text) : null,
503
+
504
+ h("table", { style: TABLE_STYLE },
505
+ h("thead", null, h("tr", null,
506
+ h("th", { style: TH_STYLE }, "任务"),
507
+ h("th", { style: TH_STYLE }, "完成情况"),
508
+ h("th", { style: TH_STYLE }, "说明"),
509
+ )),
510
+ h("tbody", null,
511
+ taskRow("签到", checkinResults, "每天一次,幂等", "无只读接口,需执行后才有数据"),
512
+ taskRow("猫猫旅行", travelDone, "到站领奖 · 空闲派出", "今日已派或在途都算完成"),
513
+ taskRow("连登兑换", redeemDone, "满 7/14/28 天可兑换", redeemPending > 0 ? redeemPending + " 个账号有可兑换档位" : "没有待兑换的档位"),
514
+ taskRow("抽奖", lotteryDone, "次数来自连登兑换", lotteryPending > 0 ? lotteryPending + " 个账号有抽奖次数" : "暂无抽奖次数"),
515
+ ),
516
+ ),
517
+
518
+ h("p", { style: { margin: "10px 0 0 0", fontSize: "12px", opacity: 0.6 } },
519
+ "打开本页只做只读查询,不会替你签到或派出;点「全部执行」才会真正执行。"),
520
+ ),
521
+
522
+ // ---- 各账号明细 ----
523
+ h("div", { style: CARD_STYLE },
524
+ h("div", { style: { fontWeight: 600, fontSize: "14px", marginBottom: "10px" } }, "账号明细"),
525
+ total === 0
526
+ ? h("p", { style: { margin: 0, fontSize: "13px", opacity: 0.7 } }, "还没有账号。")
527
+ : h("table", { style: TABLE_STYLE },
528
+ h("thead", null, h("tr", null,
529
+ h("th", { style: TH_STYLE }, "账号"),
530
+ h("th", { style: TH_STYLE }, "连续登录"),
531
+ h("th", { style: TH_STYLE }, "猫"),
532
+ h("th", { style: TH_STYLE }, "旅行"),
533
+ h("th", { style: TH_STYLE }, "抽奖次数"),
534
+ )),
535
+ h("tbody", null, accounts.map(a => h("tr", { key: a.accountId },
536
+ h("td", { style: TD_STYLE }, a.nickname || a.accountId?.slice(0, 8) || "—"),
537
+ h("td", { style: TD_STYLE },
538
+ a.streak
539
+ ? h("span", null, formatNumber(a.streak.days) + " 天",
540
+ a.streak.nextTier
541
+ ? h("span", { style: { opacity: 0.6, marginLeft: "6px", fontSize: "12px" } },
542
+ "距 " + a.streak.nextTier + " 还差 " + a.streak.nextTierRemaining + " 天")
543
+ : null)
544
+ : h("span", { style: { opacity: 0.5 } }, "—")),
545
+ h("td", { style: TD_STYLE },
546
+ a.buddy
547
+ ? h("span", null, a.buddy.name, h("span", { style: { opacity: 0.6, marginLeft: "5px" } }, a.buddy.rarity))
548
+ : h("span", { style: { opacity: 0.5 } }, "未领养")),
549
+ h("td", { style: TD_STYLE },
550
+ a.travel
551
+ ? h("span", { style: { color: a.travel.dailyLimitReached ? "var(--dsw-alias-state-success-primary)" : "inherit" } },
552
+ a.travel.state === 'traveling' ? "在途中"
553
+ : a.travel.state === 'arrived' ? "可领奖"
554
+ : a.travel.dailyLimitReached ? "今日已派"
555
+ : "空闲")
556
+ : h("span", { style: { opacity: 0.5 } }, "—")),
557
+ h("td", { style: TD_STYLE },
558
+ a.lottery !== undefined ? String(a.lottery.chances) : h("span", { style: { opacity: 0.5 } }, "—")),
559
+ ))),
560
+ ),
561
+ ),
562
+ );
563
+ }
564
+
565
+ /**
566
+ * 把后端的时间片键转成给人看的文字。
567
+ *
568
+ * 后端键的格式(存储用,保持原样不动):
569
+ * 小时 2026-09-18T14
570
+ * 日 2026-06-01
571
+ *
572
+ * 显示上做两点处理:
573
+ * 1. 去掉年份——用量通常只看最近几天,年份是噪音;
574
+ * 但跨年时会补回,避免 12-31 和 01-01 分不清哪年在先。
575
+ * 2. 小时补上「:00」,让它一眼看出是时刻而不是日期。
576
+ *
577
+ * @param {string} key 原始时间片键
578
+ * @param {string} kind 'hour' | 'day'
579
+ * @param {boolean} withYear 是否强制带年份
580
+ */
581
+ /**
582
+ * Trae 的每日任务视图。
583
+ *
584
+ * 比 WorkBuddy 简单:只有签到与积分,没有旅行 / 连登兑换 / 抽奖。
585
+ * 而且 Trae **有只读的签到状态接口**,所以完成数直接来自查询,
586
+ * 不像 WorkBuddy 那样要靠「上次执行结果」推断。
587
+ */
588
+ /**
589
+ * 签到单元格:区分「本账号已签」和「设备已签(别的账号签的)」。
590
+ *
591
+ * Trae 的签到按**设备**计数。同一台机器上的多个账号共享 deviceId,
592
+ * 所以第二个账号永远会看到 checked_in=false,但它其实"今天已经签过了"。
593
+ * 直接显示「未签到」会让人以为漏签、反复点执行。
594
+ *
595
+ * 三态:
596
+ * 已签到 本账号领到了(checked_in=true)
597
+ * 设备已签到 本账号没领,但同设备今天签过(did_checked_in=true)
598
+ * 未签到 都没签,可以执行
599
+ */
600
+ function checkinCell(account) {
601
+ if (account.checkinError) {
602
+ return h("span", { style: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px" } }, "查询失败");
603
+ }
604
+ const ci = account.checkin;
605
+ if (!ci) return h("span", { style: { opacity: 0.5 } }, "—");
606
+ if (ci.checkedIn) {
607
+ return h("span", { style: { color: "var(--dsw-alias-state-success-primary)" } }, "已签到");
608
+ }
609
+ if (ci.deviceCheckedIn) {
610
+ return h("span", {
611
+ title: "该设备今天已由同设备的账号签到;Trae 的签到按设备计数,换设备登录才能各签各的",
612
+ style: { color: "var(--dsw-alias-state-warn-primary)", cursor: "help" },
613
+ }, "设备已签到");
614
+ }
615
+ return h("span", { style: { opacity: 0.7 } }, "未签到");
616
+ }
617
+
618
+ function TraeTasksView({ region, data, busy, setBusy, setNotice, notice, reload, onRunDone }) {
619
+ const accounts = data.accounts || [];
620
+ const total = accounts.length;
621
+
622
+ // 签到:直接用只读状态里的 checked_in
623
+ // 完成数把「设备已签到」也算进来:Trae 按设备计数,同设备的第二个
624
+ // 账号拿不到该状态,但今天这件事已经发生过了,不该算未完成。
625
+ const checkedIn = accounts.filter(a => a.checkin
626
+ && (a.checkin.checkedIn === true || a.checkin.deviceCheckedIn === true)).length;
627
+ // 积分:能查到就算「已获取」
628
+ const withCredits = accounts.filter(a => typeof a.credits === "number").length;
629
+
630
+ const sumCredits = accounts.reduce((sum, a) => sum + (typeof a.credits === "number" ? a.credits : 0), 0);
631
+
632
+ const taskRow = (name, done, note) => h("tr", { key: name },
633
+ h("td", { style: TD_STYLE }, name),
634
+ h("td", { style: TD_STYLE },
635
+ total === 0
636
+ ? h("span", { style: { opacity: 0.5 } }, "—")
637
+ : h(TaskProgressCell, { done, total })),
638
+ h("td", { style: Object.assign({}, TD_STYLE, { fontSize: "12px", opacity: 0.7 }) }, note || ""),
639
+ );
640
+
641
+ return h("div", null,
642
+ h("div", { style: CARD_STYLE },
643
+ h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" } },
644
+ h("div", null,
645
+ h("span", { style: { fontWeight: 600, fontSize: "14px" } }, "每日任务"),
646
+ h("span", { style: { marginLeft: "10px", fontSize: "12px", opacity: 0.7 } },
647
+ total + " 个账号"),
648
+ ),
649
+ h("button", {
650
+ style: Object.assign({}, PRIMARY_BUTTON_STYLE, { opacity: busy ? 0.6 : 1 }),
651
+ disabled: busy || total === 0,
652
+ onClick: async () => {
653
+ setBusy(true);
654
+ try {
655
+ const res = await fetch("/account-pool/tasks/daily", {
656
+ method: "POST",
657
+ headers: { "content-type": "application/json" },
658
+ body: JSON.stringify({ region }),
659
+ });
660
+ const out = await res.json();
661
+ if (!out.ok) throw new Error(out.error || "执行失败");
662
+ const map = {};
663
+ for (const r of out.results || []) map[r.accountId] = r;
664
+ onRunDone(map);
665
+ setNotice(region, "ok", "已完成 " + (out.results || []).length + " 个账号的签到");
666
+ await reload();
667
+ } catch (error) {
668
+ setNotice(region, "error", error.message);
669
+ } finally {
670
+ setBusy(false);
671
+ }
672
+ },
673
+ }, busy ? "执行中…" : "全部执行"),
674
+ ),
675
+
676
+ notice ? h("p", {
677
+ style: {
678
+ margin: "0 0 10px 0", fontSize: "12px",
679
+ color: notice.kind === "error"
680
+ ? "var(--dsw-alias-state-error-primary)"
681
+ : "var(--dsw-alias-state-success-primary)",
682
+ },
683
+ }, notice.text) : null,
684
+
685
+ h("table", { style: TABLE_STYLE },
686
+ h("thead", null, h("tr", null,
687
+ h("th", { style: TH_STYLE }, "任务"),
688
+ h("th", { style: TH_STYLE }, "完成情况"),
689
+ h("th", { style: TH_STYLE }, "说明"),
690
+ )),
691
+ h("tbody", null,
692
+ taskRow("每日签到", checkedIn, "每天一次,先查状态再领取,不会重复领"),
693
+ taskRow("积分查询", withCredits, total > 0 ? "当前合计 " + formatNumber(sumCredits) + " 积分" : "只读查询,不消耗额度"),
694
+ ),
695
+ ),
696
+ ),
697
+
698
+ // 各账号明细
699
+ total > 0
700
+ ? h("div", { style: CARD_STYLE },
701
+ h("div", { style: { fontWeight: 600, fontSize: "14px", marginBottom: "10px" } }, "账号明细"),
702
+ h("table", { style: TABLE_STYLE },
703
+ h("thead", null, h("tr", null,
704
+ h("th", { style: TH_STYLE }, "账号"),
705
+ h("th", { style: TH_STYLE }, "今日签到"),
706
+ h("th", { style: TH_STYLE }, "积分"),
707
+ )),
708
+ h("tbody", null, accounts.map(a => h("tr", { key: a.accountId },
709
+ h("td", { style: TD_STYLE }, a.nickname || String(a.accountId).slice(0, 8)),
710
+ h("td", { style: TD_STYLE }, checkinCell(a)),
711
+ h("td", { style: Object.assign({}, TD_STYLE, NUM_STYLE) },
712
+ a.creditsError
713
+ ? "—"
714
+ : typeof a.credits === "number" ? formatNumber(a.credits) : "—"),
715
+ ))),
716
+ ),
717
+ )
718
+ : null,
719
+ );
720
+ }
721
+
722
+ function formatTimeKey(key, kind, withYear = false) {
723
+ const raw = String(key ?? "");
724
+ // 日粒度:2026-06-01
725
+ if (kind === "day") {
726
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
727
+ if (m === null) return raw;
728
+ return withYear ? `${m[1]}-${m[2]}-${m[3]}` : `${m[2]}-${m[3]}`;
729
+ }
730
+ // 小时粒度:2026-09-18T14
731
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})$/.exec(raw);
732
+ if (m === null) return raw;
733
+ return (withYear ? `${m[1]}-` : "") + `${m[2]}-${m[3]} ${m[4]}:00`;
734
+ }
735
+
736
+ /**
737
+ * 时间跨度是否跨年。跨年时两端要显示年份,
738
+ * 否则 12-31 和 01-01 摆在一起分不清先后。
739
+ */
740
+ function spansYear(points) {
741
+ if (points.length < 2) return false;
742
+ const first = String(points[0]?.t ?? "").slice(0, 4);
743
+ const last = String(points[points.length - 1]?.t ?? "").slice(0, 4);
744
+ return first !== "" && last !== "" && first !== last;
745
+ }
746
+
747
+ /**
748
+ * 迷你趋势图:按时间聚合的柱状图。
749
+ *
750
+ * series 是「每(时间片, 账号, 模型)一条」,画图前必须先按时间片合并,
751
+ * 否则同一时刻会有多根柱子。
752
+ *
753
+ * 零依赖实现:不用图表库(插件要保持无第三方运行时依赖),
754
+ * 用 div 高度模拟柱子,配合 title 提供悬停数值。
755
+ */
756
+ function UsageTrend({ series, height = 56 }) {
757
+ if (!Array.isArray(series) || series.length === 0) return null;
758
+
759
+ // 按时间片合并(同一个 t 可能有多条:不同账号/模型)
760
+ const byTime = new Map();
761
+ for (const point of series) {
762
+ // kind 必须一起带过来:漏了会让日粒度的数据被当成小时显示
763
+ // (「2 天有记录」会错写成「2 个小时有记录」)。
764
+ const slot = byTime.get(point.t)
765
+ ?? { t: point.t, kind: point.kind, requests: 0, errors: 0, totalTokens: 0 };
766
+ slot.requests += point.requests || 0;
767
+ slot.errors += point.errors || 0;
768
+ slot.totalTokens += point.totalTokens || 0;
769
+ byTime.set(point.t, slot);
770
+ }
771
+ const points = [...byTime.values()].sort((a, b) => a.t.localeCompare(b.t));
772
+
773
+ // 只显示最近 N 个点,太多柱子会挤成一团
774
+ const MAX_BARS = 48;
775
+ const shown = points.slice(-MAX_BARS);
776
+ const peak = Math.max(...shown.map(p => p.requests), 1);
777
+ // 是否有失败:决定要不要显示图例。没有失败时不显示,避免无意义噪音。
778
+ const hasAnyError = shown.some(p => (p.errors || 0) > 0);
779
+
780
+ return h("div", { style: { marginBottom: "14px" } },
781
+ h("div", {
782
+ style: {
783
+ display: "flex", justifyContent: "space-between",
784
+ fontSize: "11px", color: "var(--dsw-alias-label-secondary)",
785
+ marginBottom: "6px",
786
+ },
787
+ },
788
+ h("span", { style: { display: "flex", alignItems: "center", gap: "10px" } },
789
+ h("span", null, "请求趋势"),
790
+ // 图例:技能要求「信息不能只靠颜色传达」,
791
+ // 所以红色叠加段必须有文字说明它是什么。
792
+ hasAnyError
793
+ ? h("span", { style: { display: "inline-flex", alignItems: "center", gap: "4px" } },
794
+ h("span", {
795
+ style: {
796
+ width: "7px", height: "7px", borderRadius: "1px",
797
+ background: "var(--wb-chart-error, #f25a5a)",
798
+ display: "inline-block",
799
+ },
800
+ }),
801
+ "失败")
802
+ : null,
803
+ ),
804
+ h("span", null,
805
+ // 不用「时间片」这个实现术语(后端按 scope 分桶)。
806
+ // 粒度会变:近期按小时、超 90 天折叠成按天,两种文案分开写,
807
+ // 否则「2 个天有记录」这种量词错误会露出来。
808
+ shown[0]?.kind === 'day'
809
+ ? shown.length + " 天有记录 · 单日最高 " + formatNumber(peak) + " 次"
810
+ : shown.length + " 小时有记录 · 单小时最高 " + formatNumber(peak) + " 次"),
811
+ ),
812
+ h("div", {
813
+ style: {
814
+ display: "flex", alignItems: "flex-end", gap: "2px",
815
+ height: height + "px",
816
+ padding: "0 1px",
817
+ borderBottom: "1px solid var(--dsw-alias-border-l1)",
818
+ },
819
+ }, shown.map((point, i) => {
820
+ // 最低给 2px,否则 0 请求的柱子看不见,会以为数据缺失
821
+ const barHeight = Math.max(Math.round((point.requests / peak) * height), 2);
822
+ const hasError = (point.errors || 0) > 0;
823
+ // 失败占比决定红色叠加段的高度。
824
+ // 不给整根柱子标红:一个时段常是几十次成功里夹一两次失败,
825
+ // 整根变红会把小问题放大成「整体异常」。
826
+ const errorRatio = point.requests > 0 ? (point.errors || 0) / point.requests : 0;
827
+ const errorHeight = hasError
828
+ ? Math.max(Math.round(barHeight * errorRatio), 2)
829
+ : 0;
830
+
831
+ return h("div", {
832
+ key: point.t + i,
833
+ title: formatTimeKey(point.t, point.kind, true)
834
+ + (point.kind === 'day' ? "(整天)" : " 这一小时")
835
+ + "\n请求 " + formatNumber(point.requests) + " 次"
836
+ + (hasError ? "\n失败 " + formatNumber(point.errors) + " 次("
837
+ + (errorRatio * 100).toFixed(0) + "%)" : "")
838
+ + "\n总计 " + formatNumber(point.totalTokens) + " tokens",
839
+ style: {
840
+ flex: "1 1 0",
841
+ minWidth: "2px",
842
+ height: barHeight + "px",
843
+ borderRadius: "2px 2px 0 0",
844
+ // 兜底色:万一 styles 注入失败(变量未定义),var() 会失效
845
+ // 导致柱子透明看不见。带兜底值就永远有颜色。
846
+ background: "var(--wb-chart-bar, #3b82f6)",
847
+ opacity: 0.9,
848
+ // 相对定位:红色叠加段贴在柱子顶部
849
+ position: "relative",
850
+ overflow: "hidden",
851
+ },
852
+ },
853
+ // 失败段:柱子顶部的一小截,高度与失败占比成正比。
854
+ // 这样「有失败」仍能一眼看出,但不会被误读成整体故障。
855
+ errorHeight > 0
856
+ ? h("div", {
857
+ style: {
858
+ position: "absolute",
859
+ top: 0, left: 0, right: 0,
860
+ height: errorHeight + "px",
861
+ background: "var(--wb-chart-error, #f25a5a)",
862
+ },
863
+ })
864
+ : null,
865
+ );
866
+ })),
867
+ // 横轴两端的时间标签:左早右晚,明确方向避免误读成倒序
868
+ h("div", {
869
+ style: {
870
+ display: "flex", justifyContent: "space-between",
871
+ fontSize: "10px", color: "var(--dsw-alias-label-secondary)",
872
+ marginTop: "4px", opacity: 0.8,
873
+ },
874
+ },
875
+ h("span", null, formatTimeKey(shown[0]?.t, shown[0]?.kind, spansYear(shown)) + "(早)"),
876
+ shown.length > 1
877
+ ? h("span", null,
878
+ formatTimeKey(shown[shown.length - 1].t, shown[shown.length - 1].kind, spansYear(shown))
879
+ + "(近)")
880
+ : null,
881
+ ),
882
+ );
883
+ }
884
+
885
+ /**
886
+ * 用量统计卡片。
887
+ *
888
+ * 设计取舍(参考 ui-ux-pro-max 的 Data-Dense Dashboard 风格):
889
+ * - 主次分层:4 个核心指标用大号数字,3 个次要指标缩小淡化。
890
+ * 平铺 7 个等大卡片会让人抓不到重点。
891
+ * - 数字等宽:表格与指标用 tabular-nums,位数变了也不会跳动。
892
+ * - 紧凑格式:卡片上用 1.23M,完整值放 title,鼠标悬停可看。
893
+ * - 表格横向滚动:窄屏不破版(技能明确要求的 table 处理方式)。
894
+ * - 颜色全部走宿主 token,自动跟随明暗主题。
895
+ */
896
+ function UsageCard({ usage, onRefresh, busy }) {
897
+ if (!usage) return null;
898
+ const t = usage.totals || {};
899
+ const models = usage.byModel || [];
900
+ const totalCalls = t.requests || 0;
901
+ const failRate = totalCalls > 0 ? (t.errors || 0) / totalCalls : 0;
902
+
903
+ /** 核心指标块:大号数字 + 小标签。 */
904
+ const kpi = (label, value, opts = {}) => h("div", {
905
+ style: {
906
+ // 宽度完全由父级网格决定,自己不设 minWidth——
907
+ // 设了会在窄面板里把第四块挤到下一行。
908
+ minWidth: 0,
909
+ overflow: "hidden",
910
+ padding: "12px 14px",
911
+ borderRadius: "8px",
912
+ background: "var(--dsw-alias-bg-layer-2)",
913
+ border: "1px solid var(--dsw-alias-border-l1)",
914
+ // 次要指标整体降透明度,视觉上退到第二层
915
+ opacity: opts.muted ? 0.72 : 1,
916
+ },
917
+ },
918
+ h("div", {
919
+ style: {
920
+ fontSize: "11px",
921
+ letterSpacing: "0.02em",
922
+ color: "var(--dsw-alias-label-secondary)",
923
+ marginBottom: "6px",
924
+ whiteSpace: "nowrap",
925
+ overflow: "hidden",
926
+ textOverflow: "ellipsis",
927
+ },
928
+ }, label),
929
+ h("div", {
930
+ title: opts.full ?? String(value),
931
+ style: Object.assign({}, NUM_STYLE, {
932
+ fontSize: opts.muted ? "15px" : "19px",
933
+ fontWeight: 600,
934
+ lineHeight: 1.15,
935
+ color: opts.color ?? "var(--dsw-alias-label-primary)",
936
+ whiteSpace: "nowrap",
937
+ overflow: "hidden",
938
+ textOverflow: "ellipsis",
939
+ }),
940
+ }, value),
941
+ );
942
+
943
+ /** 失败率条:一眼看出健康度。 */
944
+ const failBar = totalCalls === 0 ? null : h("div", {
945
+ title: `失败 ${formatNumber(t.errors)} / 请求 ${formatNumber(totalCalls)}`,
946
+ style: {
947
+ height: "3px", borderRadius: "2px", overflow: "hidden",
948
+ background: "var(--dsw-alias-border-l1)", marginTop: "6px",
949
+ },
950
+ }, h("div", {
951
+ style: {
952
+ width: Math.max(failRate * 100, failRate > 0 ? 2 : 0) + "%",
953
+ height: "100%",
954
+ background: failRate > 0.1
955
+ ? "var(--dsw-alias-state-error-primary)"
956
+ : "var(--dsw-alias-state-warn-primary)",
957
+ },
958
+ }));
959
+
960
+ // 表格列宽:数字列右对齐、等宽,读起来才顺
961
+ const numCell = (value, color) => h("td", {
962
+ style: Object.assign({}, TD_STYLE, NUM_STYLE, {
963
+ textAlign: "right",
964
+ color: color ?? "var(--dsw-alias-label-primary)",
965
+ }),
966
+ }, value);
967
+
968
+ return h("div", { style: CARD_STYLE },
969
+ // ---- 标题行 ----
970
+ h("div", {
971
+ style: {
972
+ display: "flex", alignItems: "center",
973
+ justifyContent: "space-between", marginBottom: "12px",
974
+ },
975
+ },
976
+ h("div", { style: { display: "flex", alignItems: "baseline", gap: "8px" } },
977
+ h("span", { style: { fontWeight: 600, fontSize: "14px" } }, "用量统计"),
978
+ usage.since
979
+ ? h("span", { style: { fontSize: "11px", color: "var(--dsw-alias-label-secondary)" } },
980
+ // 「数据起点」始终带年份:它可能落在几个月前甚至去年,
981
+ // 不像横轴那样只覆盖一小段时间。粒度为日时不补 :00。
982
+ "始于 " + formatTimeKey(usage.since, usage.since.includes("T") ? "hour" : "day", true))
983
+ : null,
984
+ ),
985
+ h("button", {
986
+ style: Object.assign({}, BUTTON_STYLE, {
987
+ padding: "4px 12px", fontSize: "12px",
988
+ cursor: busy ? "default" : "pointer",
989
+ }),
990
+ disabled: busy,
991
+ onClick: onRefresh,
992
+ }, busy ? "刷新中…" : "刷新"),
993
+ ),
994
+
995
+ // ---- 核心指标:强制 4 列一行 ----
996
+ // 用 grid 而不是 flex-wrap:wrap 在窄面板里会把第 4 块挤到下一行,
997
+ // grid 的 1fr 会按可用宽度均分,永远保持一行。
998
+ h("div", {
999
+ className: "wb-kpi-grid-4",
1000
+ style: {
1001
+ display: "grid",
1002
+ gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
1003
+ gap: "8px",
1004
+ marginBottom: "8px",
1005
+ },
1006
+ },
1007
+ h("div", { style: { minWidth: 0, overflow: "hidden", padding: "12px 14px", borderRadius: "8px", background: "var(--dsw-alias-bg-layer-2)", border: "1px solid var(--dsw-alias-border-l1)" } },
1008
+ h("div", { style: { fontSize: "11px", color: "var(--dsw-alias-label-secondary)", marginBottom: "6px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, "请求总数"),
1009
+ h("div", { style: Object.assign({}, NUM_STYLE, { fontSize: "19px", fontWeight: 600, lineHeight: 1.15, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }) },
1010
+ formatCompact(totalCalls)),
1011
+ failBar,
1012
+ ),
1013
+ kpi("总 tokens", formatCompact(t.totalTokens), { full: formatNumber(t.totalTokens) }),
1014
+ // 积分精确到个位、不缩写:它是能对账的数字,
1015
+ // 显示成 8.4K 会让人不知道到底扣了多少。
1016
+ kpi("积分消耗", formatNumber(t.credits), { full: formatNumber(t.credits) + " 积分" }),
1017
+ // 延迟只统计成功请求。没有成功请求时显示 —,
1018
+ // 而不是误导性的 0(或把失败请求的快速返回当成"很快")。
1019
+ h("div", {
1020
+ style: {
1021
+ minWidth: 0, overflow: "hidden",
1022
+ padding: "12px 14px", borderRadius: "8px",
1023
+ background: "var(--dsw-alias-bg-layer-2)",
1024
+ border: "1px solid var(--dsw-alias-border-l1)",
1025
+ },
1026
+ },
1027
+ h("div", {
1028
+ style: {
1029
+ fontSize: "11px", letterSpacing: "0.02em",
1030
+ color: "var(--dsw-alias-label-secondary)",
1031
+ marginBottom: "6px", whiteSpace: "nowrap",
1032
+ overflow: "hidden", textOverflow: "ellipsis",
1033
+ },
1034
+ }, "平均延迟"),
1035
+ h("div", {
1036
+ title: (t.latencySamples || 0) > 0
1037
+ ? Math.round(t.avgLatencyMs) + " 毫秒(基于 " + formatNumber(t.latencySamples) + " 次成功请求)"
1038
+ : "暂无成功请求,无法统计延迟",
1039
+ style: Object.assign({}, NUM_STYLE, {
1040
+ fontSize: "19px", fontWeight: 600, lineHeight: 1.15,
1041
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
1042
+ color: (t.avgLatencyMs || 0) > 10000
1043
+ ? "var(--dsw-alias-state-warn-primary)"
1044
+ : "var(--dsw-alias-label-primary)",
1045
+ }),
1046
+ // formatMs 对 0/undefined 已返回 —,无需再判样本数
1047
+ }, formatMs(t.avgLatencyMs)),
1048
+ ),
1049
+ ),
1050
+
1051
+ // ---- 次要指标 ----
1052
+ h("div", {
1053
+ className: "wb-kpi-grid-3",
1054
+ style: {
1055
+ display: "grid",
1056
+ gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
1057
+ gap: "8px",
1058
+ marginBottom: "12px",
1059
+ },
1060
+ },
1061
+ kpi("输入 tokens", formatCompact(t.promptTokens), { muted: true, full: formatNumber(t.promptTokens) }),
1062
+ kpi("输出 tokens", formatCompact(t.completionTokens), { muted: true, full: formatNumber(t.completionTokens) }),
1063
+ kpi("吐字速度", t.avgTokensPerSecond ? t.avgTokensPerSecond.toFixed(1) + " t/s" : "—", { muted: true }),
1064
+ ),
1065
+
1066
+ // ---- 趋势图 ----
1067
+ h(UsageTrend, { series: usage.series }),
1068
+
1069
+ // ---- 按模型明细 ----
1070
+ models.length === 0
1071
+ ? h("p", { style: { margin: "4px 0 0 0", fontSize: "13px", color: "var(--dsw-alias-label-secondary)" } },
1072
+ "还没有调用记录。用这个 provider 跑一次对话后这里就会有数据。")
1073
+ : h("div", { style: { overflowX: "auto", marginTop: "4px" } },
1074
+ h("table", { style: Object.assign({}, TABLE_STYLE, { minWidth: "560px" }) },
1075
+ h("thead", null, h("tr", null,
1076
+ h("th", { style: TH_STYLE }, "模型"),
1077
+ h("th", { style: Object.assign({}, TH_STYLE, { textAlign: "right" }) }, "请求"),
1078
+ h("th", { style: Object.assign({}, TH_STYLE, { textAlign: "right" }) }, "失败"),
1079
+ h("th", { style: Object.assign({}, TH_STYLE, { textAlign: "right" }) }, "输入"),
1080
+ h("th", { style: Object.assign({}, TH_STYLE, { textAlign: "right" }) }, "输出"),
1081
+ h("th", { style: Object.assign({}, TH_STYLE, { textAlign: "right" }) }, "总 tokens"),
1082
+ h("th", { style: Object.assign({}, TH_STYLE, { textAlign: "right" }) }, "积分"),
1083
+ )),
1084
+ h("tbody", null, models.map(m =>
1085
+ h("tr", { key: m.key },
1086
+ h("td", { style: Object.assign({}, TD_STYLE, { whiteSpace: "nowrap" }) }, m.key),
1087
+ numCell(formatNumber(m.requests)),
1088
+ numCell(formatNumber(m.errors), m.errors > 0 ? "var(--dsw-alias-state-error-primary)" : "var(--dsw-alias-label-secondary)"),
1089
+ numCell(formatNumber(m.promptTokens)),
1090
+ numCell(formatNumber(m.completionTokens)),
1091
+ numCell(formatNumber(m.totalTokens)),
1092
+ numCell(formatNumber(m.credits)),
1093
+ )
1094
+ )),
1095
+ ),
1096
+ ),
1097
+
1098
+ // ---- 存储元信息 ----
1099
+ h("p", {
1100
+ style: {
1101
+ margin: "12px 0 0 0", fontSize: "11px",
1102
+ color: "var(--dsw-alias-label-secondary)",
1103
+ paddingTop: "10px",
1104
+ borderTop: "1px solid var(--dsw-alias-border-l1)",
1105
+ },
1106
+ },
1107
+ formatBytes(usage.fileBytes || 0) + " · " + (usage.buckets || 0) + " 个分桶" +
1108
+ (failRate > 0 ? " · 失败率 " + (failRate * 100).toFixed(1) + "%" : ""),
1109
+ ),
1110
+ );
1111
+ }
1112
+
1113
+ /** 导入 auth 文件的对话框:支持粘贴 JSON,或填服务器上的文件/目录路径。 */
1114
+ /**
1115
+ * 弹窗的 z-index。
1116
+ *
1117
+ * DSH 前端最高用到 1100(聊天 / 会话 / 模型选择器)。弹窗若低于它,
1118
+ * 会被那些层的背景盖住——面板看起来是「透明」的,内容与背景混在一起。
1119
+ * 所以统一取 2000,留出余量。
1120
+ */
1121
+ const DIALOG_Z_INDEX = 2000;
1122
+
1123
+ function ImportDialog({ region, onClose, onSubmit, busy, result }) {
1124
+ const [mode, setMode] = useState("file"); // file | paste | path
1125
+ const [content, setContent] = useState("");
1126
+ const [path, setPath] = useState("");
1127
+ const [localError, setLocalError] = useState("");
1128
+ const [fileName, setFileName] = useState("");
1129
+ const [readError, setReadError] = useState("");
1130
+ const [dragging, setDragging] = useState(false);
1131
+ const fileRef = useRef(null);
1132
+
1133
+ /** 读选中的 auth 文件(用户手上是文件,不该逼他手动复制内容)。 */
1134
+ const readFile = (file) => {
1135
+ setReadError("");
1136
+ if (file === undefined || file === null) return;
1137
+ if (file.size > 2 * 1024 * 1024) {
1138
+ setReadError("文件太大(" + Math.round(file.size / 1024) + " KB),可能选错了");
1139
+ return;
1140
+ }
1141
+ const reader = new FileReader();
1142
+ reader.onload = () => {
1143
+ setContent(String(reader.result ?? ""));
1144
+ setFileName(file.name);
1145
+ setMode("paste");
1146
+ };
1147
+ reader.onerror = () => setReadError("读取文件失败,请重试或改用粘贴");
1148
+ reader.readAsText(file);
1149
+ };
1150
+
1151
+ const dropProps = {
1152
+ onDragOver: (e) => { e.preventDefault(); setDragging(true); },
1153
+ onDragLeave: () => setDragging(false),
1154
+ onDrop: (e) => {
1155
+ e.preventDefault();
1156
+ setDragging(false);
1157
+ readFile(e.dataTransfer?.files?.[0]);
1158
+ },
1159
+ };
1160
+
1161
+ const submit = () => {
1162
+ setLocalError("");
1163
+ if (mode === "file") {
1164
+ setLocalError("请先选择 auth 文件(也可以直接拖进来)");
1165
+ return;
1166
+ }
1167
+ if (mode === "paste" && content.trim() === "") {
1168
+ setLocalError("请粘贴 auth 文件的 JSON 内容");
1169
+ return;
1170
+ }
1171
+ if (mode === "path" && path.trim() === "") {
1172
+ setLocalError("请填写文件或目录路径");
1173
+ return;
1174
+ }
1175
+ onSubmit(mode === "paste" ? { content } : { path });
1176
+ };
1177
+
1178
+ const tabStyle = (active) => Object.assign({}, BUTTON_STYLE, {
1179
+ borderRadius: "6px 6px 0 0",
1180
+ borderBottom: active ? "2px solid var(--dsw-alias-brand-primary)" : "1px solid var(--dsw-alias-border-l1)",
1181
+ opacity: active ? 1 : 0.6,
1182
+ marginRight: "6px",
1183
+ });
1184
+
1185
+ return h("div", {
1186
+ style: {
1187
+ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
1188
+ display: "flex", alignItems: "center", justifyContent: "center", zIndex: DIALOG_Z_INDEX,
1189
+ },
1190
+ },
1191
+ h("div", {
1192
+ style: {
1193
+ background: "var(--dsw-alias-bg-overlay)",
1194
+ border: "1px solid var(--dsw-alias-border-l1)",
1195
+ borderRadius: "10px", padding: "20px 24px", maxWidth: "620px", width: "92%",
1196
+ maxHeight: "85vh", overflowY: "auto",
1197
+ },
1198
+ },
1199
+ h("h3", { style: { margin: "0 0 6px 0", fontSize: "15px" } }, "导入 auth 文件"),
1200
+ h("p", { style: { fontSize: "12px", opacity: 0.7, margin: "0 0 14px 0" } },
1201
+ "支持网关 auths/*.json 的嵌套格式,也支持手写的扁平格式。"),
1202
+
1203
+ h("div", null,
1204
+ h("button", { style: tabStyle(mode === "file"), onClick: () => setMode("file") }, "选择文件"),
1205
+ h("button", { style: tabStyle(mode === "paste"), onClick: () => setMode("paste") }, "粘贴 JSON"),
1206
+ h("button", { style: tabStyle(mode === "path"), onClick: () => setMode("path") }, "服务器路径"),
1207
+ ),
1208
+
1209
+ mode === "file"
1210
+ ? h("div", Object.assign({
1211
+ style: {
1212
+ border: "2px dashed " + (dragging
1213
+ ? "var(--dsw-alias-brand-primary)"
1214
+ : "var(--dsw-alias-border-l2)"),
1215
+ borderRadius: "10px", padding: "28px 20px", textAlign: "center",
1216
+ background: dragging ? "var(--dsw-alias-bg-layer-2)" : "transparent",
1217
+ transition: "background 150ms, border-color 150ms",
1218
+ },
1219
+ }, dropProps),
1220
+ h("p", { style: { margin: "0 0 12px 0", fontSize: "13px", opacity: 0.8 } },
1221
+ "把 auth 文件拖到这里"),
1222
+ h("button", {
1223
+ style: PRIMARY_BUTTON_STYLE,
1224
+ disabled: busy,
1225
+ onClick: () => fileRef.current?.click(),
1226
+ }, "选择文件"),
1227
+ h("input", {
1228
+ type: "file",
1229
+ accept: ".json,application/json",
1230
+ ref: fileRef,
1231
+ style: { display: "none" },
1232
+ onChange: (e) => readFile(e.target.files?.[0]),
1233
+ }),
1234
+ h("p", { style: { margin: "12px 0 0 0", fontSize: "11px", opacity: 0.6 } },
1235
+ "选好后会切到「粘贴 JSON」显示内容"),
1236
+ )
1237
+ : null,
1238
+
1239
+ mode === "paste" && fileName !== ""
1240
+ ? h("p", { style: { fontSize: "12px", opacity: 0.7, margin: "0 0 6px 0" } },
1241
+ "已读取文件:" + fileName)
1242
+ : null,
1243
+
1244
+ mode === "paste"
1245
+ ? h("textarea", {
1246
+ style: Object.assign({}, INPUT_STYLE, {
1247
+ width: "100%", minWidth: "0", minHeight: "200px", boxSizing: "border-box",
1248
+ fontFamily: "ui-monospace, Consolas, monospace", fontSize: "12px",
1249
+ lineHeight: 1.5, resize: "vertical", display: "block",
1250
+ }),
1251
+ value: content,
1252
+ onChange: (e) => setContent(e.target.value),
1253
+ spellCheck: false,
1254
+ placeholder: '{\n "auth": { "accessToken": "...", "refreshToken": "...", "domain": "..." },\n "account": { "uid": "...", "nickname": "..." }\n}',
1255
+ })
1256
+ : h("div", null,
1257
+ h("input", {
1258
+ style: Object.assign({}, INPUT_STYLE, { width: "100%", boxSizing: "border-box" }),
1259
+ value: path,
1260
+ onChange: (e) => setPath(e.target.value),
1261
+ placeholder: "/path/to/auths (目录会自动扫描里面的 .json)",
1262
+ }),
1263
+ h("p", { style: { fontSize: "12px", opacity: 0.65, margin: "6px 0 0 0" } },
1264
+ "填目录会把里面所有 .json 逐个尝试导入。"),
1265
+ ),
1266
+
1267
+ localError ? h("p", { style: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px", margin: "10px 0 0 0" } }, localError) : null,
1268
+
1269
+ result
1270
+ ? h("div", { style: { marginTop: "12px", fontSize: "12px" } },
1271
+ result.imported && result.imported.length > 0
1272
+ ? h("div", { style: { color: "var(--dsw-alias-state-success-primary)" } },
1273
+ "成功导入 " + result.imported.length + " 个:" + result.imported.map(a => a.nickname).join("、"))
1274
+ : null,
1275
+ result.failed && result.failed.length > 0
1276
+ ? h("div", { style: { marginTop: "6px", color: "var(--dsw-alias-state-error-primary)" } },
1277
+ "失败 " + result.failed.length + " 个:",
1278
+ h("ul", { style: { margin: "4px 0 0 0", paddingLeft: "18px" } },
1279
+ result.failed.map((f, i) => h("li", { key: i }, f.source + " — " + f.error))
1280
+ ))
1281
+ : null,
1282
+ )
1283
+ : null,
1284
+
1285
+ h("div", { style: { display: "flex", gap: "8px", marginTop: "16px" } },
1286
+ h("button", { style: PRIMARY_BUTTON_STYLE, disabled: busy, onClick: submit }, busy ? "导入中…" : "导入"),
1287
+ h("button", { style: BUTTON_STYLE, onClick: onClose }, result ? "关闭" : "取消"),
1288
+ ),
1289
+ ),
1290
+ );
1291
+ }
1292
+
1293
+ /**
1294
+ * Trae 凭证导入对话框。
1295
+ *
1296
+ * 不做网页登录——Trae 授权页把 token fetch 投递到 127.0.0.1:18080,
1297
+ * DSH 在 NAS、浏览器在另一台电脑时那个地址指用户自己的电脑,
1298
+ * 请求被拒且地址栏不会出现带 token 的链接,那条路走不通。
1299
+ *
1300
+ * 改为:在 Windows/macOS 上登录 Trae,把 storage.json 内容粘过来。
1301
+ */
1302
+ function TraeImportDialog({ onClose, onSubmit, busy, result }) {
1303
+ const [mode, setMode] = useState("file"); // file | content | path
1304
+ const [content, setContent] = useState("");
1305
+ const [filePath, setFilePath] = useState("");
1306
+ const [fileName, setFileName] = useState("");
1307
+ const [readError, setReadError] = useState("");
1308
+ /** 表单校验的错误(与 readError「读文件失败」分开,便于分别清除)。 */
1309
+ const [localError, setLocalError] = useState("");
1310
+ const [dragging, setDragging] = useState(false);
1311
+ const fileRef = useRef(null);
1312
+ const done = result?.ok === true;
1313
+
1314
+ /**
1315
+ * 读用户选中的文件。
1316
+ *
1317
+ * 用户手上其实就是一个 storage.json 文件——让他自己打开、全选、
1318
+ * 复制太别扭。直接选文件(或拖进来)最顺手。
1319
+ *
1320
+ * FileReader 是浏览器标准 API,不引第三方库。
1321
+ */
1322
+ const readFile = (file) => {
1323
+ setReadError("");
1324
+ if (file === undefined || file === null) return;
1325
+ // 大文件多半是选错了(storage.json 通常几十 KB)
1326
+ if (file.size > 2 * 1024 * 1024) {
1327
+ setReadError("文件太大(" + Math.round(file.size / 1024) + " KB),可能选错了");
1328
+ return;
1329
+ }
1330
+ const reader = new FileReader();
1331
+ reader.onload = () => {
1332
+ setContent(String(reader.result ?? ""));
1333
+ setFileName(file.name);
1334
+ setMode("content");
1335
+ };
1336
+ reader.onerror = () => setReadError("读取文件失败,请重试或改用粘贴");
1337
+ reader.readAsText(file);
1338
+ };
1339
+
1340
+ /** 拖拽支持:拖进来直接读。 */
1341
+ const dropProps = {
1342
+ onDragOver: (e) => { e.preventDefault(); setDragging(true); },
1343
+ onDragLeave: () => setDragging(false),
1344
+ onDrop: (e) => {
1345
+ e.preventDefault();
1346
+ setDragging(false);
1347
+ readFile(e.dataTransfer?.files?.[0]);
1348
+ },
1349
+ };
1350
+
1351
+ const submit = () => {
1352
+ setLocalError("");
1353
+
1354
+ // 文件模式:还没选文件就点导入是操作失误,明确提示而不是
1355
+ // 落进下面「服务器路径」的分支(那样会提交空路径)。
1356
+ if (mode === "file") {
1357
+ setLocalError("请先选择 storage.json 文件(也可以直接拖进来)");
1358
+ return;
1359
+ }
1360
+
1361
+ if (mode === "content") {
1362
+ const text = content.trim();
1363
+ if (text === "") { setLocalError("请粘贴 storage.json 的内容"); return; }
1364
+ if (!text.startsWith("{")) {
1365
+ setLocalError("内容应以 { 开头(是 storage.json 的 JSON 文本)"); return;
1366
+ }
1367
+ onSubmit({ content: text });
1368
+ return;
1369
+ }
1370
+
1371
+ const path = filePath.trim();
1372
+ if (path === "") { setLocalError("请填写服务器上的文件路径"); return; }
1373
+ onSubmit({ path });
1374
+ };
1375
+
1376
+ /** 两种来源用同一个输入框风格。 */
1377
+ const inputStyle = Object.assign({}, INPUT_STYLE, {
1378
+ width: "100%", minWidth: "0", boxSizing: "border-box",
1379
+ fontFamily: "ui-monospace, Consolas, monospace", fontSize: "12px",
1380
+ lineHeight: 1.5, display: "block",
1381
+ });
1382
+
1383
+ return h("div", {
1384
+ style: {
1385
+ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
1386
+ display: "flex", alignItems: "center", justifyContent: "center", zIndex: DIALOG_Z_INDEX,
1387
+ },
1388
+ },
1389
+ h("div", {
1390
+ style: {
1391
+ background: "var(--dsw-alias-bg-overlay)",
1392
+ border: "1px solid var(--dsw-alias-border-l1)",
1393
+ borderRadius: "10px", padding: "20px 24px", maxWidth: "680px", width: "92%",
1394
+ maxHeight: "85vh", overflowY: "auto",
1395
+ },
1396
+ },
1397
+ h("h3", { style: { margin: "0 0 6px 0", fontSize: "15px" } }, "导入 Trae 凭证"),
1398
+
1399
+ done
1400
+ ? h("p", { style: { fontSize: "13px", color: "var(--dsw-alias-state-success-primary)", margin: "0 0 14px 0" } },
1401
+ "已导入账号:" + (result.account?.nickname ?? ""))
1402
+ : h("div", { style: { fontSize: "12px", opacity: 0.78, margin: "0 0 14px 0", lineHeight: 1.7 } },
1403
+ h("p", { style: { margin: "0 0 6px 0" } },
1404
+ "在已登录 Trae 的电脑上找到这个文件,把它的内容复制过来:"),
1405
+ h("code", {
1406
+ style: {
1407
+ display: "block", padding: "8px 10px", borderRadius: "6px",
1408
+ background: "var(--dsw-alias-bg-layer-2)", fontSize: "11px",
1409
+ wordBreak: "break-all", marginBottom: "6px",
1410
+ },
1411
+ },
1412
+ "%APPDATA%\\Trae CN\\User\\globalStorage\\storage.json"),
1413
+ h("p", { style: { margin: 0, opacity: 0.8 } },
1414
+ "解密不依赖那台电脑的硬件,所以拷过来就能用。"),
1415
+
1416
+ // 两种输入方式
1417
+ !done
1418
+ ? h(TabBar, {
1419
+ tabs: [
1420
+ { id: "file", label: "选择文件" },
1421
+ { id: "content", label: "粘贴内容" },
1422
+ { id: "path", label: "服务器路径" },
1423
+ ],
1424
+ active: mode,
1425
+ onChange: setMode,
1426
+ })
1427
+ : null,
1428
+
1429
+ // 选文件 —— 用户手上就是一个文件,这是最顺手的入口
1430
+ !done && mode === "file"
1431
+ ? h("div", Object.assign({
1432
+ style: {
1433
+ border: "2px dashed " + (dragging
1434
+ ? "var(--dsw-alias-brand-primary)"
1435
+ : "var(--dsw-alias-border-l2)"),
1436
+ borderRadius: "10px",
1437
+ padding: "28px 20px",
1438
+ textAlign: "center",
1439
+ background: dragging ? "var(--dsw-alias-bg-layer-2)" : "transparent",
1440
+ transition: "background 150ms, border-color 150ms",
1441
+ },
1442
+ }, dropProps),
1443
+ h("p", { style: { margin: "0 0 12px 0", fontSize: "13px", opacity: 0.8 } },
1444
+ "把 storage.json 拖到这里"),
1445
+ h("button", {
1446
+ style: PRIMARY_BUTTON_STYLE,
1447
+ disabled: busy,
1448
+ onClick: () => fileRef.current?.click(),
1449
+ }, "选择文件"),
1450
+ h("input", {
1451
+ type: "file",
1452
+ accept: ".json,application/json",
1453
+ ref: fileRef,
1454
+ style: { display: "none" },
1455
+ onChange: (e) => readFile(e.target.files?.[0]),
1456
+ }),
1457
+ h("p", { style: { margin: "12px 0 0 0", fontSize: "11px", opacity: 0.6 } },
1458
+ "选好后会切到「粘贴内容」显示,确认无误再点导入"),
1459
+ )
1460
+ : null,
1461
+
1462
+ !done && mode === "content" && fileName !== ""
1463
+ ? h("p", { style: { fontSize: "12px", opacity: 0.7, margin: "0 0 6px 0" } },
1464
+ "已读取文件:" + fileName)
1465
+ : null,
1466
+
1467
+ !done && mode === "content"
1468
+ ? h("textarea", {
1469
+ style: Object.assign({}, inputStyle, { minHeight: "140px", resize: "vertical" }),
1470
+ value: content,
1471
+ onChange: (e) => setContent(e.target.value),
1472
+ spellCheck: false,
1473
+ placeholder: '{"iCubeAuthInfo://icube.cloudide":"...","telemetry.machineId":"..."}',
1474
+ })
1475
+ : null,
1476
+
1477
+ !done && mode === "path"
1478
+ ? h("input", {
1479
+ style: inputStyle,
1480
+ value: filePath,
1481
+ onChange: (e) => setFilePath(e.target.value),
1482
+ spellCheck: false,
1483
+ placeholder: "/root/trae-storage.json",
1484
+ })
1485
+ : null,
1486
+
1487
+ (localError || readError)
1488
+ ? h("p", { style: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px", margin: "10px 0 0 0" } },
1489
+ localError || readError)
1490
+ : null,
1491
+
1492
+ result && result.ok === false
1493
+ ? h("p", { style: { color: "var(--dsw-alias-state-error-primary)", fontSize: "12px", margin: "10px 0 0 0" } },
1494
+ "导入失败:" + (result.error ?? "未知错误"))
1495
+ : null,
1496
+
1497
+ done
1498
+ ? h("div", { style: { fontSize: "12px", opacity: 0.7, marginTop: "12px" } },
1499
+ "区域:" + (result.region ?? "cn")
1500
+ + (result.hasRefreshToken ? " · 含刷新令牌(可自动续期)" : " · 无刷新令牌(到期需重新导入)"))
1501
+ : null,
1502
+
1503
+ h("div", { style: { display: "flex", gap: "8px", marginTop: "16px" } },
1504
+ !done
1505
+ ? h("button", {
1506
+ style: PRIMARY_BUTTON_STYLE,
1507
+ // 文件模式下没选文件就不该可点
1508
+ disabled: busy || mode === "file",
1509
+ onClick: submit,
1510
+ }, busy ? "导入中…" : "导入")
1511
+ : null,
1512
+ h("button", { style: BUTTON_STYLE, onClick: onClose }, done ? "关闭" : "取消"),
1513
+ ),
1514
+ ),
1515
+ ),
1516
+ );
1517
+ }
1518
+
1519
+ function LoginDialog({ region, authUrl, onCancel, status }) {
1520
+ return h("div", {
1521
+ style: {
1522
+ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
1523
+ display: "flex", alignItems: "center", justifyContent: "center", zIndex: DIALOG_Z_INDEX,
1524
+ },
1525
+ },
1526
+ h("div", {
1527
+ style: {
1528
+ background: "var(--dsw-alias-bg-overlay)",
1529
+ border: "1px solid var(--dsw-alias-border-l1)",
1530
+ borderRadius: "10px", padding: "20px 24px", maxWidth: "520px", width: "90%",
1531
+ },
1532
+ },
1533
+ h("h3", { style: { margin: "0 0 10px 0", fontSize: "15px" } }, "登录 WorkBuddy 账号"),
1534
+ h("p", { style: { fontSize: "13px", opacity: 0.8, margin: "0 0 12px 0" } },
1535
+ "在浏览器中打开下面的链接完成登录,完成后本页面会自动加入账号。"),
1536
+ h("div", {
1537
+ style: {
1538
+ padding: "10px", borderRadius: "6px", wordBreak: "break-all",
1539
+ background: "var(--dsw-alias-bg-layer-2)",
1540
+ fontFamily: "ui-monospace, Consolas, monospace", fontSize: "12px",
1541
+ border: "1px solid var(--dsw-alias-border-l1)", marginBottom: "12px",
1542
+ },
1543
+ }, authUrl),
1544
+ h("div", { style: { display: "flex", alignItems: "center", gap: "12px" } },
1545
+ h("button", {
1546
+ style: PRIMARY_BUTTON_STYLE,
1547
+ onClick: () => window.open(authUrl, "_blank", "noopener,noreferrer"),
1548
+ }, "打开登录页"),
1549
+ h("span", { style: { fontSize: "12px", opacity: 0.75 } }, status),
1550
+ h("button", { style: Object.assign({}, BUTTON_STYLE, { marginLeft: "auto" }), onClick: onCancel }, "取消"),
1551
+ ),
1552
+ ),
1553
+ );
1554
+ }
1555
+
1556
+ // ---------------------------------------------------------------------
1557
+ // 数据获取 hook:唯一的数据入口,集中处理 loading/error/刷新
1558
+ // ---------------------------------------------------------------------
1559
+
1560
+ /**
1561
+ * 拉取某个「上游分组」的状态。
1562
+ *
1563
+ * 分组是为了让 WorkBuddy 与 Trae 各自成为一个独立菜单:
1564
+ * 每个菜单只显示自己的区域与账号,不互相干扰。
1565
+ *
1566
+ * @param {string[]} regions 该菜单关心的区域,如 ['cn'] 或 ['trae']
1567
+ */
1568
+ function usePoolState(regions) {
1569
+ const [state, setState] = useState({ loading: true, regions: {}, usage: {}, tasks: [], error: null });
1570
+ // 用字符串做依赖:数组每次渲染都是新引用,会让 useCallback 失效。
1571
+ const regionKey = regions.join(',');
1572
+
1573
+ const load = useCallback(async () => {
1574
+ try {
1575
+ const wanted = regionKey.split(',');
1576
+ // 三个接口并发拉:状态/用量/任务互不依赖,串行会白等三轮。
1577
+ // 任务状态是按区域查的,所以有几个区域就发几次。
1578
+ const [stateRes, usageRes, ...taskResList] = await Promise.all([
1579
+ fetch("/account-pool/state", { cache: "no-store" }),
1580
+ fetch("/account-pool/usage", { cache: "no-store" }),
1581
+ ...wanted.map(r => fetch("/account-pool/tasks/status?region=" + encodeURIComponent(r), { cache: "no-store" })),
1582
+ ]);
1583
+ const data = await stateRes.json();
1584
+ if (!data.ok) throw new Error(data.error || "读取失败");
1585
+
1586
+ // 只保留本分组关心的区域:接口返回全部区域,这里做过滤,
1587
+ // 否则 Trae 菜单会漏出 WorkBuddy 的区域卡片。
1588
+ const regions_ = {};
1589
+ for (const r of wanted) {
1590
+ if (data.regions?.[r] !== undefined) regions_[r] = data.regions[r];
1591
+ }
1592
+
1593
+ let usage = {};
1594
+ try {
1595
+ const usageData = await usageRes.json();
1596
+ if (usageData.ok) {
1597
+ usage = {};
1598
+ for (const r of wanted) {
1599
+ if (usageData.regions?.[r] !== undefined) usage[r] = usageData.regions[r];
1600
+ }
1601
+ }
1602
+ } catch {
1603
+ // 用量拉不到不影响主体展示
1604
+ }
1605
+
1606
+ let tasks = [];
1607
+ for (const res of taskResList) {
1608
+ try {
1609
+ const tasksData = await res.json();
1610
+ if (tasksData.ok) tasks = tasks.concat(tasksData.accounts || []);
1611
+ } catch {
1612
+ // 任务状态拉不到(无账号/网络不通)不影响账号视图
1613
+ }
1614
+ }
1615
+
1616
+ setState({ loading: false, regions: regions_, usage, tasks, error: null });
1617
+ } catch (error) {
1618
+ setState({ loading: false, regions: {}, usage: {}, tasks: [], error: error.message });
1619
+ }
1620
+ }, [regionKey]);
1621
+
1622
+ useEffect(() => { void load(); }, [load]);
1623
+
1624
+ // 定时刷新:不加的话冷却倒计时会冻结在「打开页面那一刻」的值,
1625
+ // 用户看到数字一直不动,会以为坏了。
1626
+ // 10 秒一次足够看出倒计时在走;只在页面可见时拉,避免后台白耗。
1627
+ useEffect(() => {
1628
+ const timer = setInterval(() => {
1629
+ if (typeof document !== "undefined" && document.hidden) return;
1630
+ void load();
1631
+ }, 10_000);
1632
+ return () => clearInterval(timer);
1633
+ }, [load]);
1634
+
1635
+ return { state, reload: load };
1636
+ }
1637
+
1638
+ // ---------------------------------------------------------------------
1639
+ // 主组件
1640
+ // ---------------------------------------------------------------------
1641
+
1642
+ /**
1643
+ * 每个上游菜单的配置。
1644
+ *
1645
+ * 两个菜单共用同一份组件代码——只在「说明文案」「区域列表」
1646
+ * 「每日任务视图」上不同。这样风格天然一致,改一处两边都变。
1647
+ */
1648
+ const PROFILES = {
1649
+ workbuddy: {
1650
+ id: "account-pool",
1651
+ label: () => "WorkBuddy",
1652
+ order: 35,
1653
+ // 本菜单只管 cn;global 未启用时接口不会返回它,会被过滤掉
1654
+ regions: ["cn", "global"],
1655
+ intro: "把多个 WorkBuddy 账号接入 DSH。请求会在账号之间自动切换:"
1656
+ + "某个账号限流或积分耗尽时,自动换下一个继续,无需手动干预。",
1657
+ tasksView: "full",
1658
+ },
1659
+ trae: {
1660
+ id: "trae-pool",
1661
+ label: () => "Trae",
1662
+ order: 36,
1663
+ regions: ["trae"],
1664
+ intro: "把 Trae(TRAE SOLO)账号接入 DSH。与 WorkBuddy 共用选号、"
1665
+ + "自动换号与用量统计。凭证来自 Trae 桌面端的 storage.json——"
1666
+ + "在已登录 Trae 的电脑上复制那个文件的内容,粘到「导入凭证」里即可。",
1667
+ tasksView: "simple",
1668
+ },
1669
+ };
1670
+
1671
+ function PoolSection({ profile }) {
1672
+ const { state, reload } = usePoolState(profile.regions);
1673
+ const [busy, setBusy] = useState(false);
1674
+ const [notices, setNotices] = useState({});
1675
+ const [login, setLogin] = useState(null); // {region, authUrl, status}
1676
+ const [importing, setImporting] = useState(null); // {region, result}
1677
+ // Trae 登录:{loginUrl, machineId, deviceId, result}
1678
+ const [traeImport, setTraeImport] = useState(null); // {result}
1679
+ const [tab, setTab] = useState("accounts"); // accounts | tasks
1680
+ // 每秒推进一次,让倒计时实时走动(数据本身 10 秒才刷一次)
1681
+ const [, setTick] = useState(0);
1682
+ useEffect(() => {
1683
+ const timer = setInterval(() => setTick(t => t + 1), 1000);
1684
+ return () => clearInterval(timer);
1685
+ }, []);
1686
+ // 上次每日任务的执行结果:签到没有只读接口,靠它显示完成情况。
1687
+ const [lastRun, setLastRun] = useState(null);
1688
+ const pollTimer = useRef(null);
1689
+
1690
+ /** 给某个区域设一条提示信息。 */
1691
+ const setNotice = useCallback((region, kind, text) => {
1692
+ setNotices(prev => Object.assign({}, prev, { [region]: { kind, text } }));
1693
+ }, []);
1694
+
1695
+ /** 统一包装一次 POST 调用的 busy 与错误处理。 */
1696
+ const post = useCallback(async (path, body, region) => {
1697
+ setBusy(true);
1698
+ try {
1699
+ const res = await fetch(path, {
1700
+ method: "POST",
1701
+ headers: { "content-type": "application/json" },
1702
+ body: JSON.stringify(body),
1703
+ });
1704
+ const data = await res.json();
1705
+ if (!data.ok) throw new Error(data.error || "操作失败");
1706
+ return data;
1707
+ } catch (error) {
1708
+ if (region) setNotice(region, "error", error.message);
1709
+ return null;
1710
+ } finally {
1711
+ setBusy(false);
1712
+ }
1713
+ }, [setNotice]);
1714
+
1715
+ const onRefresh = useCallback(async (region) => {
1716
+ const data = await post("/account-pool/refresh", { region }, region);
1717
+ if (data) setNotice(region, "ok", "已刷新,共 " + data.models + " 个模型");
1718
+ await reload();
1719
+ }, [post, reload, setNotice]);
1720
+
1721
+ /** 打开导入对话框。 */
1722
+ const onImport = useCallback((region) => {
1723
+ setImporting({ region, result: null });
1724
+ }, []);
1725
+
1726
+ /** 提交导入:content(粘贴)或 path(服务器路径)。 */
1727
+ const onImportSubmit = useCallback(async (payload) => {
1728
+ const region = importing?.region ?? "cn";
1729
+ setBusy(true);
1730
+ try {
1731
+ const res = await fetch("/account-pool/login/import", {
1732
+ method: "POST",
1733
+ headers: { "content-type": "application/json" },
1734
+ body: JSON.stringify({ region, ...payload }),
1735
+ });
1736
+ const data = await res.json();
1737
+ if (!data.ok) throw new Error(data.error || "导入失败");
1738
+ setImporting({ region, result: data });
1739
+ if (data.imported.length > 0) {
1740
+ setNotice(region, "ok", "已导入 " + data.imported.length + " 个账号");
1741
+ await reload();
1742
+ }
1743
+ } catch (error) {
1744
+ setImporting({ region, result: { imported: [], failed: [{ source: "导入", error: error.message }] } });
1745
+ } finally {
1746
+ setBusy(false);
1747
+ }
1748
+ }, [importing, reload, setNotice]);
1749
+
1750
+ /** 打开 Trae 凭证导入对话框。 */
1751
+ const onTraeLogin = useCallback(() => {
1752
+ setTraeImport({ result: null });
1753
+ }, []);
1754
+
1755
+ /** 提交导入:粘贴内容或给服务器路径。 */
1756
+ const onTraeImportSubmit = useCallback(async (payload) => {
1757
+ setBusy(true);
1758
+ try {
1759
+ const res = await fetch("/account-pool/trae/import", {
1760
+ method: "POST",
1761
+ headers: { "content-type": "application/json" },
1762
+ body: JSON.stringify(payload),
1763
+ });
1764
+ const data = await res.json();
1765
+ if (data.ok) {
1766
+ // 成功就直接关:账号出现在列表里、区域提示条有确认信息,
1767
+ // 对话框继续开着反而让人以为还有下一步没做完。
1768
+ setTraeImport(null);
1769
+ setNotice("trae", "ok", "已导入账号:" + (data.account?.nickname ?? "")
1770
+ + "(区域 " + (data.region ?? "cn") + ")");
1771
+ await reload();
1772
+ } else {
1773
+ // 失败留在对话框里,用户能看到原因并改
1774
+ setTraeImport({ result: data });
1775
+ }
1776
+ } catch (error) {
1777
+ setTraeImport({ result: { ok: false, error: error.message } });
1778
+ } finally {
1779
+ setBusy(false);
1780
+ }
1781
+ }, [reload, setNotice]);
1782
+
1783
+ const onRefreshUsage = useCallback(async () => {
1784
+ const data = await post("/account-pool/usage", {});
1785
+ if (data !== null) {
1786
+ const first = Object.keys(data.regions || {})[0] || "cn";
1787
+ setNotice(first, "ok", "已刷新用量");
1788
+ }
1789
+ await reload();
1790
+ }, [post, reload, setNotice]);
1791
+
1792
+ const onRefreshCredits = useCallback(async (region) => {
1793
+ const data = await post("/account-pool/credits", { region }, region);
1794
+ if (data) setNotice(region, "ok", "已刷新积分");
1795
+ await reload();
1796
+ }, [post, reload, setNotice]);
1797
+
1798
+ const onRevive = useCallback(async (region, accountId) => {
1799
+ const data = await post("/account-pool/revive", { region, accountId }, region);
1800
+ if (data) setNotice(region, "ok", "已解冻该账号");
1801
+ await reload();
1802
+ }, [post, reload, setNotice]);
1803
+
1804
+ const onRemove = useCallback(async (region, accountId) => {
1805
+ if (!window.confirm("确定移除这个账号?凭证会从本机删除。")) return;
1806
+ const data = await post("/account-pool/remove", { region, accountId }, region);
1807
+ if (data) setNotice(region, "ok", "已移除账号");
1808
+ await reload();
1809
+ }, [post, reload, setNotice]);
1810
+
1811
+
1812
+ /** 取消登录:停轮询、关弹窗。 */
1813
+ const cancelLogin = useCallback(() => {
1814
+ if (pollTimer.current) { clearInterval(pollTimer.current); pollTimer.current = null; }
1815
+ setLogin(null);
1816
+ }, []);
1817
+
1818
+ /** 添加账号:取授权链接 → 开始轮询。 */
1819
+ const onAddAccount = useCallback(async (region) => {
1820
+ setBusy(true);
1821
+ try {
1822
+ const res = await fetch("/account-pool/login/start", {
1823
+ method: "POST",
1824
+ headers: { "content-type": "application/json" },
1825
+ body: JSON.stringify({ region }),
1826
+ });
1827
+ const data = await res.json();
1828
+ if (!data.ok) throw new Error(data.error || "申请授权失败");
1829
+ setLogin({ region: data.region, authUrl: data.authUrl, status: "等待浏览器完成登录…" });
1830
+
1831
+ const startedAt = Date.now();
1832
+ if (pollTimer.current) clearInterval(pollTimer.current);
1833
+ pollTimer.current = setInterval(async () => {
1834
+ if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
1835
+ cancelLogin();
1836
+ setNotice(region, "error", "登录超时,请重新添加");
1837
+ return;
1838
+ }
1839
+ try {
1840
+ const pollRes = await fetch(
1841
+ "/account-pool/login/poll?region=" + encodeURIComponent(data.region)
1842
+ + "&state=" + encodeURIComponent(data.state),
1843
+ { cache: "no-store" },
1844
+ );
1845
+ const pollData = await pollRes.json();
1846
+ if (!pollData.ok) throw new Error(pollData.error || "轮询失败");
1847
+ if (pollData.pending) return;
1848
+ cancelLogin();
1849
+ setNotice(region, "ok", "已添加账号:" + (pollData.account.nickname || pollData.account.id.slice(0, 8)));
1850
+ await reload();
1851
+ } catch (error) {
1852
+ cancelLogin();
1853
+ setNotice(region, "error", error.message);
1854
+ }
1855
+ }, POLL_INTERVAL_MS);
1856
+ } catch (error) {
1857
+ setNotice(region, "error", error.message);
1858
+ } finally {
1859
+ setBusy(false);
1860
+ }
1861
+ }, [cancelLogin, reload, setNotice]);
1862
+
1863
+ // 组件卸载时停掉轮询,避免定时器泄漏。
1864
+ useEffect(() => () => {
1865
+ if (pollTimer.current) clearInterval(pollTimer.current);
1866
+ }, []);
1867
+
1868
+ /**
1869
+ * 本菜单实际渲染的区域。
1870
+ *
1871
+ * usePoolState 已经按 profile 过滤过,这里**再挡一道**:
1872
+ * 接口返回全部区域,任何一处过滤失效都会让 Trae 菜单漏出
1873
+ * WorkBuddy 的账号卡(反之亦然)。多这一层成本极低,
1874
+ * 却能防止「串区」这类静默错误。
1875
+ */
1876
+ const regions = Object.keys(state.regions || {}).filter(r => profile.regions.includes(r));
1877
+
1878
+ return h("div", { style: { maxWidth: "900px" } },
1879
+ h("p", { style: { marginTop: 0, fontSize: "13px", opacity: 0.75 } }, profile.intro),
1880
+
1881
+ state.error
1882
+ ? h("div", { style: Object.assign({}, CARD_STYLE, { color: "var(--dsw-alias-state-error-primary)" }) },
1883
+ "读取状态失败:" + state.error)
1884
+ : null,
1885
+
1886
+ h(TabBar, {
1887
+ tabs: [
1888
+ { id: "accounts", label: "账号" },
1889
+ { id: "tasks", label: "每日任务" },
1890
+ ],
1891
+ active: tab,
1892
+ onChange: setTab,
1893
+ }),
1894
+
1895
+ state.loading
1896
+ ? h("p", { style: { opacity: 0.6, fontSize: "13px" } }, "加载中…")
1897
+ : null,
1898
+
1899
+ // ---- 每日任务视图 ----
1900
+ // Trae 的任务体系不同(只有签到 + 积分),用另一个视图,
1901
+ // 避免显示它根本没有的「猫猫旅行 / 连登兑换 / 抽奖」。
1902
+ !state.loading && tab === "tasks" && regions.length > 0 && profile.tasksView === "simple"
1903
+ ? h(TraeTasksView, {
1904
+ region: regions[0],
1905
+ data: { accounts: state.tasks || [] },
1906
+ lastRun,
1907
+ busy,
1908
+ setBusy,
1909
+ setNotice,
1910
+ notice: notices[regions[0]],
1911
+ reload,
1912
+ onRunDone: setLastRun,
1913
+ })
1914
+ : null,
1915
+
1916
+ !state.loading && tab === "tasks" && regions.length > 0 && profile.tasksView === "full"
1917
+ ? h(DailyTasksView, {
1918
+ region: regions[0],
1919
+ data: { accounts: state.tasks || [] },
1920
+ lastRun,
1921
+ busy,
1922
+ setBusy,
1923
+ setNotice,
1924
+ notice: notices[regions[0]],
1925
+ reload,
1926
+ onRunDone: setLastRun,
1927
+ })
1928
+ : null,
1929
+
1930
+ // ---- 账号视图 ----
1931
+ !state.loading && tab === "accounts"
1932
+ ? regions.map(region => h(RegionPanel, {
1933
+ key: region,
1934
+ region,
1935
+ data: state.regions[region],
1936
+ busy,
1937
+ notice: notices[region],
1938
+ onRefresh,
1939
+ onRefreshCredits,
1940
+ onAddAccount,
1941
+ onImport,
1942
+ onTraeLogin,
1943
+ onRevive,
1944
+ onRemove,
1945
+ }))
1946
+ : null,
1947
+
1948
+ tab === "accounts" && regions.length > 0 && !state.loading
1949
+ ? h(UsageCard, {
1950
+ usage: state.usage[regions[0]],
1951
+ busy,
1952
+ onRefresh: onRefreshUsage,
1953
+ })
1954
+ : null,
1955
+
1956
+ traeImport
1957
+ ? h(TraeImportDialog, {
1958
+ busy,
1959
+ result: traeImport.result,
1960
+ onClose: () => setTraeImport(null),
1961
+ onSubmit: onTraeImportSubmit,
1962
+ })
1963
+ : null,
1964
+
1965
+ importing
1966
+ ? h(ImportDialog, {
1967
+ region: importing.region,
1968
+ busy,
1969
+ result: importing.result,
1970
+ onClose: () => setImporting(null),
1971
+ onSubmit: onImportSubmit,
1972
+ })
1973
+ : null,
1974
+
1975
+ login
1976
+ ? h(LoginDialog, {
1977
+ region: login.region,
1978
+ authUrl: login.authUrl,
1979
+ status: login.status,
1980
+ onCancel: cancelLogin,
1981
+ })
1982
+ : null,
1983
+ );
1984
+ }
1985
+
1986
+ /**
1987
+ * 响应式规则用真正的 CSS 媒体查询。
1988
+ *
1989
+ * 内联样式写不了 @media,而 KPI 网格在窄面板里需要降级列数
1990
+ * (4 列挤不下时改 2 列)。styles.insert 是 Client 提供的样式表
1991
+ * 注入能力,随插件运行一起清理,不留残留。
1992
+ */
1993
+ const RESPONSIVE_CSS = [
1994
+ // 图表填充色。
1995
+ //
1996
+ // 不能用 --dsw-alias-brand-primary:它是「反色块上的前景色」,
1997
+ // 浅色模式是 #f9fafb(近白)、深色模式是 #0f1115(近黑)。
1998
+ // 拿它当柱身,浅色下白底看不见、深色下黑红搭配难看(实测踩过)。
1999
+ //
2000
+ // 改用固定的中间调蓝:两种模式下都与背景有足够对比,
2001
+ // 也是设计数据库推荐的图表主色(Blue data)。
2002
+ ":root {",
2003
+ " --wb-chart-bar: #3b82f6;", /* blue-500 */
2004
+ " --wb-chart-error: #f25a5a;", /* red-400,比 red-600 柔和 */
2005
+ "}",
2006
+ // 深色标记是 body[data-ds-dark-theme]——由主题 presenter 设置,
2007
+ // 与主题 id 无关(见 dsh-client-ui-theme 的 ThemeDefinition 注释)。
2008
+ // 用属性选择器猜 [data-theme='dark'] 是不生效的(实测踩过)。
2009
+ "body[data-ds-dark-theme] {",
2010
+ " --wb-chart-bar: #60a5fa;", /* blue-400,深色下提亮 */
2011
+ " --wb-chart-error: #f87171;", /* 深色下用更亮的红,避免暗沉 */
2012
+ "}",
2013
+ // 窄屏:4 列降为 2 列,3 列降为 2 列
2014
+ "@media (max-width: 560px) {",
2015
+ " .wb-kpi-grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }",
2016
+ " .wb-kpi-grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }",
2017
+ "}",
2018
+ // 极窄:全部单列
2019
+ "@media (max-width: 380px) {",
2020
+ " .wb-kpi-grid-4, .wb-kpi-grid-3 { grid-template-columns: minmax(0, 1fr) !important; }",
2021
+ "}",
2022
+ ].join("\n");
2023
+
2024
+ function apply(ctx) {
2025
+ // styles 是 Client 内置注入器;拿不到就跳过(不影响功能,只是少了响应式)
2026
+ const styleInjector = typeof styles !== "undefined" ? styles : undefined;
2027
+ if (styleInjector !== undefined) {
2028
+ ctx.effect(() => styleInjector.insert(RESPONSIVE_CSS));
2029
+ }
2030
+
2031
+ // 两个独立菜单:WorkBuddy 与 Trae 各占一个设置分区。
2032
+ // 它们共用 PoolSection 组件,只传不同的 profile——风格天然一致,
2033
+ // 也不必维护两份几乎相同的界面代码。
2034
+ for (const profile of Object.values(PROFILES)) {
2035
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
2036
+ name: "settings.section",
2037
+ id: profile.id,
2038
+ order: profile.order,
2039
+ label: profile.label,
2040
+ }, () => h(PoolSection, { profile })));
2041
+ }
2042
+ }
2043
+
2044
+ exports.name = name;
2045
+ exports.inject = inject;
2046
+ exports.apply = apply;
2047
+ return module.exports;
2048
+ }
2049
+ });