creditdaddy 1.6.0 → 1.6.3

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/src/panel.html CHANGED
@@ -224,6 +224,8 @@
224
224
  .credit { display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums; }
225
225
  .credit b { font-weight: 700; }
226
226
  .credit svg { width: 13px; height: 13px; color: rgb(var(--accent-rgb)); }
227
+ .credit.pending b { color: var(--text-3); }
228
+ .seg.pending-ghost { background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--text-3) 30%, transparent) 0 6px, transparent 6px 10px); }
227
229
  .dim { color: var(--text-3); }
228
230
  .claim-plan { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 2px; border-bottom: 1px solid var(--border); }
229
231
  .claim-plan:last-of-type { border-bottom: 0; }
@@ -801,9 +803,6 @@
801
803
  <div class="field" style="margin-top:12px"><label data-i18n="10Router 地址">10Router 地址</label><input class="input mono" id="tr-endpoint" placeholder="http://127.0.0.1:20128(站点根地址,不要带 /v1)"></div>
802
804
  <div class="field"><label data-i18n="虚拟 key">虚拟 key</label><input class="input mono" id="tr-key" placeholder="sk-…" autocomplete="off"></div>
803
805
  <div class="field"><label data-i18n="10Router 面板密码(可选)">10Router 面板密码(可选)</label><input class="input mono" id="tr-admin-pw" type="password" placeholder="配置后「同步账号」走 OAuth 通道(带 refreshToken 自动续期);留空保持不变" autocomplete="new-password"></div>
804
- <div class="field"><label data-i18n="账号同步">账号同步</label>
805
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="btn" id="tr-sync-accounts" data-i18n="同步账号到 10R">同步账号到 10R</button><span class="hint" data-i18n="把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接;zcode 待反代就绪后支持">把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接;zcode 待反代就绪后支持</span></div>
806
- </div>
807
806
  <div class="field"><label data-i18n="显示">显示</label>
808
807
  <div><label class="check"><input type="checkbox" id="tr-hide-empty"> 「全部」视图隐藏没有额度 / 余额为 0 的供应商汇总卡</label></div>
809
808
  </div>
@@ -864,15 +863,26 @@
864
863
  </div>
865
864
  </div>
866
865
 
867
- <div class="mask" id="m-cookie">
866
+ <!-- 10Router 账号管理:账号同步 + 网页会话 Cookie 捕获(原「Cookie 会话」按钮与「设置」里的账号同步合并) -->
867
+ <div class="mask" id="m-am">
868
868
  <div class="modal">
869
- <div class="modal-title"><span data-i18n="Cookie 会话捕获">Cookie 会话捕获</span><button class="close" data-close>✕</button></div>
870
- <div class="hint" data-i18n="为「额度只在网页 dashboard」的供应商捕获登录会话(cookie),同步给 10Router 点亮配额卡片;凭据全程只存本机(账号库加密)。">为「额度只在网页 dashboard」的供应商捕获登录会话(cookie),同步给 10Router 点亮配额卡片;凭据全程只存本机(账号库加密)。</div>
871
- <div id="cookie-sites-body" style="margin-top:10px;display:grid;gap:8px"></div>
869
+ <div class="modal-title"><span data-i18n="账号管理">账号管理</span><button class="close" data-close>✕</button></div>
870
+ <div class="field"><label data-i18n="账号同步">账号同步</label>
871
+ <div class="hint" style="margin-bottom:8px" data-i18n="把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接。">把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接。</div>
872
+ <input class="input mono" id="am-pw" type="password" autocomplete="new-password">
873
+ <div style="display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap">
874
+ <button class="btn" id="am-sync"><span class="dot"></span><span class="lbl">同步账号到 10R</span></button>
875
+ <span class="hint" id="am-sync-msg"></span>
876
+ </div>
877
+ <div style="margin-top:10px" id="am-sync-channels"></div>
878
+ </div>
879
+ <div class="field" style="margin-top:14px"><label data-i18n="网页会话">网页会话</label>
880
+ <div class="hint" data-i18n="为「额度只在网页 dashboard」的供应商捕获登录会话(cookie),同步给 10Router 点亮配额卡片;凭据全程只存本机(账号库加密)。">为「额度只在网页 dashboard」的供应商捕获登录会话(cookie),同步给 10Router 点亮配额卡片;凭据全程只存本机(账号库加密)。</div>
881
+ <div id="cookie-sites-body" style="margin-top:10px;display:grid;gap:8px"></div>
882
+ </div>
872
883
  <div class="modal-actions"><button class="btn" data-close data-i18n="关闭">关闭</button></div>
873
884
  </div>
874
885
  </div>
875
-
876
886
  <!-- 通用对话框 -->
