creditdaddy 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  ## 功能
12
12
 
13
13
  - **仪表盘 + 分产品标签页**:首页仪表盘汇总账号数、今日签到进度、剩余积分、下次自动签到,各产品概况、需要处理的账号(token 失效 / 即将过期、签到失败)与最近签到记录;Qoder / WorkBuddy / ZCode 各自一个标签页,卡片式账号列表(参考 WorkDaddy),可按国际 / 国内版筛选,显示签到状态、连签天数、剩余积分、积分包到期、token 有效期,支持亮色 / 暗色
14
- - **10Router 集成**:配置 10Router 地址 + 虚拟 key(sk-…)后,「10Router」标签页渲染 10Router 里其他供应商(Claude / Codex / Kiro / GLM …)的额度卡片,额度告急 / 查询失败会进仪表盘「需要处理」;并内置 10router-sync 插件的用量同步能力,每小时把本机 ZCode / OpenCode / mirasim / 小米 MiMo 的用量导入 10Router 的用量统计
14
+ - **10Router 集成**:配置 10Router 地址 + 虚拟 key(sk-…)后,「10Router」标签页**按供应商聚类**渲染 10Router 里其他供应商(CodeBuddy / Qoder / Claude / GLM …)的额度卡片(CodeBuddy / Qoder / GLM 系连接带**国内版 / 国际版**徽标,与账号卡同一语义),默认只显示主额度,其余收进一行摘要(明细可展开);额度告急 / 查询失败会进仪表盘「需要处理」,工具栏显示 10Router 健康(存储驱动降级有提示);并内置 10router-sync 插件的用量同步能力,每小时把本机 ZCode / OpenCode / mirasim / 小米 MiMo 的用量导入 10Router 的用量统计
15
15
  - **本机导入**:一键读取本机 Qoder / Qoder CN 客户端(本地解密 `auth.v1.dat`)与 WorkBuddy 客户端(当前 + 历史会话)已登录的账号,token 不出机器;同一用户 token 续期时自动更新
16
16
  - **WorkBuddy / ZCode 账号切换**:一键把客户端切换到选中的账号(WorkBuddy 自动应用;ZCode 需先退出客户端),切换前先保全当前登录,绝不丢号,每个账号独立设备指纹
17
17
  - **隐私(无痕)登录窗口**:桌面版内置一次性会话的登录窗口,网页授权不带出系统浏览器里已登录的账号、Cookie 也不落盘,适合同一产品登录多个账号
@@ -33,15 +33,15 @@
33
33
  - **浏览器登录**:`POST {域名}/v2/plugin/auth/state?platform=WorkBuddy` 取授权地址,用户登录后轮询 `/v2/plugin/auth/token?state=`(11217 = 等待)拿 token,再轮询 `/v2/plugin/login/account?state=`(12151 = 等待)与 `/v2/plugin/accounts` 补齐账号信息,组装成与 `workbuddy-desktop.info` 同结构的会话;国内版走 www.codebuddy.cn,国际版走 www.codebuddy.ai
34
34
  - **token 刷新**:只在 token 过期或接口返回 401 时用 refreshToken 刷新(`/v2/plugin/auth/token/refresh`);若该账号正是 WorkBuddy 客户端当前登录的账号,会把新 token 同步写回客户端,避免客户端掉线。refreshToken 也失效时(如在别处登出),需要在 WorkBuddy 重新登录后再「本机导入」同步
35
35
  - **切换账号**:写入 `workbuddy-desktop.info` 并清除登出标记,WorkBuddy 监听该文件并自动应用;切换前会先把客户端当前会话的最新 token 保存到 CreditDaddy
36
- - 国际版(workbuddy.ai / codebuddy.ai)的签到接口沿用同一路径,尚未经真实账号验证
36
+ - **国际版「活跃领取」**:国际版没有签到接口,官方规则为「当天有 ≥1 次有效对话请求即视为活跃用户,发放每日赠送积分」。调度器每天为每个国际版账号发一条免费档模型(rateMultiplier 0,~0 消耗)的极短流式请求(`POST {域名}/v2/chat/completions`,system 提示 + typed blocks 是网关必需格式),2xx 即当日活跃达成;额度耗尽时会得到「等官方发放后再试」的明确提示
37
37
 
38
38
  ## ZCode 说明
39
39
 
40
40
  - **账号来源**:ZCode 桌面客户端把当前登录存在 `~/.zcode/v2/credentials.json`(值用客户端私有的 `enc:v1:` AES-256-GCM 加密,密钥从机器信息派生),CreditDaddy 本地解密读取,token 不出机器
41
41
  - **浏览器登录**:与客户端的 CLI 轮询登录同一协议 —— `POST https://zcode.z.ai/api/v1/oauth/cli/init`(本地随机 poll token)拿授权地址(回调指向 zcode.z.ai 服务端,无需 `zcode://` 协议,任何浏览器都能用),用户登录后轮询 `/api/v1/oauth/cli/poll/{flow_id}`;Z.ai 账号再用 `api.z.ai/api/auth/z/login` 换业务 token。拿到的凭据按 `credentials.json` 格式用本机密钥加密保存,可直接切换到 ZCode 客户端
42
- - **没有每日签到,支持活动领取**:ZCode 积分靠「活动领取」(billing/preview → billing/claim)。点卡片上的领取按钮,弹窗列出当期活动并可领取 —— 领取时面板会跑阿里云验证码(先静默验证,触发风控时弹出验证码手动完成,任何浏览器环境都能用),故不做后台无人值守的自动签到
42
+ - **没有每日签到,活动领取可自动轮询(开关默认关)**:ZCode 积分靠「活动领取」(billing/preview → billing/claim)。标签页打开「自动领取」后,每轮签到结束会轮询可领活动:先试免验证码领取,需要验证码时 —— 桌面版用隐藏窗口跑阿里云验证码 SDK 静默验证(触发风控时弹出窗口让人工完成,2 分钟超时),NAS / 纯 CLI 环境标记「需手动领取」;平时保持关闭即可,活动期前再开。也可点卡片上的领取按钮手动领取。ZCode 卡片不显示任何签到 / 领取徽标,结果只看运行日志与仪表盘最近记录
43
43
  - **客户端版本跟随本机**:billing 类接口校验客户端版本(过低直接 3001),请求头里上报的版本取本机已安装 ZCode 的版本(Windows 注册表读取),没有客户端时用兜底值
44
- - **出口可切换**:ZCode 上游请求默认「直连优先、代理兜底」,标签页提示条里可切「代理优先、直连兜底」(代理取 `HTTPS_PROXY` 环境变量,http 代理 + CONNECT 隧道,纯 Node 内置实现;`NO_PROXY` 与 loopback 始终直连)
44
+ - **出口可切换**:ZCode 上游请求默认「直连优先、代理兜底」,标签页提示条里可切「代理优先、直连兜底」。代理地址可在面板配置(`zcode-net.json`,0600,优先),未配置时取 `HTTPS_PROXY` 环境变量;http 代理 + CONNECT 隧道,纯 Node 内置实现;`NO_PROXY` 与 loopback 始终直连
45
45
  - **额度展示**:查询 BigModel Coding Plan(quota/limit + subscription/list)与 Z.ai / Start Plan(billing/balance);balances 为空时从生效套餐的权益派生展示额度(未到生效时间的权益显示「xx 生效」);无有效套餐时显示「仅免费额度」
46
46
  - **账号切换**:写回 `credentials.json` / `config.json`,并为每个账号写入独立的设备 ID(`telemetry-state.json` 的 deviceMid),避免账号间被风控关联;需先退出 ZCode 客户端(运行中会覆盖回内存里的旧登录)。切换前当前登录会先保存进 CreditDaddy,不会丢号
47
47
  - 协议移植自 [zcode-switch](https://github.com/pjpv/zcode-switch)(MIT)
@@ -167,7 +167,8 @@ Qoder 侧(国际版 openapi.qoder.sh,国内版 openapi.qoder.com.cn):
167
167
  src/workbuddyClient.js WorkBuddy API:签到、积分、token 刷新
168
168
  src/workbuddyLocal.js 本机 WorkBuddy 会话读取与账号切换
169
169
  src/zcrypto.js ZCode 本机凭据加解密(enc:v1: AES-256-GCM)
170
- src/zcodeClient.js ZCode 额度查询 + 活动领取(billing preview/claim、验证码配置)
170
+ src/zcodeClient.js ZCode 额度查询 + 活动领取(billing preview/claim、验证码配置、出口切换)
171
+ src/zcodeAutoClaim.js ZCode 活动自动轮询领取(免验证码 → 静默验证码 → 需手动)
171
172
  src/zcodeLocal.js 本机 ZCode 凭据读取与账号切换
172
173
  desktop/preload.js 桌面版 preload:暴露隐私登录窗口能力
173
174
  src/checkin.js 调度器(2h tick + 当日去重 + 本机限领 + 串行执行)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "creditdaddy",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "CreditDaddy — Qoder / WorkBuddy / ZCode 多账号本地管理与每日积分自动签到助手",
5
5
  "homepage": "https://github.com/techysy/CreditDaddy",
6
6
  "repository": {
package/src/checkin.js CHANGED
@@ -14,6 +14,8 @@ import { productImpl } from './providers.js';
14
14
  import { productOf } from './constants.js';
15
15
  import { startUsageSyncScheduler } from './tenrouter.js';
16
16
  import { refreshContext } from './accounts.js';
17
+ import { zcodeAutoClaim } from './zcodeAutoClaim.js';
18
+ import { autoClaimEnabled } from './zcodeClient.js';
17
19
  import { logger } from './logger.js';
18
20
 
19
21
  const TICK_MS = 2 * 60 * 60 * 1000; // 2 小时
@@ -70,15 +72,24 @@ async function runTickNow(opts) {
70
72
  }
71
73
 
72
74
  async function runTickInner(opts) {
75
+ const allAccounts = await loadAccounts();
73
76
  // 没有每日签到能力的产品(如 ZCode:积分靠需验证码的活动领取)不参与签到轮
74
- const accounts = (await loadAccounts()).filter(
77
+ const accounts = allAccounts.filter(
75
78
  (a) => (!opts.provider || a.provider === opts.provider)
76
79
  && (!opts.product || productOf(a.provider) === opts.product)
77
80
  && (!opts.onlyAccountId || a.id === opts.onlyAccountId)
78
81
  && typeof productImpl(a.provider)?.checkin === 'function'
79
82
  );
80
83
 
81
- if (accounts.length === 0) {
84
+ // ZCode 活动轮询领取:开关打开(标签页可切换,默认关)且未被筛选参数排除时参与
85
+ const zAccounts = (autoClaimEnabled()
86
+ && !opts.onlyAccountId
87
+ && (!opts.provider || opts.provider === 'zcode')
88
+ && (!opts.product || opts.product === 'zcode'))
89
+ ? allAccounts.filter((a) => a.provider === 'zcode')
90
+ : [];
91
+
92
+ if (accounts.length === 0 && zAccounts.length === 0) {
82
93
  return { results: [], summary: '没有匹配的账号' };
83
94
  }
84
95
 
@@ -152,6 +163,24 @@ async function runTickInner(opts) {
152
163
  });
153
164
  await saveState({ ...state, qoderDailyDone: memo, deviceClaim });
154
165
 
166
+ // ZCode 活动自动领取:每轮签到后顺带轮询(preview 为空时零请求副作用;需开关打开)。
167
+ // 桌面版已注册隐藏窗口验证码提供者,可静默过验证码;其它环境需要验证码时标记「需手动领取」。
168
+ for (const za of zAccounts) {
169
+ const label = za.name || za.uid || za.id;
170
+ try {
171
+ const r = await zcodeAutoClaim(za);
172
+ if (!r) continue;
173
+ results.push({ accountId: za.id, account: label, provider: 'zcode', status: r.status, message: r.message });
174
+ const lastResult = { status: r.status, message: r.message, amount: 0, at: new Date().toISOString() };
175
+ await withAccounts((list) => {
176
+ const cur = list.find((a) => a.id === za.id);
177
+ if (cur) cur.lastResult = lastResult;
178
+ });
179
+ } catch (e) {
180
+ logger.warn('ZCODE-CLAIM', `${label} 自动领取异常:${e.message}`);
181
+ }
182
+ }
183
+
155
184
  const claimed = results.filter((r) => r.status === 'checked-in');
156
185
  const failed = results.filter((r) => r.status === 'failed');
157
186
  const none = results.filter((r) => r.status === 'no-activity');
package/src/daemon.js CHANGED
@@ -50,7 +50,7 @@ import { addAccount, importAccounts, refreshContext } from './accounts.js';
50
50
  import { productImpl } from './providers.js';
51
51
  import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
52
52
  import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode } from './zcodeLocal.js';
53
- import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst } from './zcodeClient.js';
53
+ import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, setAutoClaimEnabled } from './zcodeClient.js';
54
54
  import { exportAccounts, parseImport, TransferError } from './transfer.js';
55
55
  import { runCheckinTick, getSchedulerInfo, dayKey } from './checkin.js';
56
56
  import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource } from './qoderApp.js';
