creditdaddy 1.5.2 → 1.6.2
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/build.json +3 -3
- package/package.json +1 -1
- package/src/assets/donate-alipay.svg +1 -0
- package/src/assets/donate-ifdian.svg +1 -0
- package/src/assets/donate-wechat.svg +1 -0
- package/src/checkin.js +11 -10
- package/src/constants.js +3 -1
- package/src/cookieSites.js +30 -11
- package/src/daemon.js +2062 -1961
- package/src/doubaoClient.js +168 -0
- package/src/externalQuota.js +113 -9
- package/src/formatDuration.js +17 -0
- package/src/i18n/en.json +1734 -1755
- package/src/i18n/ja.json +1734 -1755
- package/src/i18n/ko.json +1734 -1755
- package/src/i18n/zh-TW.json +1734 -1755
- package/src/longcatClient.js +24 -15
- package/src/minimaxClient.js +29 -0
- package/src/minimaxGateway.js +459 -419
- package/src/mirasimClient.js +3 -0
- package/src/panel.html +393 -126
- package/src/providers.js +39 -3
- package/src/qoderApp.js +10 -1
- package/src/tenrouter.js +27 -3
- package/src/traeClient.js +23 -15
- package/src/traeGateway.js +52 -4
- package/src/traeLocal.js +14 -4
- package/src/usageSync.js +10 -2
- package/src/workbuddyClient.js +0 -45
- package/src/workbuddyLocal.js +40 -0
- package/src/zcodeAutoClaim.js +21 -4
- package/src/zcodeClient.js +8 -0
- package/src/zcodeGateway.js +763 -616
package/src/providers.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { productOf } from './constants.js';
|
|
12
12
|
import { checkinOne as checkinQoder, fetchQuotaUsage, fetchUserinfo, fetchUsageDetail } from './qoderClient.js';
|
|
13
|
-
import { checkinWorkbuddy,
|
|
13
|
+
import { checkinWorkbuddy, fetchWorkbuddyQuota, inspectToken } from './workbuddyClient.js';
|
|
14
14
|
import { fetchZcodeQuota } from './zcodeClient.js';
|
|
15
15
|
import { fetchMirasimQuota, fetchMirasimProfile } from './mirasimClient.js';
|
|
16
16
|
import { fetchCatpawQuota, fetchCatpawProfile } from './catpawClient.js';
|
|
17
17
|
import { fetchTraeQuota, checkinTrae, verifyTraeAccount } from './traeClient.js';
|
|
18
18
|
import { fetchMiniMaxQuota, checkinMiniMax, verifyMiniMaxAccount } from './minimaxClient.js';
|
|
19
|
+
import { fetchDoubaoStatus, checkinDoubao } from './doubaoClient.js';
|
|
19
20
|
/** 从 userinfo 响应中挑一个可读的显示名 */
|
|
20
21
|
export function displayNameFrom(ui) {
|
|
21
22
|
const pick = [ui?.nickname, ui?.name, ui?.username, ui?.email]
|
|
@@ -124,8 +125,9 @@ const PRODUCTS = {
|
|
|
124
125
|
},
|
|
125
126
|
workbuddy: {
|
|
126
127
|
label: 'WorkBuddy',
|
|
127
|
-
// 国内版走 billing daily-checkin
|
|
128
|
-
|
|
128
|
+
// 国内版走 billing daily-checkin 签到;国际版无签到动作(原「活跃领取」线已删——
|
|
129
|
+
// 逐日主动打上游对话接口博资格的机制整体下线,workbuddy-intl 产品线本身保留,额度查看照常)
|
|
130
|
+
checkin: (account, ctx) => checkinWorkbuddy(account, ctx),
|
|
129
131
|
quota: (account, ctx) => fetchWorkbuddyQuota(account, ctx),
|
|
130
132
|
verify: async (account) => {
|
|
131
133
|
const info = inspectToken(account.token);
|
|
@@ -194,6 +196,40 @@ const PRODUCTS = {
|
|
|
194
196
|
quota: (account, ctx = {}) => fetchMiniMaxQuota(account, wrapMiniMaxRefresh(account, ctx)),
|
|
195
197
|
verify: (account) => verifyMiniMaxAccount(account),
|
|
196
198
|
},
|
|
199
|
+
doubao: {
|
|
200
|
+
label: '豆包',
|
|
201
|
+
// 网页会话捕获线(issue #32):凭据是 doubao.com 会话 cookie(meta.cookieSiteSession.cookie)。
|
|
202
|
+
// 每日签到 +200 额度(服务端幂等);「额度」= 活动累计获得点数(progress.total_amount),
|
|
203
|
+
// 与其他产品线的套餐/积分口径不同,面板以独立产品页呈现。
|
|
204
|
+
checkin: (account) => checkinDoubao(account),
|
|
205
|
+
quota: async (account) => {
|
|
206
|
+
const cookie = account.meta?.cookieSiteSession?.cookie;
|
|
207
|
+
const st = await fetchDoubaoStatus(cookie);
|
|
208
|
+
if (!st.auth) {
|
|
209
|
+
const e = new Error('豆包会话已失效,请重新登录捕获');
|
|
210
|
+
e.auth = true;
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
total: st.totalAmount,
|
|
215
|
+
used: 0,
|
|
216
|
+
remaining: st.totalAmount,
|
|
217
|
+
exceeded: false,
|
|
218
|
+
plan: null,
|
|
219
|
+
planExpiresAt: null,
|
|
220
|
+
parts: st.checkin ? [{
|
|
221
|
+
name: '每日签到',
|
|
222
|
+
total: st.checkin.rewardAmount,
|
|
223
|
+
used: 0,
|
|
224
|
+
remaining: st.checkin.checkedIn ? st.checkin.rewardAmount : 0,
|
|
225
|
+
expiresAt: null,
|
|
226
|
+
recurring: true,
|
|
227
|
+
unit: '额度',
|
|
228
|
+
}] : [],
|
|
229
|
+
empty: st.totalAmount === 0 && !st.checkin,
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
},
|
|
197
233
|
};
|
|
198
234
|
|
|
199
235
|
/** 包裹 MiniMax 的 ctx.onRefresh:回写账号库后,安全同步新凭据回本机 auth.json(仅本机导入账号且客户端未运行时) */
|
package/src/qoderApp.js
CHANGED
|
@@ -496,7 +496,16 @@ export async function switchTo(account, { force = false } = {}) {
|
|
|
496
496
|
if (!auth || !file) {
|
|
497
497
|
throw new Error(`账号「${account.name || account.id}」没有本机登录快照,无法切换:请在 Qoder 里登录该账号后到「添加账号 → 本机导入」导入一次`);
|
|
498
498
|
}
|
|
499
|
-
if (!fs.existsSync(file))
|
|
499
|
+
if (!fs.existsSync(file)) {
|
|
500
|
+
// 本机当初导入时文件是在的,现在没了:绝大多数情况是客户端退出登录(登出/换号后它会删掉
|
|
501
|
+
// auth.v1.dat),只有少数是卸载或换了数据目录。按发生率给指引,别让人以为软件坏了。
|
|
502
|
+
const app = detectQoderApps().find((a) => a.provider === account.provider);
|
|
503
|
+
const label = app?.label || (account.provider === 'qoder-cn' ? 'Qoder 国内版' : 'Qoder 国际版');
|
|
504
|
+
const hint = app?.installed
|
|
505
|
+
? `请打开「${label}」客户端确认已登录该账号,然后到「添加账号 → 本机导入」重新导入一次`
|
|
506
|
+
: `没检测到「${label}」客户端,请先安装并登录,然后到「添加账号 → 本机导入」导入`;
|
|
507
|
+
throw new Error(`账号「${account.name || account.id}」的切换凭据已失效:${label}的登录文件不在了(${file})。${hint}`);
|
|
508
|
+
}
|
|
500
509
|
if (!force && qoderRunning()) {
|
|
501
510
|
const e = new Error('Qoder 正在运行。切换会改写 Qoder 的登录文件,运行中的 Qoder 退出时会把旧登录写回去——退出后重试,或选择「关闭并强制切换」。');
|
|
502
511
|
e.qoderRunning = true;
|
package/src/tenrouter.js
CHANGED
|
@@ -29,6 +29,14 @@ const DEFAULT_CONFIG = {
|
|
|
29
29
|
syncState: {},
|
|
30
30
|
lastSync: null,
|
|
31
31
|
quotaPush: { enabled: true, intervalMin: 60, lastPush: null },
|
|
32
|
+
// 账号连接同步(账号管理弹窗「同步账号到 10R」):enabled 总开关 + 渠道勾选。
|
|
33
|
+
// providers 空 = 全部候选渠道(向后兼容老配置);渠道是 CD provider 视角
|
|
34
|
+
// (qoder / qoder-cn / workbuddy / workbuddy-intl / tokenharbor)。
|
|
35
|
+
accountSync: { enabled: true, providers: [] },
|
|
36
|
+
// 额度卡推送的渠道排除:10Router 已单独配了同源渠道(如 minimax-code 连接)时,
|
|
37
|
+
// 免去 CD 侧维护同名免费卡(minimax-free-*),两边卡片重复且数字各刷新各的。
|
|
38
|
+
// 记「明确不推」的 CD provider,空 = 全推。
|
|
39
|
+
quotaPushDisabledProviders: [],
|
|
32
40
|
};
|
|
33
41
|
// v1.0.0 及更早保存的来源全集;版本迁移只补「此后新增」的来源,不复活用户手动关掉的
|
|
34
42
|
const LEGACY_SOURCES = ['zcode', 'opencode', 'mirasim', 'mimo', 'catpaw'];
|
|
@@ -39,6 +47,7 @@ export function loadConfig() {
|
|
|
39
47
|
const c = JSON.parse(fs.readFileSync(FILE(), 'utf8'));
|
|
40
48
|
const sync = { ...DEFAULT_CONFIG.sync, ...(c.sync || {}) };
|
|
41
49
|
const quotaPush = { ...DEFAULT_CONFIG.quotaPush, ...(c.quotaPush || {}) };
|
|
50
|
+
const accountSync = { ...DEFAULT_CONFIG.accountSync, ...(c.accountSync || {}) };
|
|
42
51
|
// 旧配置里的 sources 是保存时的快照,会盖掉新增来源(妙手因此被静默关掉);
|
|
43
52
|
// 只把此后新增的来源默认并入,用户手动关掉的保持关闭。
|
|
44
53
|
const savedVersion = Number(c.sync?.sourcesVersion) || 1;
|
|
@@ -47,7 +56,7 @@ export function loadConfig() {
|
|
|
47
56
|
for (const s of SOURCES) if (!LEGACY_SOURCES.includes(s) && !sync.sources.includes(s)) sync.sources.push(s);
|
|
48
57
|
sync.sourcesVersion = SOURCES_VERSION;
|
|
49
58
|
}
|
|
50
|
-
return { ...DEFAULT_CONFIG, ...c, sync, quotaPush, syncState: c.syncState || {} };
|
|
59
|
+
return { ...DEFAULT_CONFIG, ...c, sync, quotaPush, accountSync, syncState: c.syncState || {} };
|
|
51
60
|
} catch { return structuredClone(DEFAULT_CONFIG); }
|
|
52
61
|
}
|
|
53
62
|
|
|
@@ -97,6 +106,8 @@ export async function publicConfig() {
|
|
|
97
106
|
hasAdminPassword: Boolean(c.adminPassword),
|
|
98
107
|
sync: c.sync,
|
|
99
108
|
quotaPush: c.quotaPush,
|
|
109
|
+
accountSync: c.accountSync,
|
|
110
|
+
quotaPushDisabledProviders: c.quotaPushDisabledProviders || [],
|
|
100
111
|
lastSync: c.lastSync,
|
|
101
112
|
syncing: Boolean(syncing),
|
|
102
113
|
sources: await detectSources(),
|
|
@@ -104,7 +115,7 @@ export async function publicConfig() {
|
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
/** 更新配置:key 留空表示保持原值;endpoint 为空 = 清除整个配置 */
|
|
107
|
-
export function updateConfig({ endpoint, key, adminPassword, syncEnabled, sources, quotaPushEnabled, quotaPushIntervalMin } = {}) {
|
|
118
|
+
export function updateConfig({ endpoint, key, adminPassword, syncEnabled, sources, quotaPushEnabled, quotaPushIntervalMin, accountSyncEnabled, accountSyncProviders, quotaPushDisabledProviders } = {}) {
|
|
108
119
|
return withConfig((c) => {
|
|
109
120
|
if (endpoint !== undefined) {
|
|
110
121
|
c.endpoint = normalizeEndpoint(endpoint);
|
|
@@ -127,6 +138,12 @@ export function updateConfig({ endpoint, key, adminPassword, syncEnabled, source
|
|
|
127
138
|
const min = Math.max(15, Math.min(1440, Number(quotaPushIntervalMin) || 60));
|
|
128
139
|
c.quotaPush.intervalMin = min;
|
|
129
140
|
}
|
|
141
|
+
// 账号连接同步:总开关 + 渠道白名单(空数组 = 不限渠道,向后兼容)
|
|
142
|
+
c.accountSync = { ...DEFAULT_CONFIG.accountSync, ...(c.accountSync || {}) };
|
|
143
|
+
if (typeof accountSyncEnabled === 'boolean') c.accountSync.enabled = accountSyncEnabled;
|
|
144
|
+
if (Array.isArray(accountSyncProviders)) c.accountSync.providers = accountSyncProviders.map(String);
|
|
145
|
+
// 额度卡推送的渠道排除(10R 侧已有同源渠道时免去重复卡片)
|
|
146
|
+
if (Array.isArray(quotaPushDisabledProviders)) c.quotaPushDisabledProviders = quotaPushDisabledProviders.map(String);
|
|
130
147
|
});
|
|
131
148
|
}
|
|
132
149
|
|
|
@@ -292,7 +309,14 @@ export async function pushQuotaCards({ onlySources } = {}) {
|
|
|
292
309
|
if (c.quotaPush?.enabled === false) return { results: [], skipped: 'disabled' };
|
|
293
310
|
const { runQuotaPush } = await import('./externalQuota.js');
|
|
294
311
|
const { loadAccounts } = await import('./store.js');
|
|
295
|
-
|
|
312
|
+
let accounts = await loadAccounts();
|
|
313
|
+
// 渠道排除:10Router 已单独配了同源渠道(如 minimax-code 连接)时,跳过对应 CD 渠道的
|
|
314
|
+
// 免费卡推送——两边卡片重复且数字各刷各的。账号从源头滤掉比在 runQuotaPush 里逐家族判
|
|
315
|
+
// 更简单:没有账号的家族自然一个卡都不出。
|
|
316
|
+
const disabled = Array.isArray(c.quotaPushDisabledProviders) ? c.quotaPushDisabledProviders : [];
|
|
317
|
+
if (disabled.length && !Array.isArray(onlySources)) {
|
|
318
|
+
accounts = accounts.filter((a) => !disabled.includes(a.provider));
|
|
319
|
+
}
|
|
296
320
|
const res = await runQuotaPush({
|
|
297
321
|
endpoint: c.endpoint, key: c.key, accounts,
|
|
298
322
|
...(Array.isArray(onlySources) && onlySources.length ? { onlySource: onlySources } : {}),
|
package/src/traeClient.js
CHANGED
|
@@ -52,9 +52,12 @@ function seeded(uid, salt, n) {
|
|
|
52
52
|
return out.slice(0, n);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function traeDevice(uid, storedDeviceId) {
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
export function traeDevice(uid, storedDeviceId, attempt = 0) {
|
|
56
|
+
// attempt 参数:9074 重试时轮换派生设备号(issue #31 报告者实测——派生号被服务端一律拒 9074、
|
|
57
|
+
// 同一时刻真机号可领,说明服务端大概率只认登录时注册的设备号,换号重试才有机会过)。
|
|
58
|
+
// attempt=0 为默认指纹(跨天稳定恒定);attempt>0 派生结果互异但同样确定(同 attempt 重放一致)。
|
|
59
|
+
const d = seeded(`${uid}`, `devid:${attempt}`, 15).map((b) => String(48 + (b % 10)));
|
|
60
|
+
const m = seeded(`${uid}`, `market:${attempt}`, 16);
|
|
58
61
|
m[6] = (m[6] & 0x0f) | 0x40;
|
|
59
62
|
m[8] = (m[8] & 0x3f) | 0x80;
|
|
60
63
|
const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
@@ -102,12 +105,12 @@ function findField(obj, key, depth = 0) {
|
|
|
102
105
|
return undefined;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
/** 统一 POST → { status, body };401 / code 1001 抛 auth
|
|
106
|
-
async function post(url, account, body = {}) {
|
|
108
|
+
/** 统一 POST → { status, body };401 / code 1001 抛 auth 错误(凭据失效)。attempt 透传给设备派生(9074 轮换重试用) */
|
|
109
|
+
async function post(url, account, body = {}, attempt = 0) {
|
|
107
110
|
const jwt = account.token;
|
|
108
111
|
if (!jwt) throw new Error('账号缺少 Trae 登录 token(请到 Trae 客户端登录后用「本机导入」同步)');
|
|
109
112
|
const uid = account.uid || uidFromJwt(jwt);
|
|
110
|
-
const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId);
|
|
113
|
+
const dev = traeDevice(uid || 'anonymous', account.meta?.deviceId, attempt);
|
|
111
114
|
const res = await fetchJsonRace(url, {
|
|
112
115
|
method: 'POST',
|
|
113
116
|
headers: traeHeaders(jwt, dev),
|
|
@@ -230,10 +233,12 @@ export async function checkinTrae(account, ctx = {}) {
|
|
|
230
233
|
if (/已经签到|已签到|already.*sign/i.test(String(r.message || ''))) {
|
|
231
234
|
return { ...base, status: 'already', message: withCode(r.message || '今天已签到', r.code), uid };
|
|
232
235
|
}
|
|
233
|
-
// 9074
|
|
234
|
-
//
|
|
236
|
+
// 9074 实测两副面孔(issue #31):面板文案「当前参与用户太多」像瞬时限流,但报告者对照
|
|
237
|
+
// 实验显示派生设备号被服务端一律拒 9074、同一时刻真机号可领——服务端大概率只认登录时
|
|
238
|
+
// 注册的设备号。因此重试时**轮换派生设备号**(attempt 递增),退避 + 换号双管齐下;
|
|
239
|
+
// 重试耗尽归 failed(不写签到 memo,下一轮还试)
|
|
235
240
|
if (r.code === 9074) {
|
|
236
|
-
return finalizeClaim(await claimWithRetry(ctx, () => post(CLAIM_URL, account)), base, uid, TRAE_DEFAULT_DAILY_CREDITS);
|
|
241
|
+
return finalizeClaim(await claimWithRetry(ctx, (i) => post(CLAIM_URL, account, {}, i)), base, uid, TRAE_DEFAULT_DAILY_CREDITS);
|
|
237
242
|
}
|
|
238
243
|
return { ...base, status: 'failed', message: withCode(r.message || `code ${r.code}`, r.code), uid };
|
|
239
244
|
}
|
|
@@ -246,17 +251,20 @@ export async function checkinTrae(account, ctx = {}) {
|
|
|
246
251
|
// claim 接口有时只返回 { code: 0, message: "success" } 而不包含 credits 字段,作为 fallback 避免 +0
|
|
247
252
|
const expectedCredits = Number(findField(r.body, 'credits') ?? findField(r.body, 'extra_credits')) || TRAE_DEFAULT_DAILY_CREDITS;
|
|
248
253
|
|
|
249
|
-
return finalizeClaim(await claimWithRetry(ctx, () => post(CLAIM_URL, account)), base, uid, expectedCredits);
|
|
254
|
+
return finalizeClaim(await claimWithRetry(ctx, (i) => post(CLAIM_URL, account, {}, i)), base, uid, expectedCredits);
|
|
250
255
|
}
|
|
251
256
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
257
|
+
/**
|
|
258
|
+
* claim 重试:间隔 3s/8s/15s,每次重试换一个派生设备号(attempt 1/2/3)。
|
|
259
|
+
* 首次尝试用默认指纹(attempt 0),始终失败返回最后一次响应(由 finalizeClaim 归 failed)。
|
|
260
|
+
*/
|
|
261
|
+
async function claimWithRetry(ctx, attemptFn) {
|
|
254
262
|
const backoff = [0, 3000, 8000, 15000];
|
|
255
|
-
let res = await
|
|
263
|
+
let res = await attemptFn(0);
|
|
256
264
|
for (let i = 1; i < backoff.length && res.code === 9074; i++) {
|
|
257
|
-
ctx.log?.(`Trae
|
|
265
|
+
ctx.log?.(`Trae 服务端拒绝(code 9074),${backoff[i] / 1000}s 后换设备号重试(${i}/${backoff.length - 1})`);
|
|
258
266
|
await new Promise((ok) => setTimeout(ok, backoff[i]));
|
|
259
|
-
res = await
|
|
267
|
+
res = await attemptFn(i);
|
|
260
268
|
}
|
|
261
269
|
return res;
|
|
262
270
|
}
|
package/src/traeGateway.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 架构特性:
|
|
8
8
|
* 1. 账号多路轮换(provider=trae):自动在有效账号间按剩余积分加权轮询(SWRR)
|
|
9
9
|
* 2. 凭据热对齐:401 时若为本机导入账号,尝试重读 storage.json 最新登录态
|
|
10
|
-
* 3. 429 限流保护:429
|
|
10
|
+
* 3. 429 限流保护:429 限流自动冷却(退避阶梯 30s → 1min → 2min → 5min → 10min 封顶,成功一次即清零)
|
|
11
11
|
* 4. 局域网访问控制:继承相同的白名单与回环检查机制
|
|
12
12
|
* 5. SSE 增量转换:将 Trae SOLO 累积流转换为标准 Anthropic 流式 SSE 或一次性 JSON 响应
|
|
13
13
|
*/
|
|
@@ -18,9 +18,12 @@ import { fetchTraeQuota } from './traeClient.js';
|
|
|
18
18
|
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
19
19
|
import { logger, summarizeAttempts } from './logger.js';
|
|
20
20
|
import { t } from './i18n.js';
|
|
21
|
+
import { formatDurationMs } from './formatDuration.js';
|
|
21
22
|
|
|
22
23
|
const TRAE_SOLO_BASE = 'https://solo.trae.cn/api/remote/v1';
|
|
23
24
|
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
25
|
+
// 429 退避阶梯:30s → 1min → 2min → 5min → 10min 封顶(与 ZCode 网关同款)。
|
|
26
|
+
const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
|
|
24
27
|
const MAX_ATTEMPTS = 5;
|
|
25
28
|
const UPSTREAM_TIMEOUT_MS = 600_000;
|
|
26
29
|
const UPSTREAM_CONNECT_MS = 15_000;
|
|
@@ -38,12 +41,15 @@ export async function setGatewayEnabled(v) {
|
|
|
38
41
|
if (on) {
|
|
39
42
|
dead.clear();
|
|
40
43
|
cooling.clear();
|
|
44
|
+
coolingStreak.clear();
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
// ── 账号轮换状态(进程内) ──
|
|
45
49
|
|
|
46
50
|
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
51
|
+
// accountId → 连续 429 次数(成功即清零):决定本次 429 走到退避阶梯的哪一档
|
|
52
|
+
const coolingStreak = new Map();
|
|
47
53
|
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
|
|
48
54
|
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
|
|
49
55
|
const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
|
|
@@ -64,6 +70,16 @@ function markCooling(id, ms = ACCOUNT_COOLING_MS) {
|
|
|
64
70
|
cooling.set(id, Date.now() + ms);
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
function markRateLimited(id) {
|
|
74
|
+
const streak = (coolingStreak.get(id) || 0) + 1;
|
|
75
|
+
coolingStreak.set(id, streak);
|
|
76
|
+
const ms = RATE_LIMIT_BACKOFF_MS[Math.min(streak, RATE_LIMIT_BACKOFF_MS.length) - 1];
|
|
77
|
+
cooling.set(id, Date.now() + ms);
|
|
78
|
+
return ms;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resetCoolingStreak(id) { coolingStreak.delete(id); }
|
|
82
|
+
|
|
67
83
|
function markDead(account) {
|
|
68
84
|
dead.set(account.id, credFingerprint(account));
|
|
69
85
|
cooling.set(account.id, Number.MAX_SAFE_INTEGER);
|
|
@@ -75,12 +91,20 @@ function isDead(account) {
|
|
|
75
91
|
if (fp === credFingerprint(account)) return true;
|
|
76
92
|
dead.delete(account.id);
|
|
77
93
|
cooling.delete(account.id);
|
|
94
|
+
coolingStreak.delete(account.id);
|
|
78
95
|
logger.info('TRAE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
79
96
|
return false;
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
/**
|
|
100
|
+
* 测试专用:把某账号的冷却截止拨到指定时间戳(0 = 立即到期,用于快进冷却窗口)。
|
|
101
|
+
* 生产不走这里——冷却窗口只能自然到期。与 zcodeGateway.__setCoolingForTests 同款。
|
|
102
|
+
*/
|
|
103
|
+
export function __setCoolingForTests(accountId, untilMs) { cooling.set(accountId, untilMs); }
|
|
104
|
+
|
|
82
105
|
export function __resetForTests() {
|
|
83
106
|
cooling.clear();
|
|
107
|
+
coolingStreak.clear();
|
|
84
108
|
dead.clear();
|
|
85
109
|
weights.clear();
|
|
86
110
|
swrr.clear();
|
|
@@ -397,9 +421,11 @@ export async function handleGateway(req, res) {
|
|
|
397
421
|
}
|
|
398
422
|
|
|
399
423
|
if (sessionRes.status === 429) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
424
|
+
const ms = markRateLimited(account.id);
|
|
425
|
+
const dur = formatDurationMs(ms);
|
|
426
|
+
const streak = coolingStreak.get(account.id);
|
|
427
|
+
attempted.push({ account: label, ok: false, error: `429 限流,冷却 ${dur}` });
|
|
428
|
+
logger.info('TRAE-GW', '{label} 429 限流,冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
|
|
403
429
|
continue;
|
|
404
430
|
}
|
|
405
431
|
|
|
@@ -446,11 +472,22 @@ export async function handleGateway(req, res) {
|
|
|
446
472
|
|
|
447
473
|
if (!eventsRes.ok || !eventsRes.body) {
|
|
448
474
|
cleanupSession();
|
|
475
|
+
// 事件流被 429(会话建好了、拉流被限流)同样按阶梯冷却——不判的话这个号
|
|
476
|
+
// 下一轮会话创建仍从 30 秒档起算,继续撞限流
|
|
477
|
+
if (eventsRes.status === 429) {
|
|
478
|
+
const ms = markRateLimited(account.id);
|
|
479
|
+
const dur = formatDurationMs(ms);
|
|
480
|
+
const streak = coolingStreak.get(account.id);
|
|
481
|
+
attempted.push({ account: label, ok: false, error: `429 限流(事件流),冷却 ${dur}` });
|
|
482
|
+
logger.info('TRAE-GW', '{label} 429 限流(事件流),冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
449
485
|
attempted.push({ account: label, ok: false, error: `事件流 HTTP ${eventsRes.status}` });
|
|
450
486
|
continue;
|
|
451
487
|
}
|
|
452
488
|
|
|
453
489
|
stats.lastAccount = label;
|
|
490
|
+
resetCoolingStreak(account.id); // 成功一次即清零连续 429 计数
|
|
454
491
|
logger.debug('TRAE-GW', '{label} 连接成功,开始流式输出 ({model})', { label, model: requestedModel });
|
|
455
492
|
|
|
456
493
|
// 成功建立流,按 isStream 分发
|
|
@@ -691,7 +728,18 @@ export async function gatewayStatus() {
|
|
|
691
728
|
lastAccount: stats.lastAccount,
|
|
692
729
|
},
|
|
693
730
|
accounts: all.length,
|
|
731
|
+
// cooling 保持字符串数组(历史形状);冷却细节走 coolingDetail(与 zcodeGateway 同款)
|
|
694
732
|
cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
733
|
+
coolingDetail: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now)
|
|
734
|
+
.map((a) => {
|
|
735
|
+
const remainMs = (cooling.get(a.id) || 0) - now;
|
|
736
|
+
return {
|
|
737
|
+
id: a.id, name: a.name || a.uid || a.id,
|
|
738
|
+
remainMs, remainSeconds: Math.max(0, Math.ceil(remainMs / 1000)),
|
|
739
|
+
remainText: formatDurationMs(remainMs),
|
|
740
|
+
streak: coolingStreak.get(a.id) || 0,
|
|
741
|
+
};
|
|
742
|
+
}),
|
|
695
743
|
dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
|
|
696
744
|
endpoint: '/gateway/trae/v1/messages',
|
|
697
745
|
};
|
package/src/traeLocal.js
CHANGED
|
@@ -16,8 +16,12 @@ import crypto from 'node:crypto';
|
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
17
|
import { dataDir } from './store.js';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// 探测顺序(2026-10-21 TraeWork→TRAE 迁移后定版):新 TRAE CN 优先、国际版次之,
|
|
20
|
+
// SOLO 两个旧变体垫底——SOLO CN 客户端已停发(官方数据迁移页引导全员升新 TRAE),
|
|
21
|
+
// 但迁移期仍有未升级用户,保留兜底;已卸载残留的旧目录读到陈旧凭据的危害大于漏检,
|
|
22
|
+
// 故不再排最前。TRAE_HOME 环境变量可显式指定目录(traeDataDir)。
|
|
23
|
+
const APP_NAMES = ['Trae CN', 'Trae', 'TRAE SOLO CN', 'TRAE SOLO'];
|
|
24
|
+
const EXE_NAMES = ['Trae CN.exe', 'Trae.exe', 'TRAE SOLO CN.exe', 'TRAE SOLO.exe'];
|
|
21
25
|
const AUTH_KEY = 'iCubeAuthInfo://icube.cloudide';
|
|
22
26
|
const DC_PREFIX = 'iCubeAuthInfo://icube-dc:';
|
|
23
27
|
const HEADER = Buffer.from([116, 99, 5, 16, 0, 0]);
|
|
@@ -98,7 +102,12 @@ export function readLiveAuth() {
|
|
|
98
102
|
if (!storage) return null;
|
|
99
103
|
const auth = tcDecrypt(storage[AUTH_KEY]);
|
|
100
104
|
if (!auth || typeof auth.token !== 'string') return null;
|
|
101
|
-
|
|
105
|
+
// 国际版防御:信封里带登录 host(api.trae.cn = 国内 / *.trae.ai = 国际,实测 TraeWork→TRAE
|
|
106
|
+
// 迁移后的国际版目录 host 为 growsg-normal.trae.ai)。CD 的 Trae 线(额度/签到/网关)全部
|
|
107
|
+
// 打 api.trae.cn,国际版 JWT 不被认——标记 isIntl 让上层明确提示,而不是导入后对着空额度卡困惑。
|
|
108
|
+
const host = String(auth.host || '');
|
|
109
|
+
const isIntl = Boolean(host) && !/(^|\.)trae\.cn$/.test(new URL(host).hostname || '');
|
|
110
|
+
return { dataDir: dir, deviceId: deviceIdOf(storage), auth, isIntl, host: host || null };
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
const decodeTasklist = (buf) => {
|
|
@@ -157,7 +166,7 @@ export function detectTrae() {
|
|
|
157
166
|
export async function liveToAccount() {
|
|
158
167
|
const live = readLiveAuth();
|
|
159
168
|
if (!live) return null;
|
|
160
|
-
const { auth, deviceId } = live;
|
|
169
|
+
const { auth, deviceId, isIntl } = live;
|
|
161
170
|
const account = {
|
|
162
171
|
provider: 'trae',
|
|
163
172
|
token: auth.token,
|
|
@@ -174,6 +183,7 @@ export async function liveToAccount() {
|
|
|
174
183
|
deviceId: deviceId || undefined,
|
|
175
184
|
host: auth.host || undefined,
|
|
176
185
|
region: auth.userRegion?.region || undefined,
|
|
186
|
+
isIntl: isIntl || undefined,
|
|
177
187
|
avatarUrl: auth.account?.avatar_url || undefined,
|
|
178
188
|
capturedAt: new Date().toISOString(),
|
|
179
189
|
},
|
package/src/usageSync.js
CHANGED
|
@@ -451,7 +451,7 @@ function collectMimo(files, now = Date.now()) {
|
|
|
451
451
|
function collectMirasim(files, endpoint, now = Date.now()) {
|
|
452
452
|
const out = [];
|
|
453
453
|
const seen = new Set();
|
|
454
|
-
let selfHosted = 0, unsettled = 0;
|
|
454
|
+
let selfHosted = 0, relayed = 0, unsettled = 0;
|
|
455
455
|
for (const f of files) {
|
|
456
456
|
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
457
457
|
if (!line.trim()) continue;
|
|
@@ -460,6 +460,14 @@ function collectMirasim(files, endpoint, now = Date.now()) {
|
|
|
460
460
|
if (!e || typeof e !== 'object') continue;
|
|
461
461
|
if (e.id) { if (seen.has(e.id)) continue; seen.add(e.id); }
|
|
462
462
|
if (isSelfHostedUpstream(e.upstreamHost, endpoint)) { selfHosted++; continue; }
|
|
463
|
+
// 只同步走 mirasim 官方中继(relay.mirasim.ai)的调用(2026-10-21 用户拍板):
|
|
464
|
+
// mirasim IDE 里直连上游的调用,上游就是本机 10Router——10R 已原生记账(落在
|
|
465
|
+
// codebuddy-cn 等连接名下),CD 再导一遍就是同一批调用记两笔(10R 用量表里
|
|
466
|
+
// glm-5.3-flashx 出现 codebuddy-cn 3,800 + mirasim-anthropic 2,917 两行重复)。
|
|
467
|
+
// viaRelay=true 的行走 mirasim 官方云中继,10R 没见过,才是 CD 该补的账。
|
|
468
|
+
// direct 行 upstreamHost 全为 null 无从判上游,viaRelay 就是现成的分界(实测
|
|
469
|
+
// relay 行全部是 claude/kimi 家族、direct 行全部是 glm/deepseek/gemini,零交叉)。
|
|
470
|
+
if (!e.viaRelay) { relayed++; continue; }
|
|
463
471
|
if (!((e.input || 0) + (e.output || 0) + (e.cacheRead || 0) + (e.cacheWrite || 0))) continue;
|
|
464
472
|
// 刚发生的调用可能还在补字段(durationMs / token 等),定型前先不发
|
|
465
473
|
const tsMs = Date.parse(e.ts);
|
|
@@ -467,7 +475,7 @@ function collectMirasim(files, endpoint, now = Date.now()) {
|
|
|
467
475
|
out.push(convertMirasimRow(e));
|
|
468
476
|
}
|
|
469
477
|
}
|
|
470
|
-
return { entries: out, notes: (selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : []).concat(unsettled ? [`暂不同步 ${unsettled} 行 1 小时内的新调用(等记录定型)`] : []) };
|
|
478
|
+
return { entries: out, notes: (selfHosted ? [`跳过 ${selfHosted} 行经 10Router 中转的调用(已在 10Router 记账)`] : []).concat(relayed ? [`跳过 ${relayed} 行直连上游的调用(上游为本机 10Router,已在 10Router 记账)`] : []).concat(unsettled ? [`暂不同步 ${unsettled} 行 1 小时内的新调用(等记录定型)`] : []) };
|
|
471
479
|
}
|
|
472
480
|
|
|
473
481
|
/** 读取某来源的全部行:{ entries, notes, files, sentTotals? };来源不存在返回 entries=[] */
|
package/src/workbuddyClient.js
CHANGED
|
@@ -94,51 +94,6 @@ async function post(account, path, token) {
|
|
|
94
94
|
return { status: res.status, body, text };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// 国际版「活跃领取」探测:官方规则为「当天有 ≥1 次有效对话请求即视为活跃用户,发放每日赠送积分」。
|
|
98
|
-
// 没有签到接口,所以每天发一条免费档模型(rateMultiplier 0,~0 消耗)的极短流式请求。
|
|
99
|
-
// 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
|
|
100
|
-
const INTL_PROBE_MODEL = 'hy4-preview';
|
|
101
|
-
async function postIntlProbe(account, token) {
|
|
102
|
-
const res = await fetchJsonRace(`https://${apiHost(account)}/v2/chat/completions`, {
|
|
103
|
-
method: 'POST',
|
|
104
|
-
headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
105
|
-
body: JSON.stringify({
|
|
106
|
-
model: INTL_PROBE_MODEL,
|
|
107
|
-
stream: true,
|
|
108
|
-
max_tokens: 16,
|
|
109
|
-
messages: [
|
|
110
|
-
{ role: 'system', content: 'You are CodeBuddy Code.' },
|
|
111
|
-
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
112
|
-
],
|
|
113
|
-
}),
|
|
114
|
-
timeoutMs: Math.max(FETCH_TIMEOUT_MS, 25_000),
|
|
115
|
-
});
|
|
116
|
-
// 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
|
|
117
|
-
const text = await res.text().catch(() => '');
|
|
118
|
-
return { status: res.status, text };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 国际版「活跃领取」:一次免费档对话请求使账号成为当日活跃用户。
|
|
123
|
-
* 返回与每日签到一致的结果对象(session-ok → checked-in,claimedAmount 0 表示额度随官方结算发放)。
|
|
124
|
-
*/
|
|
125
|
-
export async function checkinWorkbuddyIntl(account, { onRefresh } = {}) {
|
|
126
|
-
const label = account.name || account.id;
|
|
127
|
-
const base = { accountId: account.id, account: label, provider: account.provider };
|
|
128
|
-
try {
|
|
129
|
-
const r = await withToken(account, (t) => postIntlProbe(account, t), onRefresh);
|
|
130
|
-
if (r.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在客户端重新登录后重新导入' };
|
|
131
|
-
if (r.status === 429) return { ...base, status: 'no-activity', message: '今日额度已耗尽,活跃请求暂不可达(等官方发放后再试)', claimedAmount: 0 };
|
|
132
|
-
if (r.status >= 200 && r.status < 300) {
|
|
133
|
-
return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: 0, message: '已发送活跃请求(保持活跃,活跃不等于必有奖励)' };
|
|
134
|
-
}
|
|
135
|
-
const m = (() => { try { return JSON.parse(r.text)?.error?.data?.msg || ''; } catch { return ''; } })();
|
|
136
|
-
return { ...base, status: 'failed', error: `活跃请求失败:HTTP ${r.status} ${m || r.text.slice(0, 120)}`.trim() };
|
|
137
|
-
} catch (e) {
|
|
138
|
-
return { ...base, status: 'failed', error: e?.message || '网络错误' };
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
97
|
export function isExpired(account, now = Date.now()) {
|
|
143
98
|
const exp = account.expiresAt || inspectToken(account.token).expiresAt;
|
|
144
99
|
return Boolean(exp) && new Date(exp).getTime() - EXPIRY_SKEW_MS <= now;
|
package/src/workbuddyLocal.js
CHANGED
|
@@ -99,6 +99,46 @@ export function currentWorkbuddyUid() {
|
|
|
99
99
|
return readWorkbuddySessions().accounts.find((a) => a.current)?.uid || null;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// 客户端可执行文件候选(VS Code 系安装惯例:%LOCALAPPDATA%\Programs\<名>\<名>.exe)。
|
|
103
|
+
// 「已安装」只认 exe:凭据目录不能当依据——CreditDaddy 自己切换时 mkdir 也会把目录建出来
|
|
104
|
+
// (无客户端时静默切换留下的空壳目录),拿目录判断会把没装客户端的机器误报成已安装。
|
|
105
|
+
const CLIENT_CANDIDATES = process.platform === 'win32'
|
|
106
|
+
? [
|
|
107
|
+
['Programs', 'CodeBuddy', 'CodeBuddy.exe'],
|
|
108
|
+
['Programs', 'WorkBuddy', 'WorkBuddy.exe'],
|
|
109
|
+
['CodeBuddy', 'CodeBuddy.exe'],
|
|
110
|
+
['WorkBuddy', 'WorkBuddy.exe'],
|
|
111
|
+
]
|
|
112
|
+
: process.platform === 'darwin'
|
|
113
|
+
? ['/Applications/CodeBuddy.app', '/Applications/WorkBuddy.app']
|
|
114
|
+
: [];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 本机 WorkBuddy / CodeBuddy 客户端探测(面板「客户端:未安装」提示与切换拦截用)。
|
|
118
|
+
* Linux / NAS 没有已知安装路径,退而用「凭据目录里有会话文件」判定——
|
|
119
|
+
* 会话文件只有客户端登录后才写,纯 Token 导入的机器不会产生。
|
|
120
|
+
*/
|
|
121
|
+
export function detectWorkbuddy() {
|
|
122
|
+
const dir = workbuddyAuthDir();
|
|
123
|
+
let dirExists = false;
|
|
124
|
+
try { dirExists = fs.statSync(dir).isDirectory(); } catch { /* 未安装 / 从未登录 */ }
|
|
125
|
+
let clientPath = null;
|
|
126
|
+
for (const cand of CLIENT_CANDIDATES) {
|
|
127
|
+
const p = process.platform === 'win32' && Array.isArray(cand)
|
|
128
|
+
? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), ...cand)
|
|
129
|
+
: cand;
|
|
130
|
+
try { if (fs.existsSync(p)) { clientPath = p; break; } } catch { /* 下一个候选 */ }
|
|
131
|
+
}
|
|
132
|
+
const sessions = dirExists ? readWorkbuddySessions() : { accounts: [] };
|
|
133
|
+
return {
|
|
134
|
+
dir,
|
|
135
|
+
dirExists,
|
|
136
|
+
clientPath,
|
|
137
|
+
installed: Boolean(clientPath) || (CLIENT_CANDIDATES.length === 0 && sessions.accounts.length > 0),
|
|
138
|
+
signedIn: sessions.accounts.some((a) => a.current),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
/**
|
|
103
143
|
* 把账号写成 WorkBuddy 当前会话(切换账号)。需要账号带有导入时保存的会话信息。
|
|
104
144
|
* @returns {{ file, previousUid }}
|
package/src/zcodeAutoClaim.js
CHANGED
|
@@ -67,7 +67,10 @@ export async function zcodeAutoClaim(account) {
|
|
|
67
67
|
// 第 2 步:先试不带验证码
|
|
68
68
|
try {
|
|
69
69
|
const r = await claimPlan(account, plan.planId, {});
|
|
70
|
-
|
|
70
|
+
// startsAt 是「到账时间」:活动权益常带 effective_at(如当天 23:00),
|
|
71
|
+
// 领到了但余额里还没有——不把这个时间带出去,面板只能显示冷冰冰的
|
|
72
|
+
// 「待生效」,用户以为没领上。
|
|
73
|
+
claims.push({ plan: planName, via: 'direct', ok: true, startsAt: r?.startsAt || null });
|
|
71
74
|
anySuccess = true;
|
|
72
75
|
logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(免验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
|
|
73
76
|
continue;
|
|
@@ -97,8 +100,8 @@ export async function zcodeAutoClaim(account) {
|
|
|
97
100
|
const cfg = await fetchCaptchaConfig();
|
|
98
101
|
if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
|
|
99
102
|
const { captchaParam, region } = await captchaProvider(cfg);
|
|
100
|
-
await claimPlan(account, plan.planId, { captchaParam, region });
|
|
101
|
-
claims.push({ plan: planName, via: 'captcha', ok: true });
|
|
103
|
+
const r2 = await claimPlan(account, plan.planId, { captchaParam, region });
|
|
104
|
+
claims.push({ plan: planName, via: 'captcha', ok: true, startsAt: r2?.startsAt || null });
|
|
102
105
|
anySuccess = true;
|
|
103
106
|
logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(静默验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
|
|
104
107
|
} catch (e2) {
|
|
@@ -128,8 +131,22 @@ export async function zcodeAutoClaim(account) {
|
|
|
128
131
|
const status = names.length ? 'checked-in'
|
|
129
132
|
: claims.every((c) => c.already) ? 'already'
|
|
130
133
|
: 'failed';
|
|
134
|
+
// 到账时间:新领的活动若带 effective_at(当天稍晚才进余额),把时间写进消息——
|
|
135
|
+
// 否则面板只显示「待生效」,用户无法判断是没领上还是要等。
|
|
136
|
+
const fmtAt = (iso) => {
|
|
137
|
+
if (!iso) return '';
|
|
138
|
+
const d = new Date(iso);
|
|
139
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
140
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
141
|
+
const now = new Date();
|
|
142
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
143
|
+
const hm = `${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
|
144
|
+
return sameDay ? hm : `${d.getMonth() + 1}-${p2(d.getDate())} ${hm}`;
|
|
145
|
+
};
|
|
146
|
+
const starts = claims.filter((c) => c.ok && c.startsAt && new Date(c.startsAt).getTime() > Date.now());
|
|
147
|
+
const startsText = starts.length ? `(${fmtAt(starts[0].startsAt)} 到账)` : '';
|
|
131
148
|
const message = status === 'checked-in'
|
|
132
|
-
? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
|
|
149
|
+
? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}${startsText}`
|
|
133
150
|
: status === 'already' ? '活动均已领取过'
|
|
134
151
|
: needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
|
|
135
152
|
: `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
|
package/src/zcodeClient.js
CHANGED
|
@@ -502,6 +502,8 @@ export function normalizeBalance(balanceResp) {
|
|
|
502
502
|
const pending = Boolean(startsAt && new Date(startsAt).getTime() > now);
|
|
503
503
|
return {
|
|
504
504
|
name: e.show_name || e.entitlement_id || '活动额度',
|
|
505
|
+
// 活动名(如「ZCode 1024 Dev Day」):未生效权益靠它告诉用户「这是哪个活动发的」
|
|
506
|
+
planName: String(p.name || p.plan_id || '') || null,
|
|
505
507
|
unit: 'Token',
|
|
506
508
|
total: grant,
|
|
507
509
|
used: 0,
|
|
@@ -514,6 +516,10 @@ export function normalizeBalance(balanceResp) {
|
|
|
514
516
|
}),
|
|
515
517
|
);
|
|
516
518
|
const parts = balanceParts.length ? balanceParts : entitlementParts;
|
|
519
|
+
// 未生效权益汇总:面板用它显示「待生效 N Token · 活动名 · HH:MM 到账」,
|
|
520
|
+
// 而不是把 0 当成「已用完」——活动权益通常要到生效点才进 balances
|
|
521
|
+
const pendingParts = parts.filter((p) => p.pending);
|
|
522
|
+
const pendingAt = pendingParts.map((p) => p.startsAt).filter(Boolean).sort()[0] || null;
|
|
517
523
|
// 套餐有效期卡片上显示在「有效期至」列之外,这里记录最近的套餐结束时间
|
|
518
524
|
const planEndsAt = plans.map((p) => epochMs(p.ends_at)).filter(Boolean).sort()[0] || null;
|
|
519
525
|
const sum = (k) => parts.reduce((s, p) => s + (p[k] || 0), 0);
|
|
@@ -523,6 +529,8 @@ export function normalizeBalance(balanceResp) {
|
|
|
523
529
|
plan: plans[0]?.name || plans[0]?.plan_id || null,
|
|
524
530
|
planEndsAt,
|
|
525
531
|
parts,
|
|
532
|
+
pendingTotal: pendingParts.reduce((s, p) => s + (p.total || 0), 0),
|
|
533
|
+
pendingAt,
|
|
526
534
|
empty: parts.length === 0 && plans.length === 0,
|
|
527
535
|
exceeded: false,
|
|
528
536
|
};
|