877
887
  <div class="mask" id="m-dialog">
878
888
  <div class="modal sm">
@@ -1025,7 +1035,7 @@ const $ = (id) => document.getElementById(id);
1025
1035
  const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '', logTab: 'run', logLevel: 'all', locale: 'zh-CN' };
1026
1036
  const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax', 'free', 'tenrouter'];
1027
1037
  // 10Router 集成:config 来自 /api/tenrouter,quotas 来自 /api/tenrouter/quotas
1028
- const tr = { config: null, quotas: null, error: null, loading: false, syncing: false };
1038
+ const tr = { config: null, quotas: null, error: null, loading: false, syncing: false, accountsSyncing: false };
1029
1039
  const PRODUCTS = [
1030
1040
  { id: 'qoder', label: 'Qoder' },
1031
1041
  { id: 'workbuddy', label: 'WorkBuddy' },
@@ -1484,7 +1494,19 @@ function initTheme() {
1484
1494
  // ── 状态 / 渲染 ──
1485
1495
  // 统一「领鸡蛋」语义:全部签到产品线 = 领取积分(workbuddy-intl 的「活跃」语义随活跃领取线删除)
1486
1496
  function statusOf(a) {
1487
- if (!CHECKIN_PRODUCTS.has(a.product)) return { cls: '', text: '', msg: '' }; // ZCode 无每日领取,不显示徽标
1497
+ if (!CHECKIN_PRODUCTS.has(a.product)) {
1498
+ // ZCode 无「每日签到」口径,但活动领取的结果必须落到徽标上:
1499
+ // 否则卡片上唯一能证明「领到了」的东西只有额度数字,而活动额度常带
1500
+ // effective_at(当天稍晚到账),在那之前卡片看起来毫无变化。
1501
+ if (a.product === 'zcode') {
1502
+ const r = a.lastResult;
1503
+ const fresh = r && r.at && Date.now() - new Date(r.at).getTime() < 26 * 3600e3;
1504
+ if (r && fresh && r.status === 'checked-in') return { cls: 'ok', text: T('已领取'), msg: r.message };
1505
+ if (r && fresh && r.status === 'already') return { cls: 'ok', text: T('已领过'), msg: r.message };
1506
+ if (r && fresh && r.status === 'failed') return { cls: 'fail', text: T('领取失败'), msg: r.message, err: true };
1507
+ }
1508
+ return { cls: '', text: '', msg: '' };
1509
+ }
1488
1510
  if (a.provider === 'workbuddy-intl') return { cls: '', text: '', msg: '' }; // 国际版无签到动作(活跃领取线已删),不显示徽标
1489
1511
  const byProd = (state.status && state.status.todayByProduct) || {};
1490
1512
  const today = byProd[a.product] || (state.status && state.status.today);
@@ -1609,15 +1631,32 @@ function creditHtml(a) {
1609
1631
 
1610
1632
  const unit = q.unit ? ' ' + T(q.unit) : '';
1611
1633
  if (q.unit) {
1634
+ // 待生效权益单独计数:活动发的额度要到生效点才进余额,把它算成「已用完」会让人以为丢了
1635
+ const pendingParts = q.parts.filter((x) => x.pending);
1636
+ const effParts = q.parts.filter((x) => !x.pending);
1612
1637
  const items = q.parts.map((x) => {
1613
1638
  const base = esc(T(x.name)) + ' ' + fmtNum(x.remaining) + '/' + fmtNum(x.total) + (x.unit ? ' ' + esc(T(x.unit)) : '');
1614
1639
  if (x.pending && x.startsAt) return base + T('(') + fmtTime(x.startsAt).slice(5, 16) + T(' 生效)');
1615
1640
  return base + (x.expiresAt ? T('(') + fmtTime(x.expiresAt).slice(5, 16) + (x.recurring ? T(' 重置)') : T(' 到期)')) : '');
1616
1641
  }).join(' · ');
1642
+ const atText = q.pendingAt ? fmtTime(q.pendingAt) : '';
1643
+ const isToday = atText.slice(0, 10) === fmtTime(new Date().toISOString()).slice(0, 10);
1644
+ const when = atText ? (isToday ? atText.slice(11) : atText.slice(0, 10) + ' ' + atText.slice(11)) : '';
1645
+ const pendingTotal = q.pendingTotal || pendingParts.reduce((s, x) => s + (x.total || 0), 0);
1646
+ const pendingLine = '<div class="credit-line"><span class="credit pending">' + T('<span class="lbl">待生效</span>')
1647
+ + '<b>' + fmtNum(pendingTotal) + '</b>' + esc(unit)
1648
+ + '</span><span class="credit dim" title="' + items + '">'
1649
+ + (q.plan ? esc(q.plan) + ' · ' : '') + (when ? when + ' ' + T('到账') : T('即将到账')) + '</span></div>';
1617
1650
  const pct = q.total > 0 ? Math.min(1, q.remaining / q.total) : 0;
1651
+ const mainLine = () => T('<div class="credit-line"><span class="credit"><span class="lbl">剩余</span><b>') + fmtNum(q.remaining) + '</b>' + esc(unit) + '</span>'
1652
+ + '<span class="credit dim" title="' + items + '">' + (q.plan ? esc(q.plan) + ' · ' : '') + items + '</span></div>';
1653
+ // 全部待生效(活动刚发、还没到点):主行显示待生效,别只显示一个 0
1654
+ if (pendingParts.length && !effParts.length) {
1655
+ return pendingLine + '<div class="bar"><span class="seg pending-ghost" style="flex:1 1 0"></span></div>';
1656
+ }
1618
1657
  // 满额(100%)不再渲染灰色「已使用」尾段——它只占 0 宽也会造成几像素的灰边
1619
- return T('<div class="credit-line"><span class="credit"><span class="lbl">剩余</span><b>') + fmtNum(q.remaining) + '</b>' + esc(unit) + '</span>'
1620
- + '<span class="credit dim" title="' + items + '">' + (q.plan ? esc(q.plan) + ' · ' : '') + items + '</span></div>'
1658
+ return mainLine()
1659
+ + (pendingParts.length ? pendingLine : '')
1621
1660
  + '<div class="bar"><span class="seg" style="flex:' + pct.toFixed(4) + ' 1 0"></span>'
1622
1661
  + (pct < 0.9999 ? '<span class="seg used" style="flex:' + (1 - pct).toFixed(4) + ' 1 0"></span>' : '')
1623
1662
  + '</div>';
@@ -1649,7 +1688,9 @@ function cardHtml(a) {
1649
1688
  + T('<span class="name" data-act="rename" title="点击修改备注名">') + esc(a.name || T('(未命名)')) + '</span>'
1650
1689
  + provBadges(a.provider, true, a.flavor)
1651
1690
  + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] ? T(CLIENT_LABEL[a.product]) : '') + T(' 客户端当前登录的账号">客户端当前</span>') : '')
1652
- + (a.product === 'zcode' && isGatewayCurrent(a) ? T('<span class="pill gw" title="体验包接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>') : '')
1691
+ + (a.product === 'zcode' && isGatewayCurrent(a) ? (zgwAccountCount() > 1
1692
+ ? T('<span class="pill gw" title="体验包接口最近一次补全用的账号({n} 个 ZCode 账号按轮转分摊请求,每个请求换一个,与客户端登录无关)">接口使用中</span>', { n: zgwAccountCount() })
1693
+ : T('<span class="pill gw" title="体验包接口最近一次补全用的账号(多账号时按轮转自动选择,与客户端登录无关)">接口使用中</span>')) : '')
1653
1694
  + (a.product === 'minimax' && isMiniMaxGatewayCurrent(a) ? T('<span class="pill gw" title="本地接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>') : '')
1654
1695
  + (a.product === 'trae' && isTraeGatewayCurrent(a) ? T('<span class="pill gw" title="本地接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>') : '')
1655
1696
  + (a.isPat ? '<span class="badge">PAT</span>' : '')
@@ -1690,6 +1731,10 @@ function isGatewayCurrent(a) {
1690
1731
  const last = state.zGatewayStats && state.zGatewayStats.lastAccount;
1691
1732
  return Boolean(last) && (a.name === last || a.uid === last || a.id === last);
1692
1733
  }
1734
+ /** 网关账号总数(多账号时徽标注明「轮转中」,避免用户以为只有一个号在服役) */
1735
+ function zgwAccountCount() {
1736
+ return state.accounts.filter((a) => a.product === 'zcode').length;
1737
+ }
1693
1738
  /** MiniMax 本地接口使用中判定 */
1694
1739
  function isMiniMaxGatewayCurrent(a) {
1695
1740
  const last = state.mGatewayStats && state.mGatewayStats.lastAccount;
@@ -1707,8 +1752,12 @@ function isClientCurrent(a) {
1707
1752
  if (!s) return false;
1708
1753
  if (a.product === 'workbuddy') return Boolean(a.uid && s.workbuddyCurrentUid && s.workbuddyCurrentUid === a.uid);
1709
1754
  if (a.product === 'qoder') {
1755
+ // 服务端按「本机 auth.v1.dat 解出的 token」对齐出账号 id(token 优先、uid 兜底)——
1756
+ // 只看 uid 的话客户端静默刷新 token 后会把别的同 uid 记录误当成当前账号
1710
1757
  const cur = s.qoderCurrent || {};
1711
- return Boolean(a.uid && cur[a.provider] && String(cur[a.provider]) === String(a.uid));
1758
+ if (cur[a.provider]) return String(cur[a.provider]) === String(a.id);
1759
+ const uid = s.qoderCurrentUid || {};
1760
+ return Boolean(a.uid && uid[a.provider] && String(uid[a.provider]) === String(a.uid));
1712
1761
  }
1713
1762
  if (a.product === 'mirasim') return Boolean(a.uid && s.mirasimCurrentUid && s.mirasimCurrentUid === a.uid);
1714
1763
  if (a.product === 'catpaw') return Boolean(a.uid && s.catpawCurrentUid && s.catpawCurrentUid === a.uid);
@@ -2008,7 +2057,7 @@ function renderStatus() {
2008
2057
  // 产品相关提示只在对应标签页出现
2009
2058
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
2010
2059
  const n = $('notice');
2011
- n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || state.view === 'catpaw' || state.view === 'trae' || state.view === 'minimax' || (hasIntl && Boolean(s.riskIdentity)));
2060
+ n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || state.view === 'catpaw' || state.view === 'trae' || state.view === 'minimax' || (state.view === 'workbuddy' && s.workbuddyClient && s.workbuddyClient.installed === false) || (hasIntl && Boolean(s.riskIdentity)));
2012
2061
  if (state.view === 'minimax') {
2013
2062
  n.style.display = '';
2014
2063
  n.innerHTML = T('<div class="z-row"><span class="z-label">每日签到</span><span class="z-text">每日签到领 800~2000 积分 + 400 连签加成。支持自动检测本机 MiniMax Code 凭据与 Bearer Token 导入,自带标准 Anthropic 本地网关。</span></div>');
@@ -2033,6 +2082,10 @@ function renderStatus() {
2033
2082
  n.innerHTML = T('<div class="z-row"><span class="z-label">活动领取</span><span class="z-text">活动包积分,每日 0 点左右刷新(无每日签到)。自动领取')
2034
2083
  + (ac ? T('每 ') + ziv + T(' 分钟检查资格') + (zwin > 0 ? T(',本次运行 ') + zwin + T(' 分钟') : T(',持续运行')) : T('未开启(右上角设置轮询计划)'))
2035
2084
  + T(';礼物图标 = 手动临时领。</span></div>');
2085
+ } else if (state.view === 'workbuddy' && s.workbuddyClient && s.workbuddyClient.installed === false) {
2086
+ // WorkBuddy 客户端未安装:切换线是死路(写凭据没人应用),进页就明说而不是切了才失败
2087
+ n.style.display = '';
2088
+ n.innerHTML = T('<div class="z-row"><span class="z-label">客户端</span><span class="z-text">本机未安装 WorkBuddy / CodeBuddy 客户端:Token 导入、额度读取与每日领取不受影响;但「切换账号」需要本机客户端(切换是写入客户端凭据由其应用)。请先安装并登录客户端,再「本机导入」。</span></div>');
2036
2089
  } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
2037
2090
  n.style.display = '';
2038
2091
  const busy = s.umid.installing || umidBusy;
@@ -2746,13 +2799,115 @@ function renderCookieSites() {
2746
2799
  + hint + accRows + '</div>';
2747
2800
  }).join('');
2748
2801
  }
2749
- function openCookieSitesModal() {
2750
- const body = $('cookie-sites-body');
2751
- if (body) body.innerHTML = T('<div class="dim">加载中…</div>');
2752
- openModal('m-cookie');
2753
- loadCookieSites(true).then(() => renderCookieSites());
2802
+ /**
2803
+ * 「账号管理」弹窗:把两件同性质的事收在一处——
2804
+ * 1. 账号同步:本机 Qoder / WorkBuddy 账号推为 10Router 上游连接(原藏在「设置」弹窗底部)
2805
+ * 2. Cookie 会话:网页会话型供应商的捕获入口(原名「Cookie 会话」按钮)
2806
+ * 面板密码栏同步展示:账号同步走 OAuth 通道时要它(带 refreshToken,自动续期)。
2807
+ *
2808
+ * 渠道开关(每渠道一行勾选):取消勾选 = 两边都不再推——
2809
+ * 账号连接(sync-accounts)跳过该渠道的账号;额度卡推送跳过该渠道账号的免费卡。
2810
+ * 场景:10Router 已单独配了同源渠道(如 minimax-code 连接),CD 侧维护同名免费卡
2811
+ * (minimax-free-*)纯属重复——两边卡片并存且数字各刷各的。
2812
+ */
2813
+ const AM_CHANNELS = [
2814
+ { provider: 'minimax', label: 'MiniMax', syncable: false },
2815
+ { provider: 'tokenharbor', label: 'Token Harbor', syncable: true },
2816
+ { provider: 'xiaomi-mimo', label: '小米 MiMo', syncable: false },
2817
+ { provider: 'river', label: 'River AI', syncable: false },
2818
+ { provider: 'modelscope', label: 'ModelScope 魔搭', syncable: false },
2819
+ { provider: 'longcat', label: 'LongCat', syncable: false },
2820
+ { provider: 'qoder', label: 'Qoder 国际版', syncable: true },
2821
+ { provider: 'qoder-cn', label: 'Qoder 国内版', syncable: true },
2822
+ { provider: 'workbuddy', label: 'WorkBuddy 国内版', syncable: true },
2823
+ { provider: 'workbuddy-intl', label: 'WorkBuddy 国际版', syncable: true },
2824
+ ];
2825
+
2826
+ function openTrAccountManage() {
2827
+ if (!(tr.config && tr.config.configured)) return toast(T('请先保存 10Router 设置'), false);
2828
+ $('am-sync-msg').textContent = '';
2829
+ $('am-pw').value = '';
2830
+ $('am-pw').placeholder = T('填 10Router 面板密码,走 OAuth 通道(带 refreshToken 自动续期);不填则按已保存的走');
2831
+ const btn = $('am-sync');
2832
+ btn.disabled = false;
2833
+ btn.querySelector('.dot').className = 'dot' + (tr.config.configured ? ' on' : '');
2834
+ btn.querySelector('.lbl').textContent = T('同步账号到 10R');
2835
+ renderAmChannels();
2836
+ loadCookieSites(true).then(() => renderCookieSites()).catch(() => {});
2837
+ openModal('m-am');
2838
+ }
2839
+
2840
+ function renderAmChannels() {
2841
+ const c = tr.config || {};
2842
+ const enabled = c.accountSync?.enabled !== false;
2843
+ const excluded = (c.quotaPushDisabledProviders || []);
2844
+ const have = new Set((state.accounts || []).map((a) => a.provider));
2845
+ const box = $('am-sync-channels');
2846
+ if (!box) return;
2847
+ const rows = AM_CHANNELS.filter((ch) => have.has(ch.provider)).map((ch) => {
2848
+ // 勾选语义 = 推送该渠道:账号同步看 accountSync(总开关关了整组禁用),
2849
+ // 额度卡看 quotaPushDisabledProviders 反集
2850
+ const syncOn = enabled && ((c.accountSync?.providers || []).length
2851
+ ? (c.accountSync.providers.includes(ch.provider)) : ch.syncable);
2852
+ const pushOn = !excluded.includes(ch.provider);
2853
+ return '<label class="check" style="display:flex;gap:6px;align-items:center">'
2854
+ + '<input type="checkbox" data-am-push="' + ch.provider + '"' + (pushOn ? ' checked' : '') + '> ' + esc(T(ch.label))
2855
+ + '<label class="check" style="margin-left:auto;gap:4px"><input type="checkbox" data-am-sync="' + ch.provider + '"' + (syncOn ? ' checked' : '') + (!ch.syncable || !enabled ? ' disabled' : '') + '> <span class="dim">连接</span></label>'
2856
+ + '</label>';
2857
+ });
2858
+ box.innerHTML = rows.length
2859
+ ? '<div class="hint" style="margin-bottom:4px">' + T('勾选 = 同步该渠道(「连接」= 账号连接;主勾 = 额度卡推送)。10Router 已单独配了同源渠道时,取消对应渠道即可去掉重复卡片。') + '</div>'
2860
+ + '<div style="display:grid;gap:4px">' + rows.join('') + '</div>'
2861
+ + '<div style="margin-top:6px"><button class="btn" id="am-channels-save">' + T('保存渠道设置') + '</button></div>'
2862
+ : '';
2863
+ $('am-channels-save')?.addEventListener('click', saveAmChannels);
2864
+ }
2865
+
2866
+ async function saveAmChannels() {
2867
+ const push = [...document.querySelectorAll('#am-sync-channels input[data-am-push]:checked')].map((x) => x.dataset.amPush);
2868
+ const sync = [...document.querySelectorAll('#am-sync-channels input[data-am-sync]:checked')].map((x) => x.dataset.amSync);
2869
+ try {
2870
+ tr.config = await api('/api/tenrouter', {
2871
+ method: 'PUT',
2872
+ json: {
2873
+ accountSyncEnabled: true,
2874
+ accountSyncProviders: sync,
2875
+ // 主勾没勾的 = 排除推送
2876
+ quotaPushDisabledProviders: AM_CHANNELS.map((c) => c.provider).filter((p) => !push.includes(p)),
2877
+ },
2878
+ });
2879
+ toast(T('渠道同步设置已保存'), true);
2880
+ renderAmChannels();
2881
+ } catch (e) { toast(e.message, false); }
2754
2882
  }
2755
- document.getElementById('m-cookie')?.addEventListener('click', (e) => {
2883
+ async function trSyncAccountsFromModal() {
2884
+ if (!(tr.config && tr.config.configured)) return toast(T('请先保存 10Router 设置'), false);
2885
+ const btn = $('am-sync');
2886
+ const msg = $('am-sync-msg');
2887
+ const pw = $('am-pw').value;
2888
+ btn.disabled = true;
2889
+ btn.querySelector('.dot').className = 'dot on pulse';
2890
+ btn.querySelector('.lbl').textContent = T('同步账号中…');
2891
+ tr.accountsSyncing = true;
2892
+ renderTenrouter();
2893
+ try {
2894
+ const r = await api('/api/tenrouter/sync-accounts', { method: 'POST', json: pw ? { adminPassword: pw } : {} });
2895
+ msg.textContent = r.summary || '';
2896
+ toast(r.summary, !/失败/.test(r.summary || ''));
2897
+ if (pw) { await loadTrConfig(); $('am-pw').value = ''; }
2898
+ loadLogs();
2899
+ } catch (e) {
2900
+ msg.textContent = e.message;
2901
+ toast(e.message, false);
2902
+ } finally {
2903
+ tr.accountsSyncing = false;
2904
+ btn.disabled = false;
2905
+ btn.querySelector('.dot').className = 'dot on';
2906
+ btn.querySelector('.lbl').textContent = T('同步账号到 10R');
2907
+ renderTenrouter();
2908
+ }
2909
+ }
2910
+ document.getElementById('m-am')?.addEventListener('click', (e) => {
2756
2911
  const b = e.target.closest && e.target.closest('[data-capture-site]');
2757
2912
  if (b) cookieSiteCapture(b.dataset.captureSite);
2758
2913
  });
@@ -2788,11 +2943,13 @@ $('btn-free-refresh')?.addEventListener('click', () => {
2788
2943
  $('btn-free-settings')?.addEventListener('click', openFreeSettings);
2789
2944
  // 「同步到 10Router」= 推连接(会话凭据)+ 推额度卡(issue #24 数字快照),一键两路
2790
2945
  $('btn-free-sync-10r')?.addEventListener('click', async () => {
2791
- await trSyncAccounts();
2792
2946
  const btn = $('btn-free-sync-10r');
2793
- if (!(tr.config && tr.config.configured)) return;
2947
+ if (!(tr.config && tr.config.configured)) return toast(T('请先保存 10Router 设置'), false);
2794
2948
  btn.disabled = true;
2795
2949
  try {
2950
+ // 先推账号连接(原 trSyncAccounts 的职责),失败不阻断额度卡推送
2951
+ const sa = await api('/api/tenrouter/sync-accounts', { method: 'POST', json: {} }).catch((e) => ({ summary: e.message, error: true }));
2952
+ if (sa && sa.summary) toast(sa.summary, !sa.error && !/失败/.test(sa.summary));
2796
2953
  const r = await api('/api/tenrouter/push-quota', { method: 'POST' });
2797
2954
  const failed = (r.results || []).filter((x) => x.status === 'failed');
2798
2955
  if (failed.length) toast(T('额度卡部分推送失败:') + failed.map((x) => x.source + '(' + x.error + ')').join(';'), false);
@@ -2973,8 +3130,8 @@ function renderTenrouter() {
2973
3130
  + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + (tr.loading ? T('刷新中…') : T('刷新额度')) + '</button>'
2974
3131
  + T('<button class="btn" data-tr="sync" title="用量同步:自动开关、同步来源与手动同步(点我打开)"') + (tr.syncing || c.syncing ? ' disabled' : '') + '>'
2975
3132
  + '<span class="dot' + (c.sync.enabled ? ' on' : '') + '"></span>' + (tr.syncing || c.syncing ? T('同步中…') : T('用量同步')) + '</button>'
3133
+ + '<button class="btn" data-tr="account-manage" title="' + T('账号同步与网页会话 Cookie 捕获') + '">' + T('账号管理') + '</button>'
2976
3134
  + T('<button class="btn" data-tr="settings">设置</button>')
2977
- + T('<button class="btn" data-tr="cookie-sites" title="Token Harbor 等网页会话型供应商">Cookie 会话</button>')
2978
3135
  + '</div></div>'
2979
3136
  + tabStrip
2980
3137
  + '<div class="tr-grid">' + grid + '</div>';
@@ -3095,19 +3252,6 @@ async function trDisconnect() {
3095
3252
  try { tr.config = await api('/api/tenrouter', { method: 'DELETE' }); tr.quotas = null; tr.error = null; closeModal('m-10r'); render(); }
3096
3253
  catch (e) { toast(e.message, false); }
3097
3254
  }
3098
- async function trSyncAccounts() {
3099
- if (!(tr.config && tr.config.configured)) return toast(T('请先保存 10Router 设置'), false);
3100
- const btn = $('tr-sync-accounts');
3101
- const pw = $('tr-admin-pw').value;
3102
- btn.disabled = true;
3103
- try {
3104
- const r = await api('/api/tenrouter/sync-accounts', { method: 'POST', json: pw ? { adminPassword: pw } : {} });
3105
- toast(r.summary, !/失败/.test(r.summary));
3106
- if (pw) await loadTrConfig();
3107
- loadLogs();
3108
- } catch (e) { toast(e.message, false); }
3109
- btn.disabled = false;
3110
- }
3111
3255
  $('tr-panel').addEventListener('click', (e) => {
3112
3256
  const b = e.target.closest('[data-tr]');
3113
3257
  if (!b) return;
@@ -3115,7 +3259,7 @@ $('tr-panel').addEventListener('click', (e) => {
3115
3259
  if (b.dataset.tr === 'settings') openTrSettings();
3116
3260
  else if (b.dataset.tr === 'refresh') loadTrQuotas(true);
3117
3261
  else if (b.dataset.tr === 'sync') openTrSync();
3118
- else if (b.dataset.tr === 'cookie-sites') goView('free');
3262
+ else if (b.dataset.tr === 'account-manage') openTrAccountManage();
3119
3263
  else if (b.dataset.tr === 'vendor') {
3120
3264
  const v = b.dataset.v || '';
3121
3265
  state.trVendor = v;
@@ -3131,7 +3275,7 @@ $('tr-sync-now').onclick = async () => {
3131
3275
  trSync();
3132
3276
  api('/api/tenrouter/push-quota', { method: 'POST' }).catch(() => {});
3133
3277
  };
3134
- $('tr-sync-accounts').onclick = trSyncAccounts;
3278
+ $('am-sync').onclick = trSyncAccountsFromModal;
3135
3279
  $('tr-save').onclick = trSave;
3136
3280
  $('tr-disconnect').onclick = trDisconnect;
3137
3281
 
@@ -3150,7 +3294,12 @@ async function syncAfterZcodeClaims() {
3150
3294
  seenZcodeTickAt = t.at;
3151
3295
  if (first || !(t.claimedIds && t.claimedIds.length)) return; // 首次加载已全量拉取
3152
3296
  try { state.accounts = (await api('/api/accounts')).accounts; } catch (e) {}
3153
- loadQuotas(t.claimedIds);
3297
+ await loadQuotas(t.claimedIds);
3298
+ // 额度拿到后必须重绘:ZCode 活动发的额度常带 effective_at(如 23:00 到账),
3299
+ // 卡片从「无有效套餐」变成「待生效 N Token · HH:MM 到账」,不重绘就一直停在旧样子,
3300
+ // 用户以为「领取成功了却不显示」——只能自己手动点刷新。
3301
+ render();
3302
+ renderStatus();
3154
3303
  loadZGateway();
3155
3304
  }
3156
3305
  async function loadAccounts() {
@@ -3310,10 +3459,15 @@ async function onCardClick(e) {
3310
3459
  if ([...sel.options].some((o) => o.value === a.provider && !o.hidden)) sel.value = a.provider;
3311
3460
  return deviceLogin();
3312
3461
  }
3313
- if (act === 'quota') { loadQuotas([id], { live: true }); return; }
3462
+ if (act === 'quota') { loadQuotas([id], { live: true }).then(render); return; }
3314
3463
  if (act === 'claim') return openClaim(id);
3315
3464
  if (act === 'switch') {
3316
3465
  const a = state.accounts.find((x) => x.id === id);
3466
+ // 本机没装客户端时先拦下(WorkBuddy 此前静默硬写凭据目录,面板显示已切换但客户端永远不应用)
3467
+ if (a && a.product === 'workbuddy' && state.status && state.status.workbuddyClient && state.status.workbuddyClient.installed === false) {
3468
+ toast(T('本机未安装 WorkBuddy / CodeBuddy 客户端,无法切换:切换是把登录写入客户端凭据,由客户端应用。请先安装客户端并登录,再「本机导入」。'), false);
3469
+ return;
3470
+ }
3317
3471
  const client = (CLIENT_LABEL[a && a.product] ? T(CLIENT_LABEL[a && a.product]) : '') || (a && prodLabel(a.product)) || T('客户端');
3318
3472
  const tip = a && a.product === 'zcode'
3319
3473
  ? T('\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
@@ -3365,6 +3519,16 @@ async function onCardClick(e) {
3365
3519
  } catch (e) {}
3366
3520
  }, 800);
3367
3521
  }
3522
+ if (a && a.product === 'qoder' && r && !r.alreadyActive && window.creditdaddy && typeof window.creditdaddy.openQoderClient === 'function') {
3523
+ // 强制切换会先关掉 Qoder:写完登录自动拉起来,不然用户得自己找快捷方式
3524
+ setTimeout(async () => {
3525
+ try {
3526
+ const re = await window.creditdaddy.openQoderClient(a.provider);
3527
+ if (re && re.ok) toast(T('已重新启动 Qoder 客户端'));
3528
+ else toast((re && re.error) || T('无法打开 Qoder 客户端(桌面版才能自动拉起)'), false);
3529
+ } catch (e) {}
3530
+ }, 800);
3531
+ }
3368
3532
  if (a && a.product === 'trae' && r && !r.alreadyActive && window.creditdaddy && typeof window.creditdaddy.openTraeClient === 'function') {
3369
3533
  setTimeout(async () => {
3370
3534
  try {
@@ -3560,7 +3724,8 @@ async function doClaim(i) {
3560
3724
  });
3561
3725
  toast(T('领取成功:') + (r.planName || pl.name || pl.planId));
3562
3726
  closeModal('m-claim');
3563
- loadQuotas([claimState.accountId]);
3727
+ await loadStatus(); // 徽标(已领取)在账号的 lastResult 上,必须先重拉账号
3728
+ await loadAccounts(); // loadQuotas 之后必须重绘,否则卡片停在旧样子
3564
3729
  loadLogs();
3565
3730
  } catch (e) {
3566
3731
  const errBox = $('claim-status');
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)) throw new Error(`Qoder 凭据文件不存在:${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
- const accounts = await loadAccounts();
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
@@ -53,8 +53,24 @@ function seeded(uid, salt, n) {
53
53
  }
54
54
 
55
55
  export function traeDevice(uid, storedDeviceId, attempt = 0) {
56
- // attempt 参数:9074 重试时轮换派生设备号(issue #31 报告者实测——派生号被服务端一律拒 9074、
57
- // 同一时刻真机号可领,说明服务端大概率只认登录时注册的设备号,换号重试才有机会过)。
56
+ // 首选**登录时注册的真机号**(meta.deviceId,本机导入时从 storage.json 抓的):
57
+ // 服务端大概率把「号-设备」绑在登录时注册的那台上——陌生设备号打接口吃 9074
58
+ //(issue #31 报告者实测派生号一律被拒、同一时刻真机号可领;本机实测同现象:
59
+ // 导入首日派生号签到成功,之后每天 9074,重试换派生号 3 轮也全拒)。
60
+ // attempt>0 才走派生:多账号同机时真机号只有一个,第二个账号起服务端按设备限
61
+ // 每日一领会拒(issue #28,TraeTools 的「设备码切换」本质就是给每号换独立设备码),
62
+ // 此时轮换派生号,至少有一个号能落在服务端风控放行的窗口里。
63
+ if (attempt === 0 && storedDeviceId && /^\d{8,}$/.test(String(storedDeviceId))) {
64
+ const m = seeded(`${uid}`, 'market:0', 16);
65
+ m[6] = (m[6] & 0x0f) | 0x40;
66
+ m[8] = (m[8] & 0x3f) | 0x80;
67
+ const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
68
+ return {
69
+ deviceId: String(storedDeviceId),
70
+ marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
71
+ sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),
72
+ };
73
+ }
58
74
  // attempt=0 为默认指纹(跨天稳定恒定);attempt>0 派生结果互异但同样确定(同 attempt 重放一致)。
59
75
  const d = seeded(`${uid}`, `devid:${attempt}`, 15).map((b) => String(48 + (b % 10)));
60
76
  const m = seeded(`${uid}`, `market:${attempt}`, 16);
@@ -62,9 +78,6 @@ export function traeDevice(uid, storedDeviceId, attempt = 0) {
62
78
  m[8] = (m[8] & 0x3f) | 0x80;
63
79
  const h = m.map((b) => b.toString(16).padStart(2, '0')).join('');
64
80
  return {
65
- // x-device-id 按 uid 派生(每号独立且稳定):本机导入的 N 个账号若共用客户端自报的
66
- // 同一设备号,服务端按设备限每日一领——第二个账号起签到必吃 9074(issue #28,
67
- // TraeTools 的「设备码切换」本质就是给每个账号换独立设备码)。这里不落 storedDeviceId。
68
81
  deviceId: d.join(''),
69
82
  marketUserId: `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`,
70
83
  sessionId: seeded(uid, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join(''),