@@ -246,6 +246,10 @@ async function handleApi(req, res, url) {
246
246
  region: typeof body.region === 'string' ? body.region : '',
247
247
  });
248
248
  logger.info('DAEMON', `ZCode ${account.name || account.id} 领取成功:${outcome.planName}`);
249
+ await withAccounts((list) => {
250
+ const cur = list.find((a) => a.id === account.id);
251
+ if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName}`, amount: 0, at: new Date().toISOString() };
252
+ });
249
253
  return json(res, 200, outcome);
250
254
  } catch (e) {
251
255
  logger.warn('DAEMON', `ZCode ${account.name || account.id} 领取失败:${e.message}`);
@@ -259,15 +263,21 @@ async function handleApi(req, res, url) {
259
263
  return json(res, 502, { error: e.message });
260
264
  }
261
265
  }
262
- // ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」(存数据目录 zcode-net.json)
266
+ // ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600)
263
267
  if (p === '/api/zcode/net' && method === 'GET') {
264
- return json(res, 200, { proxyFirst: proxyFirst() });
268
+ const u = proxyUrl();
269
+ const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
270
+ return json(res, 200, { proxyFirst: proxyFirst(), proxyUrlMasked: masked, autoClaim: autoClaimEnabled(), hasEnvProxy: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy) });
265
271
  }
266
272
  if (p === '/api/zcode/net' && method === 'PUT') {
267
273
  const body = await readBody(req).catch(() => ({}));
268
- setProxyFirst(body?.proxyFirst === true);
269
- logger.info('DAEMON', `ZCode 出口切换为:${proxyFirst() ? '代理优先' : '直连优先'}`);
270
- return json(res, 200, { proxyFirst: proxyFirst() });
274
+ try {
275
+ if (body?.proxyFirst !== undefined) setProxyFirst(body.proxyFirst === true);
276
+ if (body?.proxyUrl !== undefined) setProxyUrl(body.proxyUrl);
277
+ if (body?.autoClaim !== undefined) setAutoClaimEnabled(body.autoClaim === true);
278
+ } catch (e) { return json(res, 400, { error: e.message }); }
279
+ logger.info('DAEMON', `ZCode 出口:${proxyFirst() ? '代理优先' : '直连优先'},自动领取:${autoClaimEnabled() ? '开' : '关'},代理 ${proxyUrl() || '(环境变量/未设置)'}`);
280
+ return json(res, 200, { proxyFirst: proxyFirst(), autoClaim: autoClaimEnabled() });
271
281
  }
272
282
 
273
283
  // 切换 WorkBuddy / ZCode 客户端当前登录账号
package/src/panel.html CHANGED
@@ -89,18 +89,18 @@
89
89
  html[data-theme="dark"] .badge.prod.workbuddy { color: #5eead4; }
90
90
  .badge.prod.zcode { color: #1d4ed8; background: rgba(37,99,235,.1); }
91
91
  html[data-theme="dark"] .badge.prod.zcode { color: #93c5fd; }
92
- .pill.cur { background: rgba(20,184,166,.12); color: #0f766e; }
93
- html[data-theme="dark"] .pill.cur { color: #5eead4; }
94
92
  .seg-ctl { display: inline-flex; gap: 2px; padding: 2px; border-radius: 9px; background: var(--hover); }
95
93
  .seg-ctl button { border: none; background: transparent; color: var(--text-2); font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 7px; cursor: pointer; }
96
94
  .seg-ctl button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
97
95
  html[data-theme="dark"] .seg-ctl button.active { background: rgba(255,255,255,.1); }
98
- .pill { display: inline-flex; align-items: center; height: 22px; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; border: 1px solid transparent; }
99
- .pill.ok { background: var(--text); color: var(--surface); }
100
- html[data-theme="dark"] .pill.ok { background: rgba(255,255,255,.085); color: var(--text); border-color: rgba(255,255,255,.12); }
101
- .pill.pending { background: var(--tertiary); color: var(--text-3); }
102
- .pill.limited { background: var(--warn-soft); color: var(--warn); }
103
- .pill.fail { background: var(--err-soft); color: var(--err); }
96
+ /* 胶囊徽章:不要类型背景色,细边框 + 彩色文字即可 */
97
+ .pill { display: inline-flex; align-items: center; height: 22px; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; border: 1px solid var(--border); background: transparent; color: var(--text-2); }
98
+ .pill.ok { color: var(--text); border-color: var(--text); }
99
+ .pill.pending { color: var(--text-3); }
100
+ .pill.limited { color: var(--warn); border-color: var(--warn-soft); }
101
+ .pill.fail { color: var(--err); border-color: var(--err-soft); }
102
+ .pill.cur { color: #0f766e; border-color: rgba(20,184,166,.5); }
103
+ html[data-theme="dark"] .pill.cur { color: #5eead4; }
104
104
  .ops { display: flex; gap: 6px; flex-shrink: 0; }
105
105
 
106
106
  .meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; font-size: 12px; }
@@ -184,6 +184,7 @@
184
184
  .kpi-label { color: var(--text-3); font-size: 12px; font-weight: 500; }
185
185
  .kpi-value { font-size: 26px; font-weight: 700; letter-spacing: -.5px; margin-top: 4px; font-variant-numeric: tabular-nums; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
186
186
  .kpi-value small { font-size: 14px; color: var(--text-3); font-weight: 600; letter-spacing: 0; }
187
+ .kpi-value .kpi-side { font-size: 13px; color: var(--text-2); font-weight: 600; letter-spacing: 0; }
187
188
  .kpi-sub { color: var(--text-3); font-size: 11px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
188
189
  .prods { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px; margin-bottom: 10px; }
189
190
  .prodcard { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: border-color .15s, box-shadow .15s; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
@@ -215,9 +216,15 @@
215
216
  .badge.prod.tenrouter { color: #b45309; background: rgba(245,158,11,.14); }
216
217
  html[data-theme="dark"] .badge.prod.tenrouter { color: #fcd34d; }
217
218
  /* 瀑布流:卡片高低差异大(积分包多的卡很长),按列填充避免大片空白 */
218
- .tr-grid { columns: 3 320px; column-gap: 10px; padding: 12px; }
219
- .tr-grid > .empty { column-span: all; }
220
- .tr-card { border: 1px solid var(--border-subtle); border-radius: 12px; padding: 11px 12px 12px; background: var(--surface); min-width: 0; break-inside: avoid; margin-bottom: 10px; display: inline-block; width: 100%; }
219
+ .tr-grid { padding: 12px; display: flex; flex-direction: column; gap: 14px; }
220
+ .tr-vrow { }
221
+ .tr-vhead { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
222
+ .tr-vhead .dim { font-size: 12px; }
223
+ .tr-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 2px 12px 4px; }
224
+ .tr-vcards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; align-items: stretch; }
225
+ @media (max-width: 1180px) { .tr-vcards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
226
+ @media (max-width: 820px) { .tr-vcards { grid-template-columns: 1fr; } }
227
+ .tr-card { border: 1px solid var(--border-subtle); border-radius: 12px; padding: 11px 12px 12px; background: var(--surface); min-width: 0; }
221
228
  .tr-card.off { opacity: .6; }
222
229
  .tr-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
223
230
  .tr-title { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
@@ -230,6 +237,9 @@
230
237
  .tr-meter { height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); overflow: hidden; }
231
238
  .tr-meter span { display: block; height: 100%; border-radius: 3px; background: rgb(var(--accent-rgb)); }
232
239
  .tr-meter span.low { background: var(--err); }
240
+ .tr-meter span.mid { background: #d97706; }
241
+ .tr-reset { color: var(--text-3); font-weight: 400; }
242
+ .tr-reset.soon { color: var(--err); font-weight: 600; }
233
243
  .tr-reset { font-size: 11px; color: var(--text-3); margin-top: 3px; }
234
244
  .tr-primary { margin: 2px 0 6px; }
235
245
  .tr-primary .lbl { max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -241,6 +251,8 @@
241
251
  .tr-p-val.low b { color: var(--err); }
242
252
  .tr-meter.big { height: 7px; }
243
253
  .tr-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; margin-top: 6px; }
254
+ .tr-summary { display: flex; align-items: baseline; gap: 6px; font-size: 12px; margin-top: 6px; }
255
+ .tr-sum-txt { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
244
256
  .low-text { color: var(--err); font-weight: 600; }
245
257
  .tr-q-when { color: var(--text-3); font-weight: 400; }
246
258
  .tr-q.spent .tr-q-name, .tr-q.spent .tr-q-val { color: var(--text-3); }
@@ -270,7 +282,7 @@
270
282
  <img class="logo" alt="" src="data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPjxwYXRoIGQ9Ik0zNzIuNDggMTM4LjY2NjY2N0ExMjggMTI4IDAgMCAwIDI5OC42NjY2NjcgMTAxLjU0NjY2N2ExMTcuNzYgMTE3Ljc2IDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzNsNzEuMjUzMzMzIDkxLjUyLTgxLjI4IDI4LjM3MzMzM2ExMC42NjY2NjcgMTAuNjY2NjY3IDAgMCAwLTIuMzQ2NjY3IDE4Ljk4NjY2NyAyNDIuNTYgMjQyLjU2IDAgMCAwIDY5Ljc2IDMyLjg1MzMzMyAyNzMuNzA2NjY3IDI3My43MDY2NjcgMCAwIDAgNTYuOTYgOC4zMnoiIGZpbGw9IiNGRjhBMTQiPjwvcGF0aD48cGF0aCBkPSJNMjQ1Ljc2IDEzOC4wMjY2NjdhMTMwLjk4NjY2NyAxMzAuOTg2NjY3IDAgMCAxIDUyLjA1MzMzMyAyLjEzMzMzM0ExNDguMDUzMzMzIDE0OC4wNTMzMzMgMCAwIDEgMzY2LjcyIDE3MC42NjY2NjdsNS43Ni0zMi44NTMzMzRBMTI4IDEyOCAwIDAgMCAyOTguNjY2NjY3IDEwMS41NDY2NjdhMTE3Ljk3MzMzMyAxMTcuOTczMzMzIDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzN6IiBmaWxsPSIjRkZBQTU0Ij48L3BhdGg+PHBhdGggZD0iTTIzNC42NjY2NjcgOTcwLjY2NjY2N2EyNzcuMzMzMzMzIDMyIDAgMSAwIDU1NC42NjY2NjYgMCAyNzcuMzMzMzMzIDMyIDAgMSAwLTU1NC42NjY2NjYgMFoiIGZpbGw9IiM0NTQxM0MiIG9wYWNpdHk9Ii4xNSI+PC9wYXRoPjxwYXRoIGQ9Ik02MjAuMzczMzMzIDQ2MC41ODY2NjdzMTUxLjA0LTkwLjY2NjY2NyAxODEuMzMzMzM0LTc0LjY2NjY2Ny0xMi41ODY2NjcgMTc0LjI5MzMzMy0xMDYuNjY2NjY3IDIxMy4zMzMzMzMtNzQuNjY2NjY3LTEzOC42NjY2NjctNzQuNjY2NjY3LTEzOC42NjY2NjZ6IiBmaWxsPSIjRkZFNTAwIj48L3BhdGg+PHBhdGggZD0iTTYyMC4zNzMzMzMgNTEzLjkyczE1MS4wNC05MC42NjY2NjcgMTgxLjMzMzMzNC03NC42NjY2NjdhMTQuNzIgMTQuNzIgMCAwIDEgNS4zMzMzMzMgNS45NzMzMzRjNi44MjY2NjctMjkuODY2NjY3IDUuMzMzMzMzLTUzLjU0NjY2Ny01LjMzMzMzMy01OS4zMDY2NjctMzAuMjkzMzMzLTE2LTE4MS4zMzMzMzMgNzQuNjY2NjY3LTE4MS4zMzMzMzQgNzQuNjY2NjY3YTM4Ni45ODY2NjcgMzg2Ljk4NjY2NyAwIDAgMCAwIDYxLjg2NjY2NmMtMC40MjY2NjctNS4zMzMzMzMgMC04LjUzMzMzMyAwLTguNTMzMzMzeiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik00MDMuNjI2NjY3IDQ2MC41ODY2NjdzLTE1MS4wNC05MC42NjY2NjctMTgxLjMzMzMzNC03NC42NjY2NjcgMTIuMzczMzMzIDE3NC4yOTMzMzMgMTA2LjY2NjY2NyAyMTMuMzMzMzMzIDc0LjY2NjY2Ny0xMzguNjY2NjY3IDc0LjY2NjY2Ny0xMzguNjY2NjY2eiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik0yMjIuMjkzMzMzIDQzOS4yNTMzMzNjMzAuMjkzMzMzLTE2IDE4MS4zMzMzMzMgNzQuNjY2NjY3IDE4MS4zMzMzMzQgNzQuNjY2NjY3djguNTMzMzMzYTM3My41NDY2NjcgMzczLjU0NjY2NyAwIDAgMCAwLTYxLjg2NjY2NnMtMTUxLjA0LTkwLjY2NjY2Ny0xODEuMzMzMzM0LTc0LjY2NjY2N2MtMTAuNjY2NjY3IDUuNzYtMTIuMTYgMjkuNDQtNS4zMzMzMzMgNTkuMzA2NjY3YTEzLjQ0IDEzLjQ0IDAgMCAxIDUuMzMzMzMzLTUuOTczMzM0eiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik0yODggNjUwLjY2NjY2N2EyNjYuNjY2NjY3IDIyNCA5MCAxIDAgNDQ4IDAgMjY2LjY2NjY2NyAyMjQgOTAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik02ODAuMTA2NjY3IDQ3NC4yNGEyMjQgMjI0IDAgMCAxLTMzNi4yMTMzMzQgMEEyMTAuNTYgMjEwLjU2IDAgMCAxIDUxMiAzODRhMjEwLjU2IDIxMC41NiAwIDAgMSAxNjguMTA2NjY3IDkwLjI0eiIgZmlsbD0iI0VCQ0IwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMjY2LjY2NjY2N20tMjI0IDBhMjI0IDIyNCAwIDEgMCA0NDggMCAyMjQgMjI0IDAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMTAxLjk3MzMzM2EyMjQuMjEzMzMzIDIyNC4yMTMzMzMgMCAwIDEgMjIxLjg2NjY2NyAxOTQuMzQ2NjY3IDIyNCAyMjQgMCAxIDAtNDQzLjczMzMzNCAwQTIyMy43ODY2NjcgMjIzLjc4NjY2NyAwIDAgMSA1MTIgMTAxLjk3MzMzM3oiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48cGF0aCBkPSJNODMzLjQ5MzMzMyA1OTguODI2NjY3bC03MC40IDcwLjRhMTguMzQ2NjY3IDE4LjM0NjY2NyAwIDAgMS0yNS44MTMzMzMgMEw2NDQuMDUzMzMzIDU3NkExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAwIDYxOC42NjY2NjcgNTc2bC05My4wMTMzMzQgOTMuMjI2NjY3YTE4Ljc3MzMzMyAxOC43NzMzMzMgMCAwIDEtMjYuMDI2NjY2IDBMNDA1LjMzMzMzMyA1NzZhMTguMTMzMzMzIDE4LjEzMzMzMyAwIDAgMC0yNS44MTMzMzMgMGwtOTMuMjI2NjY3IDkzLjIyNjY2N2ExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwbC03MC40LTcwLjRhOS4xNzMzMzMgOS4xNzMzMzMgMCAwIDAtMTUuNzg2NjY3IDYuODI2NjY2QzE4OS44NjY2NjcgODQxLjgxMzMzMyAzMzQuOTMzMzMzIDk2MCA1MTIgOTYwczMyMi4xMzMzMzMtMTE4LjE4NjY2NyAzMzcuMjgtMzU0LjM0NjY2N2E5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZ6IiBmaWxsPSIjRjBGMEYwIj48L3BhdGg+PHBhdGggZD0iTTE5MC41MDY2NjcgNjY1LjgxMzMzM2w3MC40IDYxLjIyNjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAwIDI1LjgxMzMzMyAwbDkzLjIyNjY2Ny04MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMSAyNS44MTMzMzMgMGw5My4yMjY2NjcgODEuMDY2NjY3YTIxLjMzMzMzMyAyMS4zMzMzMzMgMCAwIDAgMjYuMDI2NjY2IDBsOTMuNjUzMzM0LTgxLjA2NjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAxIDI2LjAyNjY2NiAwbDkzLjIyNjY2NyA4MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMCAyNS44MTMzMzMgMGw3MC40LTYxLjIyNjY2N2E5LjgxMzMzMyA5LjgxMzMzMyAwIDAgMSA4LjUzMzMzNC0xLjkyIDUzNi4xMDY2NjcgNTM2LjEwNjY2NyAwIDAgMCA3LjI1MzMzMy01OC4yNCA5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZsLTcwLjQgNzAuNGExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwTDY0NC4wNTMzMzMgNTc2QTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDAgNjE4LjY2NjY2NyA1NzZsLTkzLjAxMzMzNCA5My4yMjY2NjdhMTguNzczMzMzIDE4Ljc3MzMzMyAwIDAgMS0yNi4wMjY2NjYgMEw0MDUuMzMzMzMzIDU3NmExOC4xMzMzMzMgMTguMTMzMzMzIDAgMCAwLTI1LjgxMzMzMyAwbC05My4yMjY2NjcgOTMuMjI2NjY3YTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDEtMjUuODEzMzMzIDBsLTcwLjQtNzAuNGE5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjcgNi44MjY2NjYgNTM3LjE3MzMzMyA1MzcuMTczMzMzIDAgMCAwIDcuMDQgNTguMjQgMTAuMDI2NjY3IDEwLjAyNjY2NyAwIDAgMSA5LjE3MzMzNCAxLjkyeiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPjxwYXRoIGQ9Ik00OTguMTMzMzMzIDE4NC41MzMzMzNtLTI5LjAxMzMzMyAwYTI5LjAxMzMzMyAyOS4wMTMzMzMgMCAxIDAgNTguMDI2NjY3IDAgMjkuMDEzMzMzIDI5LjAxMzMzMyAwIDEgMC01OC4wMjY2NjcgMFoiIGZpbGw9IiM0NTQxM0MiPjwvcGF0aD48cGF0aCBkPSJNNTc2IDI5MC45ODY2NjdjMCAxMS43MzMzMzMtMTUuNzg2NjY3IDIxLjMzMzMzMy0zNC45ODY2NjcgMjEuMzMzMzMzcy0zNS4yLTkuNi0zNS4yLTIxLjMzMzMzMyAxNS43ODY2NjctMjEuMzMzMzMzIDM1LjItMjEuMzMzMzM0IDM0Ljk4NjY2NyA5LjYgMzQuOTg2NjY3IDIxLjMzMzMzNHoiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48L3N2Zz4=">
271
283
  <div>
272
284
  <div class="title">CreditDaddy</div>
273
- <div class="ver" id="ver">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动签到</div>
285
+ <div class="ver" id="ver">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取</div>
274
286
  </div>
275
287
  </div>
276
288
  <div class="head-right">
@@ -302,8 +314,8 @@
302
314
  <div class="kpis" id="kpis"></div>
303
315
  <div class="prods" id="prods"></div>
304
316
  <div class="dash-cols">
317
+ <section class="pcard"><div class="pcard-title"><span>最近记录</span><span class="hint" id="last-tick"></span></div><div id="recent"></div></section>
305
318
  <section class="pcard"><div class="pcard-title"><span>需要处理</span><span class="hint" id="att-count"></span></div><div id="attention"></div></section>
306
- <section class="pcard"><div class="pcard-title"><span>最近签到</span><span class="hint" id="last-tick"></span></div><div id="recent"></div></section>
307
319
  </div>
308
320
  </section>
309
321
 
@@ -315,7 +327,7 @@
315
327
  <div class="stat"><span>账号</span><strong id="s-count">0</strong></div>
316
328
  <div class="divider"></div>
317
329
  <div id="s-done-wrap" style="display:contents">
318
- <div class="stat"><span>今日已签</span><strong id="s-done">0</strong></div>
330
+ <div class="stat"><span>今日已领</span><strong id="s-done">0</strong></div>
319
331
  <div class="divider"></div>
320
332
  </div>
321
333
  <div class="stat"><span id="s-credits-label">剩余积分</span><strong id="s-credits">-</strong></div>
@@ -434,6 +446,9 @@
434
446
  <div id="tr-sources" style="margin-top:6px"></div>
435
447
  <div class="hint" style="margin-top:4px">ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。10Router 按行签名去重,重复同步不会产生重复数据。</div>
436
448
  </div>
449
+ <div class="field"><label>显示</label>
450
+ <div><label class="check"><input type="checkbox" id="tr-hide-empty"> 「全部」视图隐藏没有额度 / 余额为 0 的供应商汇总卡</label></div>
451
+ </div>
437
452
  <div class="hint" id="tr-test-msg"></div>
438
453
  <div class="modal-actions">
439
454
  <button class="btn" id="tr-disconnect" style="margin-right:auto">断开</button>
@@ -583,6 +598,9 @@ async function api(p, opts = {}, attempt = 0) {
583
598
  return data;
584
599
  }
585
600
 
601
+ // 10Router 汇总卡「隐藏无额度」偏好(面板本地)
602
+ try { state.trHideEmpty = localStorage.getItem('qd_tr_hideempty') === '1'; } catch (e) {}
603
+
586
604
  // ── 主题 ──
587
605
  function applyTheme(t) {
588
606
  document.documentElement.dataset.theme = t;
@@ -601,22 +619,25 @@ function initTheme() {
601
619
  }
602
620
 
603
621
  // ── 状态 / 渲染 ──
622
+ // 统一「领鸡蛋」语义:Qoder / WorkBuddy 国内/国际 = 领取积分;workbuddy-intl = 保持活跃(不保证奖励)
604
623
  function statusOf(a) {
605
- // ZCode 没有每日签到(只有活动领取),不显示签到徽标
606
- if (!CHECKIN_PRODUCTS.has(a.product)) return { cls: '', text: '', msg: '' };
624
+ if (!CHECKIN_PRODUCTS.has(a.product)) return { cls: '', text: '', msg: '' }; // ZCode 无每日领取,不显示徽标
625
+ const intl = a.provider === 'workbuddy-intl';
607
626
  const today = state.status && state.status.today;
608
627
  const done = state.status && state.status.todayDone && state.status.todayDone[a.id] === today;
609
628
  const r = a.lastResult;
610
629
  const fresh = r && r.at && state.status && (Date.now() - new Date(r.at).getTime() < 26 * 3600e3);
611
630
  if (r && fresh && r.status === 'limited' && done) return { cls: 'limited', text: '本机已领', msg: r.message };
612
631
  if (done) {
632
+ if (intl) return { cls: 'ok', text: '今日活跃', msg: r && r.message ? r.message : '' };
613
633
  const amt = r && r.status === 'checked-in' && fresh && r.amount ? ' +' + fmtNum(r.amount) : '';
614
- const streak = r && fresh && r.streakDays ? ' · 连签 ' + r.streakDays + ' 天' : '';
615
- return { cls: 'ok', text: '今日已签' + amt + streak, msg: r && r.message && !['checked-in', 'already'].includes(r.status) ? r.message : '' };
634
+ const streak = r && !intl && r.streakDays ? ' · 连续 ' + r.streakDays + ' 天' : '';
635
+ return { cls: 'ok', text: '今日已领' + amt + streak, msg: r && r.message && !['checked-in', 'already'].includes(r.status) ? r.message : '' };
616
636
  }
617
- if (r && fresh && r.status === 'failed') return { cls: 'fail', text: '签到失败', msg: r.message, err: true };
618
- if (r && fresh && r.status === 'no-activity') return { cls: 'pending', text: '待领取', msg: r.message };
619
- return { cls: 'pending', text: '未签到', msg: '' };
637
+ const failText = intl ? '活跃失败' : '领取失败';
638
+ if (r && fresh && r.status === 'failed') return { cls: 'fail', text: failText, msg: r.message, err: true };
639
+ if (r && fresh && r.status === 'no-activity') return { cls: 'pending', text: intl ? '待活跃' : '待领取', msg: r.message };
640
+ return { cls: 'pending', text: intl ? '未活跃' : '未领取', msg: '' };
620
641
  }
621
642
 
622
643
  function expiryCell(a) {
@@ -675,7 +696,7 @@ function cardHtml(a) {
675
696
  + (st.text ? '<span class="pill ' + st.cls + '" title="' + esc(a.lastResult ? fmtTime(a.lastResult.at) + ' ' + (a.lastResult.message || a.lastResult.status) : '') + '">' + esc(st.text) + '</span>' : '')
676
697
  + '</div><div class="ops">'
677
698
  + (a.product === 'zcode' ? '<button class="icon-btn" data-act="claim" title="活动领取(billing/preview → claim,可能需过一次验证码)">' + I.gift + '</button>' : '')
678
- + (canCheckin ? '<button class="icon-btn' + (state.busy[a.id] ? ' busy' : '') + '" data-act="checkin" title="立即签到">' + (state.busy[a.id] ? I.refresh : I.play) + '</button>' : '')
699
+ + (canCheckin ? '<button class="icon-btn' + (state.busy[a.id] ? ' busy' : '') + '" data-act="checkin" title="' + (a.provider === 'workbuddy-intl' ? '发送活跃请求' : '立即领取') + '">' + (state.busy[a.id] ? I.refresh : I.play) + '</button>' : '')
679
700
  + (CLIENT_LABEL[a.product] && a.canSwitch && !isCurrent && st.cls !== 'fail' ? '<button class="icon-btn" data-act="switch" title="切换 ' + CLIENT_LABEL[a.product] + ' 客户端到此账号">' + I.swap + '</button>' : '')
680
701
  + '<button class="icon-btn" data-act="quota" title="刷新积分">' + I.refresh + '</button>'
681
702
  + '<button class="icon-btn del" data-act="del" title="删除(点两次确认)">' + I.trash + '</button>'
@@ -741,7 +762,17 @@ function renderProd() {
741
762
  const canCheckin = CHECKIN_PRODUCTS.has(prod);
742
763
  $('s-count').textContent = acc.length;
743
764
  $('s-done-wrap').style.display = canCheckin ? 'contents' : 'none';
744
- $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
765
+ if (prod === 'workbuddy') {
766
+ // 国内版 = 每日签到送积分;国际版 = 保持活跃(活跃不等于必有奖励)
767
+ const cnAcc = acc.filter((a) => a.provider === 'workbuddy');
768
+ const intlAcc = acc.filter((a) => a.provider === 'workbuddy-intl');
769
+ $('s-done-wrap').innerHTML = (cnAcc.length ? '<div class="stat"><span>领取(国内)</span><strong>' + cnAcc.filter(isDone).length + ' / ' + cnAcc.length + '</strong></div><div class="divider"></div>' : '')
770
+ + (intlAcc.length ? '<div class="stat"><span>活跃(国际)</span><strong>' + intlAcc.filter(isDone).length + ' / ' + intlAcc.length + '</strong></div><div class="divider"></div>' : '');
771
+ } else {
772
+ // 从 WorkBuddy 页切回来时 s-done 可能被 innerHTML 换没了,先重建结构再写值
773
+ if (!$('s-done')) $('s-done-wrap').innerHTML = '<div class="stat"><span>今日已领</span><strong id="s-done"></strong></div><div class="divider"></div>';
774
+ $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
775
+ }
745
776
  const c = creditSum(acc);
746
777
  $('s-credits-label').textContent = prod === 'zcode' ? '有套餐' : '剩余积分';
747
778
  $('s-credits').textContent = prod === 'zcode'
@@ -752,7 +783,10 @@ function renderProd() {
752
783
  $('region-div').style.display = hasRegions ? '' : 'none';
753
784
  document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
754
785
  $('btn-checkin').style.display = canCheckin ? '' : 'none';
755
- if (!$('btn-checkin').disabled) $('btn-checkin').innerHTML = I.play + '签到全部 ' + prodLabel(prod);
786
+ if (!$('btn-checkin').disabled) {
787
+ const hasIntl = acc.some((a) => a.provider === 'workbuddy-intl');
788
+ $('btn-checkin').innerHTML = I.play + (prod === 'workbuddy' && hasIntl ? '领取 / 活跃全部' : '领取全部 ' + prodLabel(prod));
789
+ }
756
790
  }
757
791
 
758
792
  function kpi(label, value, sub) {
@@ -769,12 +803,21 @@ function renderDash() {
769
803
  const credit = creditSum(state.accounts.filter((a) => a.product !== 'zcode'));
770
804
  const byProd = PRODUCTS.map((p) => p.label + ' ' + ofProduct(p.id).length).join(' · ');
771
805
  const creditByProd = ['qoder', 'workbuddy'].map((id) => { const c = creditSum(ofProduct(id)); return prodLabel(id) + ' ' + (c.known ? fmtNum(c.sum) : '-'); }).join(' · ');
806
+ // 今日进度拆「国内领取 / 国际活跃」:国际版 WorkBuddy 是保持活跃(活跃不等于必有奖励)
807
+ const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl');
808
+ const domAcc = signable.filter((a) => a.provider !== 'workbuddy-intl');
809
+ const doneDom = domAcc.filter(isDone).length;
810
+ const doneIntl = intlAcc.filter(isDone).length;
772
811
  $('kpis').innerHTML =
773
812
  kpi('账号', state.accounts.length, byProd)
774
- + kpi('今日已签', done + ' <small>/ ' + signable.length + '</small>',
775
- !signable.length ? '暂无可签到的账号' : done === signable.length ? '今天全部签完' : '待签 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : ''))
813
+ + kpi('今日领取' + (intlAcc.length ? ' / 活跃' : ''), intlAcc.length
814
+ ? doneDom + ' <small>/ ' + domAcc.length + '</small><small class="kpi-side"> · 活跃 ' + doneIntl + ' / ' + intlAcc.length + '</small>'
815
+ : done + ' <small>/ ' + signable.length + '</small>',
816
+ !signable.length ? '暂无可领取的账号'
817
+ : (doneDom === domAcc.length && doneIntl === intlAcc.length ? (intlAcc.length ? '今天全部完成(国际为活跃请求,不保证奖励)' : '今天全部签完')
818
+ : '待办 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : '')))
776
819
  + kpi('剩余积分', credit.known ? fmtNum(credit.sum) : '-', credit.loading ? '读取中(' + credit.loading + ' 个账号)…' : creditByProd)
777
- + kpi('下次自动签到', sch.ticking ? '签到中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
820
+ + kpi('下次自动领取', sch.ticking ? '领取中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
778
821
  sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : '每 ~2 小时一轮');
779
822
 
780
823
  $('prods').innerHTML = PRODUCTS.map((p) => {
@@ -785,12 +828,21 @@ function renderDash() {
785
828
  } else if (CHECKIN_PRODUCTS.has(p.id)) {
786
829
  const d = list.filter(isDone).length;
787
830
  const c = creditSum(list);
788
- rows = '<span class="lbl">今日签到</span><span class="val">' + d + ' / ' + list.length + '</span>'
789
- + '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
831
+ if (p.id === 'workbuddy') {
832
+ // 国内 = 每日签到;国际 = 保持活跃(活跃不等于必有奖励)
833
+ const cnList = list.filter((a) => a.provider === 'workbuddy');
834
+ const intlList = list.filter((a) => a.provider === 'workbuddy-intl');
835
+ if (cnList.length) rows += '<span class="lbl">领取(国内)</span><span class="val">' + cnList.filter(isDone).length + ' / ' + cnList.length + '</span>';
836
+ if (intlList.length) rows += '<span class="lbl">活跃(国际)</span><span class="val">' + intlList.filter(isDone).length + ' / ' + intlList.length + '(不保证奖励)</span>';
837
+ } else {
838
+ rows = '<span class="lbl">今日领取</span><span class="val">' + d + ' / ' + list.length + '</span>';
839
+ }
840
+ rows += '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
790
841
  + '<span class="lbl">剩余积分</span><span class="val">' + (c.known ? fmtNum(c.sum) : '-') + '</span>';
791
842
  } else {
792
843
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
793
- rows = '<span class="lbl">每日签到</span><span class="val dim">无(活动领取需验证码)</span>'
844
+ // ZCode 没有每日签到:不展示签到语义的行
845
+ rows = '<span class="lbl">活动领取</span><span class="val dim">手动领取,或在标签页开自动轮询</span>'
794
846
  + '<span class="lbl">有效套餐</span><span class="val">' + planned + ' / ' + list.length + '</span>';
795
847
  }
796
848
  if (p.id === 'qoder' && list.some((a) => a.provider === 'qoder')) {
@@ -808,7 +860,7 @@ function renderDash() {
808
860
  + '<span class="badge">' + list.length + ' 个账号</span></span><span class="prod-go">查看 →</span></div><div class="prod-rows">' + rows + '</div></div>';
809
861
  }).join('') + trDashCard();
810
862
 
811
- // 需要处理:token 失效 / 即将过期、签到失败、国际版缺风控身份、10Router 额度告急 / 查询失败
863
+ // 需要处理:token 失效 / 即将过期、领取失败、国际版缺风控身份、10Router 额度告急 / 查询失败
812
864
  const att = [];
813
865
  if (!s.riskIdentity && state.accounts.some((a) => a.provider === 'qoder')) {
814
866
  att.push({ view: 'qoder', name: 'Qoder 国际版', badges: '', pill: 'limited', text: umidOffer(s) ? '安装设备身份组件后才能领取' : '未检测到 Qoder 客户端,国际版无法领取' });
@@ -820,7 +872,7 @@ function renderDash() {
820
872
  if (a.verified === false) att.push(Object.assign({ pill: 'fail', text: 'token 无效' }, base));
821
873
  else if (ex.label === '已过期') att.push(Object.assign({ pill: 'fail', text: 'token 已过期' }, base));
822
874
  else if (ex.warn) att.push(Object.assign({ pill: 'limited', text: ex.label.slice(5, 10) + ' 过期' }, base));
823
- if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: '签到失败', title: st.msg }, base));
875
+ if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: a.provider === 'workbuddy-intl' ? '活跃失败' : '领取失败', title: st.msg }, base));
824
876
  }
825
877
  for (const c of (tr.quotas && tr.quotas.connections) || []) {
826
878
  const base = { view: 'tenrouter', name: c.name || c.email || c.providerName, badges: '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>' };
@@ -834,19 +886,21 @@ function renderDash() {
834
886
  + (att.length > 8 ? '<div class="muted-empty">还有 ' + (att.length - 8) + ' 项</div>' : '')
835
887
  : '<div class="muted-empty">' + (state.accounts.length ? '✓ 一切正常' : '还没有账号') + '</div>';
836
888
 
837
- // 最近签到结果
889
+ // 最近领取记录
838
890
  const RES = { 'checked-in': ['ok', '领取成功'], already: ['ok', '已领过'], limited: ['limited', '本机已领'], 'no-activity': ['pending', '无可领活动'], failed: ['fail', '失败'] };
839
891
  const recent = state.accounts.filter((a) => a.lastResult && a.lastResult.at).sort((x, y) => String(y.lastResult.at).localeCompare(String(x.lastResult.at))).slice(0, 8);
840
892
  $('last-tick').textContent = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) : '';
841
893
  $('recent').innerHTML = recent.length
842
894
  ? recent.map((a) => {
843
895
  const r = a.lastResult;
844
- const [cls, txt] = RES[r.status] || ['pending', r.status];
896
+ let [cls, txt] = RES[r.status] || ['pending', r.status];
897
+ // 国际版 WorkBuddy 是保持活跃(不保证奖励),不叫「领取」
898
+ if (a.provider === 'workbuddy-intl' && r.status === 'checked-in') txt = '活跃达成';
845
899
  const label = r.status === 'checked-in' && r.amount ? txt + ' +' + fmtNum(r.amount) : txt;
846
900
  return '<div class="item" data-view="' + a.product + '" title="' + esc(r.message || '') + '"><span class="item-main"><span class="item-name">' + esc(a.name || a.email || a.id) + '</span>' + provBadges(a.provider) + '</span>'
847
901
  + '<span class="item-side"><span class="pill ' + cls + '">' + esc(label) + '</span><span class="item-time">' + fmtTime(r.at).slice(5) + '</span></span></div>';
848
902
  }).join('')
849
- : '<div class="muted-empty">还没有签到记录</div>';
903
+ : '<div class="muted-empty">还没有领取记录</div>';
850
904
  }
851
905
 
852
906
  function renderStatus() {
@@ -856,8 +910,8 @@ function renderStatus() {
856
910
  document.title = 'CreditDaddy v' + s.version;
857
911
  if (s.homepage) $('btn-home').href = s.homepage;
858
912
  const sch = s.scheduler || {};
859
- $('sched').textContent = sch.ticking ? '正在签到…'
860
- : sch.nextTickAt ? '下次自动签到 ' + fmtTime(sch.nextTickAt).slice(11) : '';
913
+ $('sched').textContent = sch.ticking ? '正在领取…'
914
+ : sch.nextTickAt ? '下次自动领取 ' + fmtTime(sch.nextTickAt).slice(11) : '';
861
915
  $('sched').title = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at) + ':' + sch.lastTick.summary : '';
862
916
  // 产品相关提示只在对应标签页出现
863
917
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
@@ -866,17 +920,21 @@ function renderStatus() {
866
920
  if (state.view === 'zcode') {
867
921
  n.style.display = '';
868
922
  const pf = state.zProxyFirst === true;
869
- n.innerHTML = 'ZCode 没有每日签到,积分靠「活动领取」:点卡片上的礼物图标领取当期活动(可能需过一次验证码,通常静默通过)。这里也用于多账号管理、额度查看与一键切换(切换前需先退出 ZCode)。'
870
- + ' <button class="btn" id="btn-znet" style="margin-left:6px" title="ZCode 上游接口的访问顺序:直连失败 / 被拦时会自动回退另一条。代理取 HTTPS_PROXY 环境变量">出口:' + (pf ? '代理优先' : '直连优先') + '</button>';
923
+ const pu = state.zProxyUrl;
924
+ const ac = state.zAutoClaim === true;
925
+ n.innerHTML = 'ZCode 没有每日签到,积分靠「活动领取」。打开「自动领取」后,每轮签到结束会轮询可领活动并尝试领取(桌面版静默过验证码,需要人工时弹验证码窗口);平时保持关闭即可,活动期前再开。也可点卡片上的礼物图标手动领取。'
926
+ + ' <button class="btn" id="btn-zac" style="margin-left:6px" title="ZCode 活动自动轮询领取(每轮签到后执行一次)">' + (ac ? '自动领取:开' : '自动领取:关') + '</button>'
927
+ + ' <button class="btn" id="btn-znet" style="margin-left:4px" title="ZCode 上游接口的访问顺序:失败 / 被拦时自动回退另一条">出口:' + (pf ? '代理优先' : '直连优先') + '</button>'
928
+ + ' <button class="btn" id="btn-zproxy" style="margin-left:4px" title="设置 ZCode 出口用的 http 代理(留空则清除;不设置时使用 HTTPS_PROXY 环境变量)">代理:' + esc(pu || (state.zHasEnvProxy ? '环境变量' : '未设置')) + '</button>';
871
929
  } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
872
930
  n.style.display = '';
873
931
  const busy = s.umid.installing || umidBusy;
874
932
  n.innerHTML = 'Qoder 国际版只向带设备风控身份的请求下发「每天领 100 Credits」。这台 Linux / NAS 上没有 Qoder 客户端,'
875
- + '可以安装「设备身份组件」:从 npm 官方包 @qoder-ai/qodercli 提取其内置的 UMID 程序(约 30MB 下载,校验 npm 完整性),装好后国际版即可签到(本机每天限领一个账号)。'
933
+ + '可以安装「设备身份组件」:从 npm 官方包 @qoder-ai/qodercli 提取其内置的 UMID 程序(约 30MB 下载,校验 npm 完整性),装好后国际版即可领取(本机每天限领一个账号)。'
876
934
  + ' <button class="btn primary" id="btn-umid" style="margin-left:6px"' + (busy ? ' disabled' : '') + '>' + (busy ? '安装中…' : '安装设备身份组件') + '</button>';
877
935
  } else if (hasIntl && !s.riskIdentity) {
878
936
  n.style.display = '';
879
- n.textContent = 'Qoder 国际版签到需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。';
937
+ n.textContent = 'Qoder 国际版领取需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。';
880
938
  } else if (hasIntl) {
881
939
  n.style.display = '';
882
940
  n.textContent = '提示:Qoder 国际版每台设备每天只能有一个账号领取每日 100 Credits(服务端按设备限领),其余国际版账号会显示「本机已领」。'
@@ -893,23 +951,51 @@ async function installUmid() {
893
951
  umidBusy = true; renderStatus();
894
952
  try {
895
953
  const r = await api('/api/qoder/umid/install', { method: 'POST' });
896
- toast('设备身份组件已安装(qodercli ' + r.installed.version + '),Qoder 国际版可以签到了');
954
+ toast('设备身份组件已安装(qodercli ' + r.installed.version + '),Qoder 国际版可以领取了');
897
955
  } catch (e) { toast(e.message, false); }
898
956
  umidBusy = false;
899
957
  await loadStatus(); render(); loadLogs();
900
958
  }
901
959
  $('notice').addEventListener('click', (e) => { if (e.target.closest('#btn-umid')) installUmid(); });
902
960
  $('notice').addEventListener('click', async (e) => {
903
- if (!e.target.closest('#btn-znet')) return;
904
- try {
905
- const r = await api('/api/zcode/net', { method: 'PUT', json: { proxyFirst: state.zProxyFirst !== true } });
906
- state.zProxyFirst = r.proxyFirst;
907
- renderStatus();
908
- toast('ZCode 出口:' + (r.proxyFirst ? '代理优先(直连兜底)' : '直连优先(代理兜底)'));
909
- } catch (err) { toast(err.message, false); }
961
+ if (e.target.closest('#btn-zac')) {
962
+ try {
963
+ const r = await api('/api/zcode/net', { method: 'PUT', json: { autoClaim: state.zAutoClaim !== true } });
964
+ state.zAutoClaim = r.autoClaim;
965
+ renderStatus();
966
+ toast('ZCode 自动轮询领取:' + (r.autoClaim ? '已开启(每轮签到后执行)' : '已关闭'));
967
+ } catch (err) { toast(err.message, false); }
968
+ return;
969
+ }
970
+ if (e.target.closest('#btn-znet')) {
971
+ try {
972
+ const r = await api('/api/zcode/net', { method: 'PUT', json: { proxyFirst: state.zProxyFirst !== true } });
973
+ state.zProxyFirst = r.proxyFirst;
974
+ renderStatus();
975
+ toast('ZCode 出口:' + (r.proxyFirst ? '代理优先(直连兜底)' : '直连优先(代理兜底)'));
976
+ } catch (err) { toast(err.message, false); }
977
+ return;
978
+ }
979
+ if (e.target.closest('#btn-zproxy')) {
980
+ const cur = state.zProxyUrl || '';
981
+ const v = await uiPrompt('ZCode 出口代理(http://host:port,支持口令 http://user:pass@host:port)\n留空 = 清除,退回 HTTPS_PROXY 环境变量', cur, { okText: '保存' });
982
+ if (v === null) return;
983
+ try {
984
+ await api('/api/zcode/net', { method: 'PUT', json: { proxyUrl: v.trim() } });
985
+ await loadZNet(); renderStatus();
986
+ toast(v.trim() ? '代理已保存' : '代理已清除');
987
+ } catch (err) { toast(err.message, false); }
988
+ }
910
989
  });
911
990
  async function loadZNet() {
912
- try { state.zProxyFirst = (await api('/api/zcode/net')).proxyFirst === true; if (state.view === 'zcode') renderStatus(); } catch (e) {}
991
+ try {
992
+ const d = await api('/api/zcode/net');
993
+ state.zProxyFirst = d.proxyFirst === true;
994
+ state.zProxyUrl = d.proxyUrlMasked || null;
995
+ state.zAutoClaim = d.autoClaim === true;
996
+ state.zHasEnvProxy = d.hasEnvProxy === true;
997
+ if (state.view === 'zcode') renderStatus();
998
+ } catch (e) {}
913
999
  }
914
1000
 
915
1001
  // ── 10Router ──
@@ -948,38 +1034,61 @@ function quotaValue(q) {
948
1034
  }
949
1035
  const resetLabel = (q) => (q.resetAt ? fmtTime(q.resetAt).slice(5) + (q.recurring === false ? ' 到期' : ' 重置') : '');
950
1036
 
1037
+ // 相对重置标签(CLIProxyAPI 管理中心风格):「3 天 后重置」「45 分钟 后到期」;一小时内标红,悬停看绝对时间
1038
+ function relReset(q) {
1039
+ if (!q.resetAt) return null;
1040
+ const ms = new Date(q.resetAt).getTime() - Date.now();
1041
+ if (!Number.isFinite(ms)) return null;
1042
+ if (ms <= 0) return { text: '即将' + (q.recurring === false ? '到期' : '重置'), soon: true };
1043
+ const m = Math.ceil(ms / 60000);
1044
+ const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
1045
+ const dur = d > 0 ? d + ' 天' + (h ? h + ' 小时' : '') : h > 0 ? h + ' 小时' + (mm ? mm + ' 分' : '') : Math.max(1, mm) + ' 分钟';
1046
+ return { text: dur + ' 后' + (q.recurring === false ? '到期' : '重置'), soon: ms < 3600e3 };
1047
+ }
1048
+ function relResetHtml(q) {
1049
+ const r = relReset(q);
1050
+ return r ? '<span class="tr-reset' + (r.soon ? ' soon' : '') + '" title="' + esc(fmtTime(q.resetAt)) + '">' + esc(r.text) + '</span>' : '';
1051
+ }
1052
+ // 三档着色水位条(≥70 绿 / ≥30 琥珀 / <30 红)
1053
+ const meterCls = (pct) => (pct >= 70 ? 'hi' : pct >= 30 ? 'mid' : 'low');
1054
+ function trMeter(pct) {
1055
+ if (pct === null) return '';
1056
+ const w = Math.min(100, Math.max(0, pct)).toFixed(1);
1057
+ return '<div class="tr-meter"><span class="' + meterCls(pct) + '" style="width:' + w + '%"></span></div>';
1058
+ }
1059
+ // 明细整行:名称 + 数值/相对重置 + 水位条(CLIProxyAPI quotaRow 版式)
1060
+ function trRow(q) {
1061
+ return trLine(q.name, lineVal(q)) + trMeter(quotaPct(q));
1062
+ }
1063
+
951
1064
  // 紧凑行(周期额度 / 聚合积分包):名称在左、数值在右,不带进度条
952
1065
  function trLine(name, valHtml, title) {
953
1066
  return '<div class="tr-line"><span class="tr-q-name"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(name) + '</span><span class="tr-q-val">' + valHtml + '</span></div>';
954
1067
  }
955
1068
  function lineVal(q) {
956
1069
  const pct = quotaPct(q);
957
- const low = pct !== null && pct < LOW_PCT && q.percentScale;
958
1070
  const txt = (q.percentScale || !(q.total > 0))
959
1071
  ? '剩 ' + (pct !== null ? fmtNum(pct) + '%' : fmtNum(quotaRemaining(q)))
960
1072
  : '剩 ' + fmtNum(quotaRemaining(q)) + ' / ' + fmtNum(q.total || 0) + (q.unit ? ' ' + q.unit : '');
961
- return '<span' + (low ? ' class="low-text"' : '') + '>' + esc(txt) + '</span>' + (q.resetAt ? '<span class="tr-q-when"> · ' + esc(resetLabel(q)) + '</span>' : '');
1073
+ return '<span>' + esc(txt) + '</span> ' + relResetHtml(q);
962
1074
  }
963
1075
 
964
- // 主额度行:与账号主卡同款 credit-line + bar
1076
+ // 主额度行:名称 + 大值 + 相对重置 + 三档水位条
965
1077
  function trPrimaryHtml(q) {
966
1078
  const v = quotaValue(q);
967
- const low = v.pct !== null && v.pct < LOW_PCT;
968
- const pct01 = v.pct !== null ? Math.max(0, Math.min(1, v.pct / 100)) : null;
969
- return '<div class="tr-primary"><div class="credit-line"><span class="credit"><span class="lbl">' + esc(q.name) + '</span><b' + (low ? ' style="color:var(--err)"' : '') + '>' + esc(v.big) + '</b> <small class="dim">' + esc(v.small) + '</small></span>'
970
- + '<span class="credit dim">' + (q.resetAt ? esc(resetLabel(q)) : '') + '</span></div>'
971
- + (pct01 !== null ? '<div class="bar"><span class="seg" style="flex:' + pct01.toFixed(4) + ' 1 0"></span><span class="seg used" style="flex:' + (1 - pct01).toFixed(4) + ' 1 0"></span></div>' : '')
1079
+ return '<div class="tr-primary"><div class="credit-line"><span class="credit"><span class="lbl">' + esc(q.name) + '</span><b>' + esc(v.big) + '</b> <small class="dim">' + esc(v.small) + '</small></span>'
1080
+ + '<span class="credit dim">' + relResetHtml(q) + '</span></div>'
1081
+ + (v.pct !== null ? trMeter(v.pct) : '')
972
1082
  + '</div>';
973
1083
  }
974
1084
 
975
1085
  function trQuotaHtml(q, { dimWhenEmpty = false } = {}) {
976
1086
  const v = quotaValue(q);
977
1087
  const used = v.pct !== null && v.pct <= 0;
978
- const low = !used && v.pct !== null && v.pct < LOW_PCT && q.percentScale;
979
1088
  const val = used && dimWhenEmpty ? '已用完' : (q.percentScale || !(q.total > 0) ? '剩余 ' + v.big : '剩余 ' + v.big + ' ' + v.small);
980
1089
  return '<div class="tr-q' + (used && dimWhenEmpty ? ' spent' : '') + '"><div class="tr-q-line"><span class="tr-q-name" title="' + esc(q.name) + '">' + esc(q.name) + '</span>'
981
- + '<span class="tr-q-val' + (low ? ' low' : '') + '">' + esc(val) + (q.resetAt ? '<span class="tr-q-when"> · ' + esc(resetLabel(q)) + '</span>' : '') + '</span></div>'
982
- + (v.pct !== null && !(used && dimWhenEmpty) ? '<div class="tr-meter"><span class="' + (low ? 'low' : '') + '" style="width:' + v.pct.toFixed(1) + '%"></span></div>' : '')
1090
+ + '<span class="tr-q-val">' + esc(val) + relResetHtml(q) + '</span></div>'
1091
+ + (!(used && dimWhenEmpty) ? trMeter(v.pct) : '')
983
1092
  + '</div>';
984
1093
  }
985
1094
 
@@ -1034,19 +1143,15 @@ function trCardHtml(c) {
1034
1143
  if (!(primary || windows.length || packs.length)) {
1035
1144
  body = '<div class="msg">' + esc(/no separate quota|billed at standard/i.test(c.message || '') ? '按量计费,无额度可显示' : (c.message || '该供应商未返回额度数据')) + '</div>';
1036
1145
  } else {
1037
- const resets = [primary, ...windows, ...packs].filter(Boolean).map((q) => q.resetAt).filter(Boolean).sort();
1038
- const quotaCount = (primary ? 1 : 0) + windows.length + packs.length;
1039
- body = '<div class="meta">'
1040
- + '<div class="mi"><span class="lbl">额度</span><span class="val">' + quotaCount + ' 项</span></div>'
1041
- + '<div class="mi right"><span class="lbl">最早到期</span><span class="val' + (resets[0] && new Date(resets[0]) - Date.now() < 7 * 86400e3 ? ' warn' : '') + '">' + (resets[0] ? fmtTime(resets[0]).slice(5, 10) : '—') + '</span></div>'
1042
- + '</div>'
1043
- + (primary ? trPrimaryHtml(primary) : '')
1044
- + windows.map((q) => trLine(q.name, lineVal(q))).join('')
1146
+ // 与汇总卡同款默认视图:主额度 + 每个周 / 月窗口额度一条(带进度条);积分包走聚合行,「逐包明细」展开单包
1147
+ body = (primary ? trPrimaryHtml(primary) : '')
1148
+ + windows.map((q) => trRow(q)).join('')
1045
1149
  + trPacksHtml(c, packs);
1046
1150
  }
1047
1151
  }
1048
1152
  return '<div class="tr-card' + (c.isActive ? '' : ' off') + '"><div class="row1"><div class="name-group">'
1049
1153
  + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1154
+ + (TR_REGION[c.provider] ? '<span class="badge ' + TR_REGION[c.provider][1] + '">' + TR_REGION[c.provider][0] + '</span>' : '')
1050
1155
  + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
1051
1156
  + (showPlan ? '<span class="badge">' + esc(c.plan) + '</span>' : '')
1052
1157
  + (c.isActive ? '' : '<span class="pill pending">已停用</span>')
@@ -1059,6 +1164,119 @@ function trCardHtml(c) {
1059
1164
  const trSorted = (list) => list.slice().sort((a, b) => (b.isActive - a.isActive)
1060
1165
  || String(a.providerName).localeCompare(String(b.providerName)) || String(a.name || a.email || '').localeCompare(String(b.name || b.email || '')));
1061
1166
 
1167
+ // 按供应商聚类:同一供应商的账号排在一行网格里,组内保持 trSorted 顺序
1168
+ function trGroups(list) {
1169
+ const map = new Map();
1170
+ for (const c of trSorted(list)) {
1171
+ const k = c.providerName || c.provider || '其他';
1172
+ if (!map.has(k)) map.set(k, []);
1173
+ map.get(k).push(c);
1174
+ }
1175
+ return [...map.entries()].map(([name, items]) => ({ name, items }));
1176
+ }
1177
+
1178
+ // 10Router 连接的国内 / 国际版标签(沿用账号卡的徽标语义)
1179
+ const TR_REGION = {
1180
+ qoder: ['国际版', 'intl'], 'qoder-cn': ['国内版', 'cn'],
1181
+ 'codebuddy-intl': ['国际版', 'intl'], 'codebuddy-cn': ['国内版', 'cn'],
1182
+ 'glm-cn': ['国内版', 'cn'],
1183
+ };
1184
+
1185
+ // 「全部」视图:一个供应商一张汇总卡。同名额度跨连接求和;百分比窗口(如限时窗口)取平均。
1186
+ // 命名含 Cycle/Capacity 的包本身是「全包汇总」,连接级聚合时跳过以避免重复计数(Total Points 这类)。
1187
+ const isAggregatePack = (name) => /cycle|capacity/i.test(String(name || ''));
1188
+ function trVendorCardHtml(g) {
1189
+ const items = g.items;
1190
+ const prov = items[0].provider;
1191
+ const lows = items.filter((x) => x.isActive && !x.error && quotaLow(x)).length;
1192
+ const errs = items.filter((x) => x.error).length;
1193
+
1194
+ const splitOf = items.map((x) => ({ x, s: splitQuotas(x) }));
1195
+ // Total Points / 含 Cycle|Capacity 的包是全包汇总:连接级聚合跳过它们,避免与子包重复计数
1196
+ const isAgg = (q) => isAggregatePack(q && q.name);
1197
+ const primaries = splitOf.filter(({ s }) => s.primary && !isAgg(s.primary)).map(({ s }) => s.primary);
1198
+ let primary = null;
1199
+ if (primaries.length) {
1200
+ const name = primaries[0].name;
1201
+ const same = primaries.filter((p) => p.name === name);
1202
+ const total = same.reduce((n, p) => n + (p.total > 0 ? p.total : 0), 0);
1203
+ const isPct = !(total > 0);
1204
+ if (isPct) {
1205
+ // 限时百分比窗口(如 Claude 5h):跨连接取平均百分比,而不是加总
1206
+ const pcts = same.map((p) => quotaPct(p)).filter((x) => x !== null);
1207
+ primary = {
1208
+ name,
1209
+ total: 0,
1210
+ remaining: 0,
1211
+ used: 0,
1212
+ unit: '',
1213
+ percentScale: true,
1214
+ remainingPercentage: pcts.length ? pcts.reduce((a, b) => a + b, 0) / pcts.length : null,
1215
+ recurring: same[0].recurring,
1216
+ resetAt: same.map((p) => p.resetAt).filter(Boolean).sort()[0] || null,
1217
+ };
1218
+ } else {
1219
+ primary = {
1220
+ name,
1221
+ total,
1222
+ remaining: same.reduce((n, p) => n + quotaRemaining(p), 0),
1223
+ used: same.reduce((n, p) => n + (p.used || 0), 0),
1224
+ unit: same[0].unit || '',
1225
+ percentScale: false,
1226
+ recurring: same[0].recurring,
1227
+ resetAt: same.map((p) => p.resetAt).filter(Boolean).sort()[0] || null,
1228
+ };
1229
+ }
1230
+ }
1231
+
1232
+ // 周期窗口:同名求和;无固定总额的按百分比平均
1233
+ const winMap = new Map();
1234
+ const packs = [];
1235
+ for (const { s } of splitOf) {
1236
+ for (const q of s.windows.filter((x) => !isAgg(x))) {
1237
+ if (!winMap.has(q.name)) winMap.set(q.name, { name: q.name, total: 0, rem: 0, anyTotal: false, resetAt: null, pcts: [] });
1238
+ const w = winMap.get(q.name);
1239
+ if (q.total > 0) { w.anyTotal = true; w.total += q.total; w.rem += quotaRemaining(q); }
1240
+ else { const pct = quotaPct(q); if (pct !== null) w.pcts.push(pct); }
1241
+ if (q.resetAt && (!w.resetAt || String(q.resetAt) < String(w.resetAt))) w.resetAt = q.resetAt;
1242
+ }
1243
+ packs.push(...s.packs.filter((x) => !isAgg(x)));
1244
+ }
1245
+ const wins = [...winMap.values()];
1246
+ const alivePacks = packs.filter((q) => quotaRemaining(q) > 0);
1247
+ const packRem = alivePacks.reduce((n, q) => n + quotaRemaining(q), 0);
1248
+
1249
+ // 周 / 月度等窗口额度也给进度条(同名跨连接已聚合好):有固定总额的加总,百分比窗口取平均
1250
+ const winRows = wins.map((w) => trRow(w.anyTotal
1251
+ ? { name: w.name, total: w.total, remaining: w.rem, used: w.total - w.rem, unit: '', percentScale: false, resetAt: w.resetAt, recurring: true }
1252
+ : { name: w.name, total: 0, remainingPercentage: w.pcts.length ? w.pcts.reduce((a, b) => a + b, 0) / w.pcts.length : null, percentScale: true, resetAt: w.resetAt, recurring: true })).join('');
1253
+
1254
+ const briefs = [];
1255
+ if (alivePacks.length) briefs.push('积分包×' + alivePacks.length + ' 剩 ' + fmtNum(packRem));
1256
+ else if (packs.length) briefs.push('积分包已用完');
1257
+ const hasQuotas = Boolean(primary || wins.length || packs.length);
1258
+ let quotaHtml;
1259
+ if (errs === items.length) {
1260
+ quotaHtml = '<div class="msg err">全部连接查询失败</div>';
1261
+ } else if (!hasQuotas) {
1262
+ quotaHtml = '<div class="msg">该供应商没有可汇总的额度</div>';
1263
+ } else {
1264
+ // 「全部」视角不显示「最早到期」:每行自带相对重置时间,足够判断
1265
+ quotaHtml = (primary ? trPrimaryHtml(primary) : '')
1266
+ + winRows
1267
+ + (briefs.length
1268
+ ? '<div class="tr-summary dim"><span class="tr-sum-txt">' + briefs.join(' · ') + '</span></div>'
1269
+ : '');
1270
+ }
1271
+ return '<div class="tr-card"><div class="row1"><div class="name-group">'
1272
+ + '<span class="badge prod tenrouter">' + esc(g.name) + '</span>'
1273
+ + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + TR_REGION[prov][0] + '</span>' : '')
1274
+ + '<span class="badge">' + items.length + ' 个账号</span>'
1275
+ + (lows ? '<span class="pill limited">告急 ×' + lows + '</span>' : '')
1276
+ + (errs ? '<span class="pill fail" title="部分连接查询失败">失败 ×' + errs + '</span>' : '')
1277
+ + '</div></div>' + quotaHtml + '</div>';
1278
+ }
1279
+
1062
1280
  function trSetupHtml() {
1063
1281
  return '<div class="setup"><h3>接入 10Router</h3>'
1064
1282
  + '<div class="hint">填 10Router 的地址和一个<b>虚拟 key</b>(10Router 仪表盘 → API Keys 创建的 sk-…),就能在这里:</div>'
@@ -1081,10 +1299,41 @@ function renderTenrouter() {
1081
1299
  return '<span class="chip' + (on ? ' on' : '') + '" title="' + (x.found ? (x.supported ? '已检测到' : '需要 Node 22.5+') : '本机未检测到') + '">' + esc(x.label) + (x.found ? '' : ' ·无') + '</span>';
1082
1300
  }).join('');
1083
1301
  let grid;
1302
+ let tabStrip = '';
1303
+ const groups = list.length ? trGroups(list) : [];
1304
+ const vendor = state.trVendor || '';
1305
+ // 「隐藏 0 / 无额度」与列表共用同一过滤:被隐藏的供应商不出现在标签条
1306
+ const shown = state.trHideEmpty
1307
+ ? groups.filter((g) => {
1308
+ if (g.items.every((x) => x.error)) return true;
1309
+ for (const x of g.items) {
1310
+ if (x.error) continue;
1311
+ const s = splitQuotas(x);
1312
+ if (s.primary && quotaRemaining(s.primary) > 0) return true;
1313
+ if (s.windows.some((q) => quotaRemaining(q) > 0 || (quotaPct(q) || 0) > 0)) return true;
1314
+ if (s.packs.some((q) => quotaRemaining(q) > 0)) return true;
1315
+ }
1316
+ return false;
1317
+ })
1318
+ : groups;
1319
+ const activeVendor = vendor && shown.some((g) => g.name === vendor) ? vendor : '';
1320
+ if (groups.length) {
1321
+ tabStrip = '<div class="tr-tabs">'
1322
+ + '<button class="btn' + (!activeVendor ? ' primary' : '') + '" data-tr="vendor" data-v="">全部</button>'
1323
+ + shown.map((g) => '<button class="btn' + (activeVendor === g.name ? ' primary' : '') + '" data-tr="vendor" data-v="' + esc(g.name) + '">' + esc(g.name) + ' ' + g.items.length + '</button>').join('')
1324
+ + '</div>';
1325
+ }
1084
1326
  if (tr.error) grid = '<div class="empty">' + esc(tr.error) + '</div>';
1085
1327
  else if (!tr.quotas) grid = '<div class="empty">正在读取 10Router 供应商额度…</div>';
1086
1328
  else if (!list.length) grid = '<div class="empty">10Router 里还没有可查额度的供应商连接(OAuth 或支持额度查询的 API Key)。</div>';
1087
- else grid = trSorted(list).map(trCardHtml).join('');
1329
+ else if (activeVendor) {
1330
+ const g = groups.find((x) => x.name === activeVendor) || { items: [] };
1331
+ grid = '<div class="tr-vcards">' + g.items.map(trCardHtml).join('') + '</div>';
1332
+ } else {
1333
+ grid = shown.length
1334
+ ? '<div class="tr-vcards">' + shown.map(trVendorCardHtml).join('') + '</div>'
1335
+ : '<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>';
1336
+ }
1088
1337
  const h = tr.health;
1089
1338
  const healthStrong = !h ? '…' : h.ok ? '正常' : '异常';
1090
1339
  const healthDetail = !h ? '' : h.ok
@@ -1105,6 +1354,7 @@ function renderTenrouter() {
1105
1354
  + '<span style="margin-left:auto" title="' + esc(ls ? ls.summary : '') + '">' + (ls ? '上次同步 ' + esc(fmtTime(ls.at).slice(5)) + ' · ' + esc(ls.summary.slice(0, 40)) : '还没有同步过') + '</span>'
1106
1355
  + (tr.quotas ? '<span>额度更新于 ' + esc(fmtTime(tr.quotas.generatedAt).slice(11)) + '</span>' : '')
1107
1356
  + '</div>'
1357
+ + tabStrip
1108
1358
  + '<div class="tr-grid">' + grid + '</div>';
1109
1359
  }
1110
1360
 
@@ -1152,6 +1402,7 @@ function openTrSettings() {
1152
1402
  $('tr-key').value = '';
1153
1403
  $('tr-key').placeholder = c.keyMasked ? '已保存 ' + c.keyMasked + '(留空保持不变)' : 'sk-…';
1154
1404
  $('tr-auto').checked = c.configured ? c.sync.enabled : true;
1405
+ $('tr-hide-empty').checked = state.trHideEmpty === true;
1155
1406
  $('tr-sources').innerHTML = (c.sources || []).map((x) => '<label class="check"><input type="checkbox" data-src="' + x.id + '"'
1156
1407
  + ((c.configured ? c.sync.sources.includes(x.id) : true) ? ' checked' : '') + (x.supported ? '' : ' disabled') + '> ' + esc(x.label)
1157
1408
  + '<span class="dim">' + (x.found ? '' : '(本机未检测到)') + (x.supported ? '' : '(需要 Node 22.5+)') + '</span></label>').join('');
@@ -1183,6 +1434,9 @@ async function trSave() {
1183
1434
  const f = trForm();
1184
1435
  if (!f.endpoint) return toast('请填写 10Router 地址', false);
1185
1436
  if (!f.key && !(tr.config && tr.config.keyMasked)) return toast('请填写虚拟 key', false);
1437
+ // 显示偏好只存在面板本地(localStorage),不上服务端
1438
+ state.trHideEmpty = $('tr-hide-empty').checked;
1439
+ try { localStorage.setItem('qd_tr_hideempty', state.trHideEmpty ? '1' : '0'); } catch (e) {}
1186
1440
  try {
1187
1441
  tr.config = await api('/api/tenrouter', { method: 'PUT', json: f });
1188
1442
  closeModal('m-10r');
@@ -1203,6 +1457,12 @@ $('tr-panel').addEventListener('click', (e) => {
1203
1457
  if (b.dataset.tr === 'settings') openTrSettings();
1204
1458
  else if (b.dataset.tr === 'refresh') loadTrQuotas(true);
1205
1459
  else if (b.dataset.tr === 'sync') trSync();
1460
+ else if (b.dataset.tr === 'vendor') {
1461
+ const v = b.dataset.v || '';
1462
+ state.trVendor = v;
1463
+ history.replaceState(null, '', v ? '#tenrouter/' + encodeURIComponent(v) : '#tenrouter');
1464
+ render();
1465
+ }
1206
1466
  });
1207
1467
  $('tr-test').onclick = trTest;
1208
1468
  $('tr-save').onclick = trSave;
@@ -1257,7 +1517,7 @@ async function checkinOne(id) {
1257
1517
  }
1258
1518
  async function checkinAll(btn, product) {
1259
1519
  const label = btn.innerHTML;
1260
- btn.disabled = true; btn.innerHTML = I.refresh + '签到中…';
1520
+ btn.disabled = true; btn.innerHTML = I.refresh + '领取中…';
1261
1521
  try {
1262
1522
  const d = await api('/api/checkin', { json: { skipIfCheckedToday: false, product } });
1263
1523
  toast(d.summary);
@@ -1700,7 +1960,7 @@ async function submitImport(password) {
1700
1960
  }
1701
1961
 
1702
1962
  // ── 绑定 ──
1703
- $('btn-checkin-all').innerHTML = I.play + '全部签到';
1963
+ $('btn-checkin-all').innerHTML = I.play + '全部领取';
1704
1964
  for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + '添加账号';
1705
1965
  for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + '导入'; $(id).onclick = pickImport; }
1706
1966
  for (const id of ['btn-export', 'btn-export-dash']) $(id).innerHTML = I.download + '导出';
@@ -1722,9 +1982,12 @@ $('f-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') addToke
1722
1982
  $('list').addEventListener('click', onCardClick);
1723
1983
  // 标签页:用 URL hash 记住(刷新 / 前进后退保持)
1724
1984
  function applyView() {
1725
- const v = location.hash.slice(1);
1985
+ const raw = location.hash.slice(1);
1986
+ const [v, sub] = raw.split('/');
1726
1987
  state.view = VIEWS.includes(v) ? v : 'dash';
1727
1988
  state.region = '';
1989
+ // 10Router 供应商标签可用 hash 直达:#tenrouter/<供应商名>
1990
+ state.trVendor = state.view === 'tenrouter' && sub ? decodeURIComponent(sub) : '';
1728
1991
  document.querySelectorAll('#nav button').forEach((x) => x.classList.toggle('active', x.dataset.view === state.view));
1729
1992
  $('view-dash').classList.toggle('active', state.view === 'dash');
1730
1993
  $('view-prod').classList.toggle('active', state.view !== 'dash' && state.view !== 'tenrouter');
package/src/providers.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { productOf } from './constants.js';
12
12
  import { checkinOne as checkinQoder, fetchQuotaUsage, fetchUserinfo } from './qoderClient.js';
13
- import { checkinWorkbuddy, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
13
+ import { checkinWorkbuddy, checkinWorkbuddyIntl, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
14
14
  import { fetchZcodeQuota } from './zcodeClient.js';
15
15
 
16
16
  /** 从 userinfo 响应中挑一个可读的显示名 */
@@ -51,7 +51,8 @@ const PRODUCTS = {
51
51
  },
52
52
  workbuddy: {
53
53
  label: 'WorkBuddy',
54
- checkin: (account, ctx) => checkinWorkbuddy(account, ctx),
54
+ // 国内版走 billing daily-checkin 签到;国际版没有签到接口,走「活跃领取」(免费档对话探测)
55
+ checkin: (account, ctx) => (account.provider === 'workbuddy-intl' ? checkinWorkbuddyIntl(account, ctx) : checkinWorkbuddy(account, ctx)),
55
56
  quota: (account, ctx) => fetchWorkbuddyQuota(account, ctx),
56
57
  verify: async (account) => {
57
58
  const info = inspectToken(account.token);
package/src/store.js CHANGED
@@ -44,14 +44,39 @@ async function ensureDir() {
44
44
  await fs.mkdir(dataDir(), { recursive: true, mode: 0o700 });
45
45
  }
46
46
 
47
- /** 原子写:临时文件 + rename,失败不留半截文件 */
48
- export async function atomicWrite(file, content) {
47
+ // Windows 上 rename 覆盖已有文件时,外部程序(杀软扫描、同步盘、另一守护进程)瞬时占用
48
+ // 会直接抛 EPERM/EBUSY/EACCES:先按递增间隔重试,仍失败则兜底直写目标(牺牲原子性换可用性)。
49
+ const RENAME_TRANSIENT = new Set(['EPERM', 'EBUSY', 'EACCES']);
50
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
51
+
52
+ let renameImpl = null;
53
+ export function _setRenameForTests(fn) { renameImpl = fn || null; }
54
+
55
+ /** 原子写:临时文件 + rename(带故障重试与直写兜底) */
56
+ export async function atomicWrite(file, content, { retries = 6 } = {}) {
49
57
  const tmp = path.join(
50
58
  path.dirname(file),
51
59
  `.tmp-${path.basename(file)}-${crypto.randomBytes(4).toString('hex')}`
52
60
  );
53
61
  await fs.writeFile(tmp, content, { mode: 0o600 });
54
- await fs.rename(tmp, file);
62
+ const doRename = renameImpl || ((a, b) => fs.rename(a, b));
63
+ let transientRetries = 0;
64
+ try {
65
+ for (let i = 0; i <= retries; i++) {
66
+ try { await doRename(tmp, file); return; } catch (e) {
67
+ if (!RENAME_TRANSIENT.has(e?.code)) throw e; // 非占用类错误立即走兜底(如 EXDEV)
68
+ transientRetries++;
69
+ if (i < retries) await sleep(120 * (i + 1));
70
+ }
71
+ }
72
+ } catch { /* 落入兜底 */ }
73
+ // 兜底:读回临时文件直接覆盖写目标,并清理临时文件(避免 .tmp-* 残留)
74
+ try {
75
+ const data = await fs.readFile(tmp, 'utf8');
76
+ await fs.writeFile(file, data, { mode: 0o600 });
77
+ } finally {
78
+ await fs.unlink(tmp).catch(() => {});
79
+ }
55
80
  }
56
81
 
57
82
  async function readJson(file, fallback) {
@@ -91,6 +91,51 @@ async function post(account, path, token) {
91
91
  return { status: res.status, body, text };
92
92
  }
93
93
 
94
+ // 国际版「活跃领取」探测:官方规则为「当天有 ≥1 次有效对话请求即视为活跃用户,发放每日赠送积分」。
95
+ // 没有签到接口,所以每天发一条免费档模型(rateMultiplier 0,~0 消耗)的极短流式请求。
96
+ // 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
97
+ const INTL_PROBE_MODEL = 'hy4-preview';
98
+ async function postIntlProbe(account, token) {
99
+ const res = await fetch(`https://${apiHost(account)}/v2/chat/completions`, {
100
+ method: 'POST',
101
+ headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
102
+ body: JSON.stringify({
103
+ model: INTL_PROBE_MODEL,
104
+ stream: true,
105
+ max_tokens: 16,
106
+ messages: [
107
+ { role: 'system', content: 'You are CodeBuddy Code.' },
108
+ { role: 'user', content: [{ type: 'text', text: 'hi' }] },
109
+ ],
110
+ }),
111
+ signal: AbortSignal.timeout(Math.max(FETCH_TIMEOUT_MS, 25_000)),
112
+ });
113
+ // 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
114
+ const text = await res.text().catch(() => '');
115
+ return { status: res.status, text };
116
+ }
117
+
118
+ /**
119
+ * 国际版「活跃领取」:一次免费档对话请求使账号成为当日活跃用户。
120
+ * 返回与每日签到一致的结果对象(session-ok → checked-in,claimedAmount 0 表示额度随官方结算发放)。
121
+ */
122
+ export async function checkinWorkbuddyIntl(account, { onRefresh } = {}) {
123
+ const label = account.name || account.id;
124
+ const base = { accountId: account.id, account: label, provider: account.provider };
125
+ try {
126
+ const r = await withToken(account, (t) => postIntlProbe(account, t), onRefresh);
127
+ if (r.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在客户端重新登录后重新导入' };
128
+ if (r.status === 429) return { ...base, status: 'no-activity', message: '今日额度已耗尽,活跃请求暂不可达(等官方发放后再试)', claimedAmount: 0 };
129
+ if (r.status >= 200 && r.status < 300) {
130
+ return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: 0, message: '已发送活跃请求(保持活跃,活跃不等于必有奖励)' };
131
+ }
132
+ const m = (() => { try { return JSON.parse(r.text)?.error?.data?.msg || ''; } catch { return ''; } })();
133
+ return { ...base, status: 'failed', error: `活跃请求失败:HTTP ${r.status} ${m || r.text.slice(0, 120)}`.trim() };
134
+ } catch (e) {
135
+ return { ...base, status: 'failed', error: e?.message || '网络错误' };
136
+ }
137
+ }
138
+
94
139
  export function isExpired(account, now = Date.now()) {
95
140
  const exp = account.expiresAt || inspectToken(account.token).expiresAt;
96
141
  return Boolean(exp) && new Date(exp).getTime() - EXPIRY_SKEW_MS <= now;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ZCode 活动自动领取 — 签到调度器每轮调用。
3
+ *
4
+ * 策略(对齐用户手动路径,逐级尝试):
5
+ * 1. billing/preview 查可领活动;为空 → 无事可做(返回 null,不留痕迹)
6
+ * 2. 先直接 claim(不带验证码:服务端未风控时可直接成功)
7
+ * 3. 返回 3007/3001(需要验证码 / 参数被拒)且有验证码提供者 → 拉 captcha 配置,
8
+ * 让提供者(桌面版的隐藏窗口跑阿里云验证码 SDK 静默验证)拿 captchaVerifyParam 后重试
9
+ * 4. 没有提供者(NAS / 纯 CLI)或验证码需要人工 → 标记「需手动领取」,卡片上提示
10
+ * - 1003(已领取过)按成功口径处理并记忆
11
+ * 结果写入账号 lastResult(签到日历记忆用的是 status checked-in/already,第二天照常重查 preview)
12
+ */
13
+
14
+ import { logger } from './logger.js';
15
+ import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
16
+
17
+ // 桌面版(Electron 主进程)注册的隐藏窗口验证码实现;NAS / CLI 为 null(走「需手动领取」分支)
18
+ let captchaProvider = null;
19
+ export function setZcodeCaptchaProvider(fn) { captchaProvider = typeof fn === 'function' ? fn : null; }
20
+ export function hasZcodeCaptchaProvider() { return Boolean(captchaProvider); }
21
+
22
+ const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.join(';') : '');
23
+
24
+ /**
25
+ * 为单个 ZCode 账号执行一次自动领取。
26
+ * @returns {null | { status, message, claims: Array }} null = preview 为空或查询失败(已记日志,不上卡片)
27
+ */
28
+ export async function zcodeAutoClaim(account) {
29
+ const label = account.name || account.uid || account.id;
30
+ let plans;
31
+ try {
32
+ plans = (await fetchClaimPlans(account)).plans;
33
+ } catch (e) {
34
+ logger.debug('ZCODE-CLAIM', `${label} 活动查询失败:${e.message}`);
35
+ return null;
36
+ }
37
+ if (!plans.length) return null;
38
+
39
+ const claims = [];
40
+ let anySuccess = false;
41
+ let needManual = false;
42
+
43
+ for (const plan of plans) {
44
+ const planName = plan.name || plan.planId;
45
+ // 第 2 步:先试不带验证码
46
+ try {
47
+ const r = await claimPlan(account, plan.planId, {});
48
+ claims.push({ plan: planName, via: 'direct', ok: true });
49
+ anySuccess = true;
50
+ logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(免验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
51
+ continue;
52
+ } catch (e) {
53
+ if (e.code === 1003) {
54
+ claims.push({ plan: planName, ok: true, already: true });
55
+ logger.info('ZCODE-CLAIM', `${label}「${planName}」已领取过`);
56
+ continue;
57
+ }
58
+ if (e.code !== 3007 && e.code !== 3001) {
59
+ claims.push({ plan: planName, ok: false, message: e.message });
60
+ logger.warn('ZCODE-CLAIM', `${label} 领取「${planName}」失败:${e.message}`);
61
+ continue;
62
+ }
63
+ }
64
+ // 第 3 步:需要验证码
65
+ if (!captchaProvider) {
66
+ needManual = true;
67
+ claims.push({ plan: planName, ok: false, needManual: true });
68
+ logger.info('ZCODE-CLAIM', `${label}「${planName}」需要验证码,本环境无法自动通过,请在面板手动领取`);
69
+ continue;
70
+ }
71
+ try {
72
+ const cfg = await fetchCaptchaConfig();
73
+ if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
74
+ const { captchaParam, region } = await captchaProvider(cfg);
75
+ await claimPlan(account, plan.planId, { captchaParam, region });
76
+ claims.push({ plan: planName, via: 'captcha', ok: true });
77
+ anySuccess = true;
78
+ logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(静默验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
79
+ } catch (e2) {
80
+ needManual = true;
81
+ claims.push({ plan: planName, ok: false, needManual: true, message: e2.message });
82
+ logger.warn('ZCODE-CLAIM', `${label}「${planName}」验证码未完成:${e2.message}(可在面板手动领取)`);
83
+ }
84
+ }
85
+
86
+ const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
87
+ const status = names.length ? 'checked-in'
88
+ : claims.every((c) => c.already) ? 'already'
89
+ : 'failed';
90
+ const message = status === 'checked-in'
91
+ ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
92
+ : status === 'already' ? '活动均已领取过'
93
+ : needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
94
+ : `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
95
+ return { status, message, claims };
96
+ }
@@ -59,26 +59,46 @@ export function zcodeAppVersion() {
59
59
  export function _resetAppVersionCache(v) { appVersionCache = v || null; }
60
60
 
61
61
  // ── HTTP 出口:直连优先 / 代理优先(可切换),任一路成功即返回 ──
62
+ // 代理地址:优先用面板里保存的 proxyUrl(zcode-net.json,0600),没有则退回 HTTPS_PROXY 环境变量
62
63
 
63
64
  function codeHome() {
64
65
  return process.env.CREDITDADDY_HOME || process.env.QODERDADDY_HOME || path.join(os.homedir(), '.creditdaddy');
65
66
  }
66
67
  const NET_PREFS_FILE = () => path.join(codeHome(), 'zcode-net.json');
67
68
 
68
- let proxyFirstCache = null;
69
- export function proxyFirst() {
70
- if (proxyFirstCache !== null) return proxyFirstCache;
71
- try { proxyFirstCache = JSON.parse(fs.readFileSync(NET_PREFS_FILE(), 'utf8')).proxyFirst === true; }
72
- catch { proxyFirstCache = false; }
73
- return proxyFirstCache;
69
+ let netPrefCache = null;
70
+ function netPref() {
71
+ if (netPrefCache) return netPrefCache;
72
+ try {
73
+ const j = JSON.parse(fs.readFileSync(NET_PREFS_FILE(), 'utf8'));
74
+ netPrefCache = {
75
+ proxyFirst: j.proxyFirst === true,
76
+ proxyUrl: typeof j.proxyUrl === 'string' && j.proxyUrl.trim() ? j.proxyUrl.trim() : null,
77
+ autoClaim: j.autoClaim === true,
78
+ };
79
+ } catch { netPrefCache = { proxyFirst: false, proxyUrl: null, autoClaim: false }; }
80
+ return netPrefCache;
74
81
  }
75
- export function setProxyFirst(v) {
76
- proxyFirstCache = v === true;
82
+ function writeNetPref(p) {
83
+ netPrefCache = p;
77
84
  fs.mkdirSync(codeHome(), { recursive: true, mode: 0o700 });
78
- fs.writeFileSync(NET_PREFS_FILE(), JSON.stringify({ proxyFirst: proxyFirstCache }, null, 2), { mode: 0o600 });
85
+ fs.writeFileSync(NET_PREFS_FILE(), JSON.stringify({ proxyFirst: p.proxyFirst, proxyUrl: p.proxyUrl, autoClaim: p.autoClaim }, null, 2), { mode: 0o600 });
86
+ }
87
+ export function proxyFirst() { return netPref().proxyFirst; }
88
+ export function proxyUrl() { return netPref().proxyUrl; }
89
+ /** 活动自动轮询领取开关(默认关:活动期前再开,平时不轮询) */
90
+ export function autoClaimEnabled() { return netPref().autoClaim; }
91
+ export function setAutoClaimEnabled(v) { writeNetPref({ ...netPref(), autoClaim: v === true }); }
92
+ export function setProxyFirst(v) { writeNetPref({ ...netPref(), proxyFirst: v === true }); }
93
+ export function setProxyUrl(u) {
94
+ const t = typeof u === 'string' ? u.trim() : '';
95
+ if (t && !/^http:\/\/\S+/i.test(t)) throw new Error('目前只支持 http:// 代理地址');
96
+ writeNetPref({ ...netPref(), proxyUrl: t || null });
79
97
  }
80
98
 
81
99
  const envProxy = () => process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || null;
100
+ /** 生效代理:面板保存的优先,其次环境变量 */
101
+ const effectiveProxy = () => proxyUrl() || envProxy();
82
102
 
83
103
  /** 目标是否应绕过代理(loopback / NO_PROXY 后缀匹配) */
84
104
  function proxyBypass(url) {
@@ -118,8 +138,8 @@ function connectTunnel(proxyUrl, target) {
118
138
  }
119
139
 
120
140
  async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
121
- const proxy = envProxy();
122
- if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(HTTPS_PROXY 未设置)');
141
+ const proxy = effectiveProxy();
142
+ if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(面板配置或 HTTPS_PROXY 环境变量)');
123
143
  const u = new URL(url);
124
144
  const p = new URL(proxy);
125
145
  const isHttps = u.protocol === 'https:';
@@ -163,7 +183,7 @@ export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
163
183
 
164
184
  export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
165
185
  const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: AbortSignal.timeout(timeoutMs) };
166
- const useProxy = Boolean(envProxy()) && !proxyBypass(url);
186
+ const useProxy = Boolean(effectiveProxy()) && !proxyBypass(url);
167
187
  const direct = () => fetch(url, base);
168
188
  const viaProxy = viaProxyImpl
169
189
  ? () => viaProxyImpl(url, { method, headers, body, timeoutMs })