creditdaddy 0.9.3 → 0.9.5

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
@@ -43,6 +43,29 @@
43
43
  .title { font-family: "Avenir Next", Avenir, "Segoe UI Variable Display", "Segoe UI", sans-serif; font-size: 17px; font-weight: 750; letter-spacing: -.5px; line-height: 1.15; }
44
44
  .ver { color: var(--text-3); font-size: 11px; line-height: 1.3; font-variant-numeric: tabular-nums; }
45
45
  .head-right { display: flex; align-items: center; gap: 10px; color: var(--text-3); font-size: 12px; }
46
+ /* 组件开关:右上角弹出菜单 */
47
+ .comp-wrap { position: relative; display: inline-flex; }
48
+ .comp-menu { position: absolute; right: 0; top: calc(100% + 8px); z-index: 80; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--toast-shadow); padding: 10px 12px; min-width: 132px; display: flex; flex-direction: column; gap: 8px; }
49
+ .comp-menu[hidden] { display: none; }
50
+ .comp-menu-title { font-size: 11px; color: var(--text-3); }
51
+ .comp-menu .check { margin-right: 0; font-size: 12.5px; color: var(--text); }
52
+ #comp-menu { max-height: 72vh; overflow: auto; min-width: 236px; }
53
+ .comp-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
54
+ .comp-row.off .check { opacity: .45; }
55
+ .comp-ops { display: inline-flex; gap: 2px; flex: none; }
56
+ .icon-btn.mini { width: 20px; height: 20px; border-radius: 6px; font-size: 11px; line-height: 1; padding: 0; }
57
+ .icon-btn.mini svg { width: 12px; height: 12px; }
58
+ .sort-sec { border-top: 1px dashed var(--border-subtle); padding-top: 6px; font-size: 12px; }
59
+ .sort-sec summary { cursor: pointer; color: var(--text-2); user-select: none; display: flex; align-items: center; gap: 6px; }
60
+ .sort-sec summary::-webkit-details-marker { display: none; }
61
+ .sort-hint { font-size: 11px; color: var(--text-3); margin: 5px 0 3px; }
62
+ .sort-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 2px 0; }
63
+ .sort-row .sort-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
64
+ .card.dragging, .tr-card.dragging { opacity: .45; }
65
+ .card.drop-before { box-shadow: inset 0 3px 0 0 rgb(var(--accent-rgb)); }
66
+ .card.drop-after { box-shadow: inset 0 -3px 0 0 rgb(var(--accent-rgb)); }
67
+ .tr-card.drop-before { box-shadow: inset 0 3px 0 0 rgb(var(--accent-rgb)); }
68
+ .tr-card.drop-after { box-shadow: inset 0 -3px 0 0 rgb(var(--accent-rgb)); }
46
69
 
47
70
  /* shell panel */
48
71
  .panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); overflow: hidden; }
@@ -193,8 +216,10 @@
193
216
  .prod-name { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 700; }
194
217
  .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
195
218
  .dot.qoder { background: #7c3aed; } .dot.workbuddy { background: #14b8a6; } .dot.zcode { background: #2563eb; }
196
- .prod-go { color: var(--text-3); font-size: 12px; white-space: nowrap; }
219
+ .prod-go { color: var(--text-3); font-size: 12px; white-space: nowrap; display: inline-flex; align-items: center; gap: 2px; }
220
+ .prod-go svg { width: 12px; height: 12px; transition: transform .15s; }
197
221
  .prodcard:hover .prod-go { color: var(--text); }
222
+ .prodcard:hover .prod-go svg { transform: translateX(2px); }
198
223
  .prod-rows { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 7px 12px; font-size: 12px; align-items: center; }
199
224
  .prod-rows .val { text-align: right; }
200
225
  .meter { height: 5px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); border-radius: 3px; overflow: hidden; }
@@ -209,6 +234,8 @@
209
234
  .item-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; min-width: 0; }
210
235
  .item-time { color: var(--text-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
211
236
  .muted-empty { color: var(--text-3); font-size: 12px; padding: 16px 0 6px; text-align: center; }
237
+ .muted-empty .ok-glyph { display: inline-flex; vertical-align: -2px; margin-right: 4px; color: var(--ok); }
238
+ .muted-empty .ok-glyph svg { width: 13px; height: 13px; }
212
239
  @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .prods, .dash-cols { grid-template-columns: 1fr; } }
213
240
 
214
241
  /* 10Router */
@@ -251,6 +278,9 @@
251
278
  .tr-p-val.low b { color: var(--err); }
252
279
  .tr-meter.big { height: 7px; }
253
280
  .tr-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; margin-top: 6px; }
281
+ .card .tr-line { margin-top: 6px; font-size: 11.5px; }
282
+ .card .tr-meter { margin-top: 2px; }
283
+ .card .mirasim-windows { margin-top: 4px; padding-top: 4px; border-top: 1px dashed var(--border-subtle); }
254
284
  .tr-summary { display: flex; align-items: baseline; gap: 6px; font-size: 12px; margin-top: 6px; }
255
285
  .tr-sum-txt { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
256
286
  .low-text { color: var(--err); font-weight: 600; }
@@ -287,6 +317,10 @@
287
317
  </div>
288
318
  <div class="head-right">
289
319
  <span id="sched" class="num"></span>
320
+ <div class="comp-wrap">
321
+ <button class="icon-btn" id="btn-comp" title="面板设置:组件显隐与排序、账号卡片排序"></button>
322
+ <div class="comp-menu" id="comp-menu" hidden></div>
323
+ </div>
290
324
  <a class="icon-btn" id="btn-home" href="https://github.com/techysy/CreditDaddy" target="_blank" rel="noopener" title="项目主页(GitHub)"></a>
291
325
  <button class="icon-btn" id="btn-theme" title="切换亮色 / 暗色"></button>
292
326
  </div>
@@ -297,6 +331,7 @@
297
331
  <button data-view="qoder">Qoder<span class="cnt" id="cnt-qoder">0</span></button>
298
332
  <button data-view="workbuddy">WorkBuddy<span class="cnt" id="cnt-workbuddy">0</span></button>
299
333
  <button data-view="zcode">ZCode<span class="cnt" id="cnt-zcode">0</span></button>
334
+ <button data-view="mirasim">mirasim<span class="cnt" id="cnt-mirasim">0</span></button>
300
335
  <button data-view="tenrouter">10Router<span class="cnt" id="cnt-tenrouter">–</span></button>
301
336
  </nav>
302
337
 
@@ -498,31 +533,160 @@ const I = {
498
533
  github: svg('<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65S8.93 17.38 9 18v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>'),
499
534
  credit: svg('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>'),
500
535
  gift: svg('<rect x="3" y="8" width="18" height="4"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5C11 3 12 8 12 8s1-5 4.5-5a2.5 2.5 0 0 1 0 5"/>'),
536
+ tune: svg('<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/>'),
537
+ chevUp: svg('<path d="M18 15l-6-6-6 6"/>'),
538
+ chevDown: svg('<path d="M6 9l6 6 6-6"/>'),
539
+ chevRight: svg('<path d="M9 18l6-6-6-6"/>'),
540
+ check: svg('<path d="M20 6L9 17l-5-5"/>'),
501
541
  };
502
542
 
503
543
  const $ = (id) => document.getElementById(id);
504
544
  const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '' };
505
- const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'tenrouter'];
545
+ const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'mirasim', 'tenrouter'];
506
546
  // 10Router 集成:config 来自 /api/tenrouter,quotas 来自 /api/tenrouter/quotas
507
547
  const tr = { config: null, quotas: null, error: null, loading: false, syncing: false };
508
548
  const PRODUCTS = [
509
549
  { id: 'qoder', label: 'Qoder' },
510
550
  { id: 'workbuddy', label: 'WorkBuddy' },
511
551
  { id: 'zcode', label: 'ZCode' },
552
+ { id: 'mirasim', label: 'mirasim' },
512
553
  ];
513
554
  const prodLabel = (id) => (PRODUCTS.find((x) => x.id === id) || { label: id }).label;
555
+
556
+ // ── 全局组件开关:不用某个产品 / 10Router 时整块收起(偏好存浏览器本地) ──
557
+ const COMPONENTS = [
558
+ { id: 'qoder', label: 'Qoder' },
559
+ { id: 'workbuddy', label: 'WorkBuddy' },
560
+ { id: 'zcode', label: 'ZCode' },
561
+ { id: 'mirasim', label: 'mirasim' },
562
+ { id: 'tenrouter', label: '10Router' },
563
+ ];
564
+ const HIDDEN_COMP_KEY = 'qd_hidden_components';
565
+ state.hidden = (() => {
566
+ const set = new Set();
567
+ try {
568
+ for (const v of JSON.parse(localStorage.getItem(HIDDEN_COMP_KEY) || '[]')) {
569
+ if (COMPONENTS.some((c) => c.id === v)) set.add(v);
570
+ }
571
+ } catch {}
572
+ return set;
573
+ })();
574
+ const compVisible = (id) => !state.hidden.has(id);
575
+ function saveHiddenComps() {
576
+ try { localStorage.setItem(HIDDEN_COMP_KEY, JSON.stringify([...state.hidden])); } catch {}
577
+ }
578
+
579
+ // ── 排序体系 ──
580
+ // 组件顺序:仪表盘产品卡与顶部标签栏按此顺序同步渲染(↑↓ 在右上角设置里调整)
581
+ const COMP_ORDER_KEY = 'qd_comp_order';
582
+ state.compOrder = (() => {
583
+ let arr = [];
584
+ try { arr = JSON.parse(localStorage.getItem(COMP_ORDER_KEY) || '[]'); } catch {}
585
+ const ids = COMPONENTS.map((c) => c.id);
586
+ arr = arr.filter((id) => ids.includes(id));
587
+ for (const id of ids) if (!arr.includes(id)) arr.push(id);
588
+ return arr;
589
+ })();
590
+ function saveCompOrder() {
591
+ try { localStorage.setItem(COMP_ORDER_KEY, JSON.stringify(state.compOrder)); } catch {}
592
+ }
593
+ const orderedVisibleComps = () => state.compOrder.filter(compVisible);
594
+
595
+ // 产品面板账号卡:默认「剩余积分多者靠前」;手动拖拽 / 设置里上下调整的顺序优先级最高(每产品独立持久化)
596
+ const CARD_ORDER_KEY = 'qd_card_order';
597
+ state.cardOrder = (() => {
598
+ try { const o = JSON.parse(localStorage.getItem(CARD_ORDER_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch { return {}; }
599
+ })();
600
+ function saveCardOrder() {
601
+ try { localStorage.setItem(CARD_ORDER_KEY, JSON.stringify(state.cardOrder)); } catch {}
602
+ }
603
+ // 参与排序的剩余量:无额度数据 / 查询失败的账号排最后(稳定排序保持原有次序)
604
+ function quotaRemainForSort(a) {
605
+ const q = state.quotas[a.id];
606
+ if (!q || q.error) return -1;
607
+ const r = Number(q.remaining);
608
+ return Number.isFinite(r) ? r : -1;
609
+ }
610
+ function defaultCardCmp(a, b) {
611
+ return quotaRemainForSort(b) - quotaRemainForSort(a);
612
+ }
613
+ function sortedAccounts(prod, list) {
614
+ const manual = state.cardOrder[prod];
615
+ const arr = list.slice();
616
+ if (manual && manual.length) {
617
+ const idx = new Map(manual.map((id, i) => [id, i]));
618
+ arr.sort((x, y) => {
619
+ const ix = idx.has(x.id) ? idx.get(x.id) : Infinity;
620
+ const iy = idx.has(y.id) ? idx.get(y.id) : Infinity;
621
+ return ix !== iy ? ix - iy : defaultCardCmp(x, y);
622
+ });
623
+ } else {
624
+ arr.sort(defaultCardCmp);
625
+ }
626
+ return arr;
627
+ }
628
+ // 10Router 连接卡:默认启用在前、剩余额度 % 多者在前;手动拖拽(每供应商独立)优先级最高
629
+ const TR_ORDER_KEY = 'qd_tr_order';
630
+ state.trOrder = (() => {
631
+ try { const o = JSON.parse(localStorage.getItem(TR_ORDER_KEY) || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch { return {}; }
632
+ })();
633
+ function saveTrOrder() {
634
+ try { localStorage.setItem(TR_ORDER_KEY, JSON.stringify(state.trOrder)); } catch {}
635
+ }
636
+ function connPctForSort(c) {
637
+ if (c.error) return -1;
638
+ const s = splitQuotas(c);
639
+ const cands = [s.primary, ...s.windows].filter(Boolean).map((q) => quotaPct(q)).filter((v) => v !== null);
640
+ return cands.length ? Math.max(...cands) : -1;
641
+ }
642
+ function trBaseCmp(a, b) {
643
+ return ((b.isActive ? 1 : 0) - (a.isActive ? 1 : 0)) || (connPctForSort(b) - connPctForSort(a));
644
+ }
645
+ function sortedByManual(items, manual, baseCmp) {
646
+ const arr = items.slice();
647
+ if (manual && manual.length) {
648
+ const idx = new Map(manual.map((id, i) => [id, i]));
649
+ arr.sort((x, y) => {
650
+ const ix = idx.has(x.id) ? idx.get(x.id) : Infinity;
651
+ const iy = idx.has(y.id) ? idx.get(y.id) : Infinity;
652
+ return ix !== iy ? ix - iy : baseCmp(x, y);
653
+ });
654
+ } else {
655
+ arr.sort(baseCmp);
656
+ }
657
+ return arr;
658
+ }
659
+ const sortedConnections = (vendor, items) => sortedByManual(items, state.trOrder[vendor], trBaseCmp);
660
+ // 「全部」视角的供应商汇总卡顺序:同名供应商聚合后按主额度 % 排序,手动拖拽(__vendors)优先
661
+ function sortedVendorGroups(groups) {
662
+ const keyOf = (g) => g.name;
663
+ const withId = groups.map((g) => ({ ...g, id: keyOf(g) }));
664
+ const base = (a, b) => connPctForSort(b.items[0]) - connPctForSort(a.items[0]) || String(a.name).localeCompare(String(b.name));
665
+ return sortedByManual(withId, state.trOrder.__vendors, base);
666
+ }
514
667
  const PROV = {
515
668
  qoder: { prod: 'qoder', prodLabel: 'Qoder', region: '国际版', cls: 'intl' },
516
669
  'qoder-cn': { prod: 'qoder', prodLabel: 'Qoder', region: '国内版', cls: 'cn' },
517
670
  workbuddy: { prod: 'workbuddy', prodLabel: 'WorkBuddy', region: '国内版', cls: 'cn' },
518
671
  'workbuddy-intl': { prod: 'workbuddy', prodLabel: 'WorkBuddy', region: '国际版', cls: 'intl' },
519
672
  zcode: { prod: 'zcode', prodLabel: 'ZCode', region: '', cls: '' },
673
+ mirasim: { prod: 'mirasim', prodLabel: 'mirasim', region: '', cls: '' },
520
674
  };
521
- // 支持每日签到的产品(ZCode 积分靠需验证码的活动领取,不做签到)
675
+ // 支持每日签到的产品(ZCode / mirasim 额度靠活动或滚动窗口,不做签到)
522
676
  const CHECKIN_PRODUCTS = new Set(['qoder', 'workbuddy']);
523
- const CLIENT_LABEL = { workbuddy: 'WorkBuddy', zcode: 'ZCode' };
677
+ const CLIENT_LABEL = { workbuddy: 'WorkBuddy', zcode: 'ZCode', mirasim: 'mirasim' };
524
678
  const provOf = (p) => PROV[p] || { prod: 'qoder', prodLabel: p, region: '', cls: '' };
525
- const provBadges = (p, regionOnly) => { const v = provOf(p); return (regionOnly ? '' : '<span class="badge prod ' + v.prod + '">' + esc(v.prodLabel) + '</span>') + (v.region ? '<span class="badge ' + v.cls + '">' + v.region + '</span>' : ''); };
679
+ const provBadges = (p, regionOnly, flavor) => {
680
+ const v = provOf(p);
681
+ const zBadge = p === 'zcode' && flavor
682
+ ? (flavor === 'both' ? '<span class="badge intl">Z.ai</span><span class="badge cn">BigModel</span>'
683
+ : flavor === 'zai' ? '<span class="badge intl">Z.ai</span>'
684
+ : '<span class="badge cn">BigModel</span>')
685
+ : '';
686
+ return (regionOnly ? '' : '<span class="badge prod ' + v.prod + '">' + esc(v.prodLabel) + '</span>')
687
+ + (v.region ? '<span class="badge ' + v.cls + '">' + v.region + '</span>' : '')
688
+ + zBadge;
689
+ };
526
690
 
527
691
  function esc(s) {
528
692
  return String(s === null || s === undefined ? '' : s)
@@ -545,6 +709,7 @@ function mask(s) {
545
709
  }
546
710
 
547
711
  let toastTimer = null;
712
+ const armed = {};
548
713
  function toast(msg, ok) {
549
714
  const t = $('toast');
550
715
  t.textContent = msg;
@@ -641,10 +806,62 @@ function statusOf(a) {
641
806
  }
642
807
 
643
808
  function expiryCell(a) {
809
+ const q = state.quotas[a.id];
810
+ // 优先展示真实的套餐/订阅到期时间(而不是几个小时就会自动刷新的 JWT 临时 token 时间)
811
+ if (q && q.planExpiresAt) {
812
+ const msPlan = new Date(q.planExpiresAt).getTime() - Date.now();
813
+ const planName = q.plan ? esc(q.plan) + ' ' : '';
814
+ if (msPlan <= 0) return { label: planName + '已到期', warn: true, title: '套餐已到期' };
815
+ return {
816
+ label: planName + fmtTime(q.planExpiresAt).slice(0, 10),
817
+ warn: msPlan < 3 * 86400e3,
818
+ title: '套餐有效期至 ' + fmtTime(q.planExpiresAt),
819
+ isPlan: true,
820
+ };
821
+ }
822
+
823
+ // 具有 refreshToken 的账号会自动在后台续期,短期 exp 属于正常轮换,不应显示刺眼红字误导用户
824
+ if (a.hasRefreshToken) {
825
+ return {
826
+ label: '凭据有效 · 自动续期',
827
+ warn: false,
828
+ title: a.expiresAt ? '本次登录凭据有效期至 ' + fmtTime(a.expiresAt) + '(过期将自动通过 refreshToken 刷新续期,无需手动干预)' : '支持自动刷新续期',
829
+ isAuto: true,
830
+ };
831
+ }
832
+
644
833
  if (!a.expiresAt) return { label: a.isPat ? 'PAT 长期有效' : '—', warn: false };
645
834
  const ms = new Date(a.expiresAt).getTime() - Date.now();
646
- if (ms <= 0) return { label: '已过期', warn: true };
647
- return { label: fmtTime(a.expiresAt), warn: ms < 7 * 86400e3 };
835
+ if (ms <= 0) return { label: '已过期', warn: true, title: '登录凭据已过期' };
836
+ return { label: fmtTime(a.expiresAt), warn: ms < 48 * 3600e3, title: '有效期至 ' + fmtTime(a.expiresAt) };
837
+ }
838
+
839
+ // 通用相对重置标签:「3 天 后重置」「45 分钟 后到期」;一小时内标红,悬停看绝对时间
840
+ function relReset(q) {
841
+ const at = q.resetAt || q.expiresAt;
842
+ if (!at) return null;
843
+ const ms = new Date(at).getTime() - Date.now();
844
+ if (!Number.isFinite(ms)) return null;
845
+ if (ms <= 0) return { text: '即将' + (q.recurring === false ? '到期' : '重置'), soon: true };
846
+ const m = Math.ceil(ms / 60000);
847
+ const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
848
+ const dur = d > 0 ? d + ' 天' + (h ? h + ' 小时' : '') : h > 0 ? h + ' 小时' + (mm ? mm + ' 分' : '') : Math.max(1, mm) + ' 分钟';
849
+ return { text: dur + ' 后' + (q.recurring === false ? '到期' : '重置'), soon: ms < 3600e3 };
850
+ }
851
+ function relResetHtml(q) {
852
+ const r = relReset(q);
853
+ const at = q.resetAt || q.expiresAt;
854
+ return r ? '<span class="tr-reset' + (r.soon ? ' soon' : '') + '" title="' + esc(fmtTime(at)) + '">' + esc(r.text) + '</span>' : '';
855
+ }
856
+ // 三档着色水位条(≥70 绿 / ≥30 琥珀 / <30 红)
857
+ const meterCls = (pct) => (pct >= 70 ? 'hi' : pct >= 30 ? 'mid' : 'low');
858
+ function trMeter(pct) {
859
+ if (pct === null || pct === undefined) return '';
860
+ const w = Math.min(100, Math.max(0, pct)).toFixed(1);
861
+ return '<div class="tr-meter"><span class="' + meterCls(pct) + '" style="width:' + w + '%"></span></div>';
862
+ }
863
+ function trLine(name, valHtml, title) {
864
+ 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>';
648
865
  }
649
866
 
650
867
  function creditHtml(a) {
@@ -652,6 +869,35 @@ function creditHtml(a) {
652
869
  if (!q) return '<div class="credit-line"><span class="dim">读取积分中…</span></div><div class="bar"></div>';
653
870
  if (q.error) return '<div class="credit-line"><span class="dim" title="' + esc(q.error) + '">积分读取失败:' + esc(q.error.slice(0, 40)) + '</span></div><div class="bar"></div>';
654
871
  if (q.empty) return '<div class="credit-line"><span class="dim">无有效套餐(仅免费额度)</span></div><div class="bar"></div>';
872
+
873
+ // 多窗口配额体系(mirasim 等):每个窗口平等等权,独立一行带分散对齐的名称、数值与三档细水位条
874
+ if (a.product === 'mirasim' && q.parts && q.parts.length) {
875
+ const isExhausted = q.exceeded || (q.remaining <= 0);
876
+ const planBadge = q.plan ? '<span class="badge">' + esc(q.plan) + ' 套餐</span>' : '';
877
+ const expLabel = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + ' 到期</span>' : '';
878
+ // 状态采用温和的徽章,避免整行大红字制造恐慌
879
+ const topStatus = isExhausted
880
+ ? '<span class="pill pending">' + esc(q.bottleneckLabel || '额度已用尽') + '</span>'
881
+ : '<span class="lbl">可用额度</span><b>' + fmtNum(q.remaining) + '%</b>';
882
+
883
+ const fmtUnits = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '万' : fmtNum(Math.round(n)));
884
+ const rows = q.parts.map((p) => {
885
+ const pct = p.remainingPercentage ?? (p.total > 0 ? Math.max(0, Math.min(100, (p.remaining / p.total) * 100)) : 0);
886
+ const isZero = pct <= 0;
887
+ const numTxt = p.total > 0 ? fmtUnits(p.remaining) + ' / ' + fmtUnits(p.total) : '';
888
+ // 数值保持中性温和排版,不用鲜红大字恐吓用户
889
+ const pctTxt = isZero
890
+ ? '<span class="dim">剩 0%</span>'
891
+ : '<span>剩 ' + fmtNum(pct) + '%</span>';
892
+ const valHtml = pctTxt + (numTxt ? ' <small class="dim">(' + numTxt + ')</small>' : '') + ' ' + relResetHtml(p);
893
+ return trLine(p.name, valHtml) + trMeter(pct);
894
+ }).join('');
895
+
896
+ return '<div class="credit-line"><span class="credit">' + topStatus + '</span>'
897
+ + '<span class="credit dim">' + planBadge + (planBadge && expLabel ? ' · ' : '') + expLabel + '</span></div>'
898
+ + '<div class="mirasim-windows">' + rows + '</div>';
899
+ }
900
+
655
901
  const unit = q.unit ? ' ' + q.unit : '';
656
902
  if (q.unit) {
657
903
  const items = q.parts.map((x) => {
@@ -689,10 +935,10 @@ function cardHtml(a) {
689
935
  const ident = a.email || a.phone || (a.uid ? 'UID ' + a.uid.slice(0, 8) : a.tokenMasked);
690
936
  const isCurrent = isClientCurrent(a);
691
937
  const canCheckin = CHECKIN_PRODUCTS.has(a.product);
692
- return '<div class="card" data-id="' + esc(a.id) + '">'
938
+ return '<div class="card" draggable="true" data-id="' + esc(a.id) + '">'
693
939
  + '<div class="row1"><div class="name-group">'
694
940
  + '<span class="name" data-act="rename" title="点击修改备注名">' + esc(a.name || '(未命名)') + '</span>'
695
- + provBadges(a.provider, true)
941
+ + provBadges(a.provider, true, a.flavor)
696
942
  + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] || '') + ' 客户端当前登录的账号">客户端当前</span>' : '')
697
943
  + (a.isPat ? '<span class="badge">PAT</span>' : '')
698
944
  + (a.verified === false ? '<span class="pill fail">token 无效</span>' : '')
@@ -706,7 +952,8 @@ function cardHtml(a) {
706
952
  + '</div></div>'
707
953
  + '<div class="meta">'
708
954
  + '<div class="mi"><span class="lbl">账号</span><span class="val" title="' + esc(a.email || a.phone || a.uid || '') + '">' + esc(a.phone && !a.email ? ident : mask(ident)) + '</span></div>'
709
- + '<div class="mi right"><span class="lbl">有效期至</span><span class="val' + (ex.warn ? ' warn' : '') + '">' + esc(ex.label) + '</span></div>'
955
+ + '<div class="mi right"><span class="lbl">' + (ex.isPlan ? '套餐到期' : ex.isAuto ? '凭据状态' : '有效期至') + '</span>'
956
+ + '<span class="val' + (ex.warn ? ' warn' : '') + '" title="' + esc(ex.title || '') + '">' + esc(ex.label) + '</span></div>'
710
957
  + '</div>'
711
958
  + creditHtml(a)
712
959
  + (st.msg && !(state.quotas[a.id] && state.quotas[a.id].error && state.quotas[a.id].error.includes(st.msg.slice(-24)))
@@ -717,11 +964,12 @@ function cardHtml(a) {
717
964
  const isDone = (a) => Boolean(state.status && state.status.todayDone && state.status.todayDone[a.id] === state.status.today);
718
965
  const ofProduct = (prod) => state.accounts.filter((a) => a.product === prod);
719
966
  const currentUidOf = (prod) => state.status && (prod === 'workbuddy' ? state.status.workbuddyCurrentUid : prod === 'zcode' ? state.status.zcodeCurrentUid : null);
720
- // 客户端当前登录判定:WorkBuddy 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
967
+ // 客户端当前登录判定:WorkBuddy / mirasim 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
721
968
  function isClientCurrent(a) {
722
969
  const s = state.status;
723
970
  if (!s) return false;
724
971
  if (a.product === 'workbuddy') return Boolean(a.uid && s.workbuddyCurrentUid && s.workbuddyCurrentUid === a.uid);
972
+ if (a.product === 'mirasim') return Boolean(a.uid && s.mirasimCurrentUid && s.mirasimCurrentUid === a.uid);
725
973
  if (a.product === 'zcode') {
726
974
  const id = s.zcodeCurrentIdentity;
727
975
  return Boolean(id && ((a.uid && id.uid != null && String(id.uid) === String(a.uid)) || (a.email && id.email && id.email === a.email)));
@@ -753,6 +1001,7 @@ const EMPTY_TIP = {
753
1001
  qoder: '还没有 Qoder 账号。<br>点「添加账号」可一键读取本机 Qoder / Qoder CN 客户端已登录的账号,或用浏览器登录。',
754
1002
  workbuddy: '还没有 WorkBuddy 账号。<br>点「添加账号」可读取本机 WorkBuddy 客户端(当前 + 历史会话),或用浏览器登录 WorkBuddy / CodeBuddy 账号。',
755
1003
  zcode: '还没有 ZCode 账号。<br>点「添加账号」可读取本机 ZCode 客户端已登录的账号,或用浏览器登录(BigModel / Z.ai),多个账号可一键切换。',
1004
+ mirasim: '还没有 mirasim 账号。<br>点「添加账号」可读取本机 mirasim 客户端当前登录的账号(本地解密,token 不出本机)。',
756
1005
  };
757
1006
 
758
1007
  function renderProd() {
@@ -761,7 +1010,7 @@ function renderProd() {
761
1010
  const acc = all.filter((a) => !state.region || provOf(a.provider).cls === state.region);
762
1011
  const list = $('list');
763
1012
  if (!acc.length) list.innerHTML = '<div class="empty">' + (all.length ? '该地区下没有账号。' : EMPTY_TIP[prod]) + '</div>';
764
- else list.innerHTML = acc.map(cardHtml).join('');
1013
+ else list.innerHTML = sortedAccounts(prod, acc).map(cardHtml).join('');
765
1014
  const canCheckin = CHECKIN_PRODUCTS.has(prod);
766
1015
  $('s-count').textContent = acc.length;
767
1016
  $('s-done-wrap').style.display = canCheckin ? 'contents' : 'none';
@@ -777,11 +1026,11 @@ function renderProd() {
777
1026
  $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
778
1027
  }
779
1028
  const c = creditSum(acc);
780
- $('s-credits-label').textContent = prod === 'zcode' ? '有套餐' : '剩余积分';
781
- $('s-credits').textContent = prod === 'zcode'
1029
+ $('s-credits-label').textContent = prod === 'zcode' || prod === 'mirasim' ? '有效套餐' : '剩余积分';
1030
+ $('s-credits').textContent = (prod === 'zcode' || prod === 'mirasim')
782
1031
  ? acc.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length + ' / ' + acc.length
783
1032
  : c.known ? fmtNum(c.sum) : '-';
784
- const hasRegions = prod !== 'zcode';
1033
+ const hasRegions = prod !== 'zcode' && prod !== 'mirasim';
785
1034
  $('region').style.display = hasRegions ? '' : 'none';
786
1035
  $('region-div').style.display = hasRegions ? '' : 'none';
787
1036
  document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
@@ -800,12 +1049,13 @@ const meter = (done, total) => '<div class="meter"><span style="width:' + (total
800
1049
  function renderDash() {
801
1050
  const s = state.status || {};
802
1051
  const sch = s.scheduler || {};
1052
+ const visibleProducts = PRODUCTS.filter((p) => compVisible(p.id));
803
1053
  const signable = state.accounts.filter((a) => CHECKIN_PRODUCTS.has(a.product));
804
1054
  const done = signable.filter(isDone).length;
805
1055
  const failed = signable.filter((a) => statusOf(a).cls === 'fail').length;
806
1056
  const credit = creditSum(state.accounts.filter((a) => a.product !== 'zcode'));
807
- const byProd = PRODUCTS.map((p) => p.label + ' ' + ofProduct(p.id).length).join(' · ');
808
- const creditByProd = ['qoder', 'workbuddy'].map((id) => { const c = creditSum(ofProduct(id)); return prodLabel(id) + ' ' + (c.known ? fmtNum(c.sum) : '-'); }).join(' · ');
1057
+ const byProd = orderedVisibleComps().filter((id) => id !== 'tenrouter').map((id) => prodLabel(id) + ' ' + ofProduct(id).length).join(' · ');
1058
+ const creditByProd = ['qoder', 'workbuddy'].filter(compVisible).map((id) => { const c = creditSum(ofProduct(id)); return prodLabel(id) + ' ' + (c.known ? fmtNum(c.sum) : '-'); }).join(' · ');
809
1059
  // 今日进度拆「国内领取 / 国际活跃」:国际版 WorkBuddy 是保持活跃(活跃不等于必有奖励)
810
1060
  const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl');
811
1061
  const domAcc = signable.filter((a) => a.provider !== 'workbuddy-intl');
@@ -823,7 +1073,8 @@ function renderDash() {
823
1073
  + kpi('下次自动领取', sch.ticking ? '领取中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
824
1074
  sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : '每 ~2 小时一轮');
825
1075
 
826
- $('prods').innerHTML = PRODUCTS.map((p) => {
1076
+ // 仪表盘产品卡按组件设置里的顺序渲染(与标签栏同步),10Router 汇总卡按其顺序插入
1077
+ const prodCardHtml = (p) => {
827
1078
  const list = ofProduct(p.id);
828
1079
  let rows = '';
829
1080
  if (!list.length) {
@@ -848,6 +1099,10 @@ function renderDash() {
848
1099
  }
849
1100
  rows += '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
850
1101
  + '<span class="lbl">剩余积分</span><span class="val">' + (c.known ? fmtNum(c.sum) : '-') + '</span>';
1102
+ } else if (p.id === 'mirasim') {
1103
+ const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1104
+ rows = '<span class="lbl">额度体系</span><span class="val dim">5h / 7d 滚动窗口</span>'
1105
+ + '<span class="lbl">有效套餐</span><span class="val">' + planned + ' / ' + list.length + '</span>';
851
1106
  } else {
852
1107
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
853
1108
  // ZCode 没有每日签到:不展示签到语义的行
@@ -862,12 +1117,18 @@ function renderDash() {
862
1117
  const zid = p.id === 'zcode' ? s.zcodeCurrentIdentity : null;
863
1118
  const live = cur ? esc(cur.name || cur.email || cur.uid)
864
1119
  : zid && (zid.email || zid.uid || zid.username) ? '未导入的账号(' + esc(zid.email || zid.username || zid.uid) + ')'
1120
+ : p.id === 'mirasim' && s.mirasimCurrentUid ? '未导入的账号'
865
1121
  : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? '未导入的账号' : '—');
866
1122
  rows += '<span class="lbl">客户端当前</span><span class="val">' + live + '</span>';
867
1123
  }
868
1124
  return '<div class="prodcard" data-view="' + p.id + '"><div class="prod-head"><span class="prod-name"><span class="dot ' + p.id + '"></span>' + p.label
869
- + '<span class="badge">' + list.length + ' 个账号</span></span><span class="prod-go">查看 →</span></div><div class="prod-rows">' + rows + '</div></div>';
870
- }).join('') + trDashCard();
1125
+ + '<span class="badge">' + list.length + ' 个账号</span></span><span class="prod-go">查看' + I.chevRight + '</span></div><div class="prod-rows">' + rows + '</div></div>';
1126
+ };
1127
+ $('prods').innerHTML = orderedVisibleComps().map((id) => {
1128
+ if (id === 'tenrouter') return trDashCard();
1129
+ const p = PRODUCTS.find((x) => x.id === id);
1130
+ return p ? prodCardHtml(p) : '';
1131
+ }).join('');
871
1132
 
872
1133
  // 需要处理:token 失效 / 即将过期、领取失败、国际版缺风控身份、10Router 额度告急 / 查询失败
873
1134
  const att = [];
@@ -884,20 +1145,24 @@ function renderDash() {
884
1145
  if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: a.provider === 'workbuddy-intl' ? '活跃失败' : '领取失败', title: st.msg }, base));
885
1146
  }
886
1147
  for (const c of (tr.quotas && tr.quotas.connections) || []) {
1148
+ if (!compVisible('tenrouter')) break;
887
1149
  const base = { view: 'tenrouter', name: c.name || c.email || c.providerName, badges: '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>' };
888
1150
  if (c.error) att.push(Object.assign({ pill: 'fail', text: '额度查询失败', title: c.error }, base));
889
1151
  else if (c.isActive && quotaLow(c)) att.push(Object.assign({ pill: 'limited', text: '额度告急' }, base));
890
1152
  }
891
- $('att-count').textContent = att.length ? att.length + ' 项' : '';
892
- $('attention').innerHTML = att.length
893
- ? att.slice(0, 8).map((x) => '<div class="item" data-view="' + x.view + '" title="' + esc(x.title || '') + '"><span class="item-main"><span class="item-name">' + esc(x.name) + '</span>' + x.badges + '</span>'
1153
+ // 隐藏掉的组件不进「需要处理」(如收起 10Router 后不再展示其额度告急)
1154
+ const attVisible = att.filter((x) => compVisible(x.view));
1155
+ $('att-count').textContent = attVisible.length ? attVisible.length + ' 项' : '';
1156
+ $('attention').innerHTML = attVisible.length
1157
+ ? attVisible.slice(0, 8).map((x) => '<div class="item" data-view="' + x.view + '" title="' + esc(x.title || '') + '"><span class="item-main"><span class="item-name">' + esc(x.name) + '</span>' + x.badges + '</span>'
894
1158
  + '<span class="item-side"><span class="pill ' + x.pill + '">' + esc(x.text) + '</span></span></div>').join('')
895
- + (att.length > 8 ? '<div class="muted-empty">还有 ' + (att.length - 8) + ' 项</div>' : '')
896
- : '<div class="muted-empty">' + (state.accounts.length ? '✓ 一切正常' : '还没有账号') + '</div>';
1159
+ + (attVisible.length > 8 ? '<div class="muted-empty">还有 ' + (attVisible.length - 8) + ' 项</div>' : '')
1160
+ : '<div class="muted-empty">' + (state.accounts.length ? '<span class="ok-glyph">' + I.check + '</span>一切正常' : '还没有账号') + '</div>';
897
1161
 
898
1162
  // 最近领取记录
899
1163
  const RES = { 'checked-in': ['ok', '领取成功'], already: ['ok', '已领过'], limited: ['limited', '本机已领'], 'no-activity': ['pending', '无可领活动'], failed: ['fail', '失败'] };
900
- 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);
1164
+ const recent = state.accounts.filter((a) => a.lastResult && a.lastResult.at && compVisible(a.product))
1165
+ .sort((x, y) => String(y.lastResult.at).localeCompare(String(x.lastResult.at))).slice(0, 8);
901
1166
  $('last-tick').textContent = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) : '';
902
1167
  $('recent').innerHTML = recent.length
903
1168
  ? recent.map((a) => {
@@ -925,8 +1190,13 @@ function renderStatus() {
925
1190
  // 产品相关提示只在对应标签页出现
926
1191
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
927
1192
  const n = $('notice');
928
- n.classList.toggle('info', state.view === 'zcode' || (hasIntl && Boolean(s.riskIdentity)));
929
- if (state.view === 'zcode') {
1193
+ n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || (hasIntl && Boolean(s.riskIdentity)));
1194
+ if (state.view === 'mirasim') {
1195
+ n.style.display = '';
1196
+ n.innerHTML = 'mirasim(原生 AI 编程开发环境)额度按「平台额度」(5h 滚动窗口、7d 全局上限与分模型上限)分配。Token 约 1 小时过期,读取额度时会自动用 refreshToken 静默续期并同步回客户端,无需手动处理。'
1197
+ + '切换账号需要至少两个已导入的账号(当前登录 + 目标账号):先在「添加账号 → 本机导入」把各账号分别导入,切换时当前登录会自动保存、目标账号写入客户端,桌面版切完自动重启 mirasim。'
1198
+ + mirasimClientChip(s);
1199
+ } else if (state.view === 'zcode') {
930
1200
  n.style.display = '';
931
1201
  const pf = state.zProxyFirst === true;
932
1202
  const pu = state.zProxyUrl;
@@ -963,6 +1233,15 @@ function zcodeClientChip(s) {
963
1233
  + (canOpen ? ' <button class="btn" id="btn-zopen" style="margin-left:4px" title="打开本机 ZCode 客户端">打开客户端</button>' : '');
964
1234
  }
965
1235
 
1236
+ // mirasim 客户端状态芯片 + 「打开客户端」按钮
1237
+ function mirasimClientChip(s) {
1238
+ const c = s.mirasimClient || {};
1239
+ if (!c.installed) return ' <span class="dim">客户端:未安装</span>';
1240
+ const canOpen = window.creditdaddy && typeof window.creditdaddy.openMirasimClient === 'function';
1241
+ return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + '</span>'
1242
+ + (canOpen ? ' <button class="btn" id="btn-miraopen" style="margin-left:4px" title="打开本机 Mirasim 客户端">打开客户端</button>' : '');
1243
+ }
1244
+
966
1245
  // Qoder 设备身份组件(Linux / fnOS)
967
1246
  const umidOffer = (s) => Boolean(s && s.umid && s.umid.supported && !s.umid.installed);
968
1247
  let umidBusy = false;
@@ -985,6 +1264,14 @@ $('notice').addEventListener('click', async (e) => {
985
1264
  } catch (err) { toast(String(err && err.message || err), false); }
986
1265
  return;
987
1266
  }
1267
+ if (e.target.closest('#btn-miraopen')) {
1268
+ try {
1269
+ const r = await window.creditdaddy.openMirasimClient();
1270
+ if (r && r.ok) toast('已启动 Mirasim 客户端');
1271
+ else toast((r && r.error) || '无法打开 Mirasim 客户端', false);
1272
+ } catch (err) { toast(String(err && err.message || err), false); }
1273
+ return;
1274
+ }
988
1275
  if (e.target.closest('#btn-zac')) {
989
1276
  try {
990
1277
  const r = await api('/api/zcode/net', { method: 'PUT', json: { autoClaim: state.zAutoClaim !== true } });
@@ -1061,37 +1348,11 @@ function quotaValue(q) {
1061
1348
  }
1062
1349
  const resetLabel = (q) => (q.resetAt ? fmtTime(q.resetAt).slice(5) + (q.recurring === false ? ' 到期' : ' 重置') : '');
1063
1350
 
1064
- // 相对重置标签(CLIProxyAPI 管理中心风格):「3 天 后重置」「45 分钟 后到期」;一小时内标红,悬停看绝对时间
1065
- function relReset(q) {
1066
- if (!q.resetAt) return null;
1067
- const ms = new Date(q.resetAt).getTime() - Date.now();
1068
- if (!Number.isFinite(ms)) return null;
1069
- if (ms <= 0) return { text: '即将' + (q.recurring === false ? '到期' : '重置'), soon: true };
1070
- const m = Math.ceil(ms / 60000);
1071
- const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
1072
- const dur = d > 0 ? d + ' 天' + (h ? h + ' 小时' : '') : h > 0 ? h + ' 小时' + (mm ? mm + ' 分' : '') : Math.max(1, mm) + ' 分钟';
1073
- return { text: dur + ' 后' + (q.recurring === false ? '到期' : '重置'), soon: ms < 3600e3 };
1074
- }
1075
- function relResetHtml(q) {
1076
- const r = relReset(q);
1077
- return r ? '<span class="tr-reset' + (r.soon ? ' soon' : '') + '" title="' + esc(fmtTime(q.resetAt)) + '">' + esc(r.text) + '</span>' : '';
1078
- }
1079
- // 三档着色水位条(≥70 绿 / ≥30 琥珀 / <30 红)
1080
- const meterCls = (pct) => (pct >= 70 ? 'hi' : pct >= 30 ? 'mid' : 'low');
1081
- function trMeter(pct) {
1082
- if (pct === null) return '';
1083
- const w = Math.min(100, Math.max(0, pct)).toFixed(1);
1084
- return '<div class="tr-meter"><span class="' + meterCls(pct) + '" style="width:' + w + '%"></span></div>';
1085
- }
1086
1351
  // 明细整行:名称 + 数值/相对重置 + 水位条(CLIProxyAPI quotaRow 版式)
1087
1352
  function trRow(q) {
1088
1353
  return trLine(q.name, lineVal(q)) + trMeter(quotaPct(q));
1089
1354
  }
1090
1355
 
1091
- // 紧凑行(周期额度 / 聚合积分包):名称在左、数值在右,不带进度条
1092
- function trLine(name, valHtml, title) {
1093
- 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>';
1094
- }
1095
1356
  function lineVal(q) {
1096
1357
  const pct = quotaPct(q);
1097
1358
  // 百分比口径(限时限额额度):一律显示 %,节约空间;积分类保持实际数字
@@ -1180,7 +1441,7 @@ function trCardHtml(c) {
1180
1441
  + trPacksHtml(c, packs);
1181
1442
  }
1182
1443
  }
1183
- return '<div class="tr-card' + (c.isActive ? '' : ' off') + '"><div class="row1"><div class="name-group">'
1444
+ return '<div class="tr-card' + (c.isActive ? '' : ' off') + '" draggable="true" data-id="' + esc(c.id) + '" data-vendor="' + esc(c.providerName || c.provider || '') + '"><div class="row1"><div class="name-group">'
1184
1445
  + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1185
1446
  + (TR_REGION[c.provider] ? '<span class="badge ' + TR_REGION[c.provider][1] + '">' + TR_REGION[c.provider][0] + '</span>' : '')
1186
1447
  + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
@@ -1306,7 +1567,7 @@ function trVendorCardHtml(g) {
1306
1567
  ? '<div class="tr-summary dim"><span class="tr-sum-txt">' + briefs.join(' · ') + '</span></div>'
1307
1568
  : '');
1308
1569
  }
1309
- return '<div class="tr-card"><div class="row1"><div class="name-group">'
1570
+ return '<div class="tr-card" draggable="true" data-vendor="' + esc(g.name) + '"><div class="row1"><div class="name-group">'
1310
1571
  + '<span class="badge prod tenrouter">' + esc(g.name) + '</span>'
1311
1572
  + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + TR_REGION[prov][0] + '</span>' : '')
1312
1573
  + '<span class="badge">' + items.length + ' 个账号</span>'
@@ -1366,10 +1627,10 @@ function renderTenrouter() {
1366
1627
  else if (!list.length) grid = '<div class="empty">10Router 里还没有可查额度的供应商连接(OAuth 或支持额度查询的 API Key)。</div>';
1367
1628
  else if (activeVendor) {
1368
1629
  const g = groups.find((x) => x.name === activeVendor) || { items: [] };
1369
- grid = '<div class="tr-vcards">' + g.items.map(trCardHtml).join('') + '</div>';
1630
+ grid = '<div class="tr-vcards">' + sortedConnections(activeVendor, g.items).map(trCardHtml).join('') + '</div>';
1370
1631
  } else {
1371
1632
  grid = shown.length
1372
- ? '<div class="tr-vcards">' + shown.map(trVendorCardHtml).join('') + '</div>'
1633
+ ? '<div class="tr-vcards">' + sortedVendorGroups(shown).map(trVendorCardHtml).join('') + '</div>'
1373
1634
  : '<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>';
1374
1635
  }
1375
1636
  const h = tr.health;
@@ -1406,7 +1667,7 @@ function trDashCard() {
1406
1667
  + '<span class="lbl">额度告急</span><span class="val' + (low ? ' warn' : '') + '">' + (tr.quotas ? low : '-') + '</span>'
1407
1668
  + '<span class="lbl">用量同步</span><span class="val">' + (ls ? esc(fmtTime(ls.at).slice(5)) : c.sync.enabled ? '等待首次同步' : '手动') + '</span>';
1408
1669
  return '<div class="prodcard" data-view="tenrouter"><div class="prod-head"><span class="prod-name"><span class="dot tenrouter"></span>10Router</span>'
1409
- + '<span class="prod-go">查看 →</span></div><div class="prod-rows">' + rows + '</div></div>';
1670
+ + '<span class="prod-go">查看' + I.chevRight + '</span></div><div class="prod-rows">' + rows + '</div></div>';
1410
1671
  }
1411
1672
 
1412
1673
  async function loadTrConfig() {
@@ -1576,19 +1837,40 @@ async function onCardClick(e) {
1576
1837
  const client = CLIENT_LABEL[a && a.product] || '客户端';
1577
1838
  const tip = a && a.product === 'zcode'
1578
1839
  ? '\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1579
- : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
1840
+ : a && a.product === 'mirasim'
1841
+ ? '\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1842
+ : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
1580
1843
  if (!(await uiConfirm('把 ' + client + ' 切换到「' + (a && a.name || id) + '」?' + tip, '切换账号', '切换'))) return;
1581
1844
  const doSwitch = (force) => api('/api/accounts/' + encodeURIComponent(id) + '/switch', { json: { force } });
1582
1845
  try {
1583
1846
  let r;
1584
1847
  try { r = await doSwitch(false); }
1585
1848
  catch (err) {
1586
- if (err.code !== 'ZCODE_RUNNING') throw err;
1587
- if (!(await uiConfirm(err.message + '\n\n仍要强制切换吗?强制切换会先关闭 ZCode 客户端再写入登录(未保存的对话内容可能丢失),重新打开 ZCode 即为新账号。', 'ZCode 正在运行', '关闭并强制切换'))) return;
1849
+ if (err.code !== 'ZCODE_RUNNING' && err.code !== 'MIRASIM_RUNNING') throw err;
1850
+ if (!(await uiConfirm(err.message + '\n\n仍要强制切换吗?强制切换会先关闭客户端再写入登录,重新打开客户端即为新账号。', client + ' 正在运行', '关闭并强制切换'))) return;
1588
1851
  r = await doSwitch(true);
1589
1852
  }
1590
- toast(r && r.alreadyActive ? client + ' 当前已是该账号' : (r && r.closedClient ? '已关闭 ZCode,' : '') + client + ' 已切换到 ' + (a && a.name || id));
1853
+ const msg = r && r.alreadyActive ? client + ' 当前已是该账号'
1854
+ : (r && r.closedClient ? '已关闭并切换 ' : '') + client + ' 已切换到 ' + (a && a.name || id);
1855
+ toast(msg);
1591
1856
  await loadStatus(); await loadAccounts();
1857
+ // 桌面版切号后自动唤醒打开客户端,免去手动查找客户端快捷方式
1858
+ if (a && a.product === 'zcode' && window.creditdaddy && typeof window.creditdaddy.openZcodeClient === 'function') {
1859
+ setTimeout(async () => {
1860
+ try {
1861
+ const re = await window.creditdaddy.openZcodeClient();
1862
+ if (re && re.ok) toast('已重新启动 ZCode 客户端');
1863
+ } catch (e) {}
1864
+ }, 800);
1865
+ }
1866
+ if (a && a.product === 'mirasim' && window.creditdaddy && typeof window.creditdaddy.openMirasimClient === 'function') {
1867
+ setTimeout(async () => {
1868
+ try {
1869
+ const re = await window.creditdaddy.openMirasimClient();
1870
+ if (re && re.ok) toast('已重新启动 Mirasim 客户端');
1871
+ } catch (e) {}
1872
+ }, 800);
1873
+ }
1592
1874
  } catch (err) { toast(err.message, false); }
1593
1875
  return;
1594
1876
  }
@@ -1799,14 +2081,15 @@ function switchTab(name) {
1799
2081
 
1800
2082
  // 添加账号:在产品页打开时只显示该产品可用的方式
1801
2083
  const LOCAL_HINT = {
1802
- '': '读取本机 Qoder 客户端(本地解密)、WorkBuddy 客户端(当前 + 历史会话)、ZCode 客户端(本地解密)已登录的账号,token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
2084
+ '': '读取本机 Qoder 客户端(本地解密)、WorkBuddy 客户端(当前 + 历史会话)、ZCode 客户端(本地解密)、mirasim 客户端(本地解密)已登录的账号,token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
1803
2085
  qoder: '读取本机 Qoder / Qoder CN 客户端已登录的账号(本地解密 auth.v1.dat),token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
1804
2086
  workbuddy: '读取本机 WorkBuddy 客户端的当前会话与历史会话(CodeBuddyExtension 凭据目录),token 不出本机。',
1805
2087
  zcode: '读取本机 ZCode 客户端当前登录的账号(本地解密 ~/.zcode/v2/credentials.json),token 不出本机。',
2088
+ mirasim: '读取本机 mirasim 客户端当前登录的账号(本地解密 ~/.mirasim/setting.json),token 不出本机。',
1806
2089
  };
1807
2090
  function openAdd(product) {
1808
2091
  state.addProduct = product || '';
1809
- const tabShown = { local: true, device: true, token: product !== 'zcode' };
2092
+ const tabShown = { local: true, device: product !== 'mirasim', token: product !== 'zcode' && product !== 'mirasim' };
1810
2093
  document.querySelectorAll('#m-add .tab').forEach((t) => { t.style.display = tabShown[t.dataset.tab] ? '' : 'none'; });
1811
2094
  $('local-hint').textContent = LOCAL_HINT[state.addProduct];
1812
2095
  let first = null;
@@ -1854,9 +2137,9 @@ async function localScan() {
1854
2137
  }
1855
2138
  for (const e of r.errors || []) html += '<div class="hint" style="color:var(--err)">读取失败:' + esc(e.file) + ':' + esc(e.error) + '</div>';
1856
2139
  if (!cands.length) {
1857
- const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode';
1858
- const alt = state.addProduct === 'zcode' ? ',或改用「浏览器登录」' : ',或改用「浏览器登录」「粘贴 Token」';
1859
- html += '<div class="hint">未发现已登录的 ' + where + ' 账号。请先在客户端登录' + alt + '。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录'
2140
+ const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode / mirasim';
2141
+ const alt = state.addProduct === 'zcode' || state.addProduct === 'mirasim' ? '' : ',或改用「浏览器登录」「粘贴 Token」';
2142
+ html += '<div class="hint">未发现已登录的 ' + where + ' 账号。请先在客户端登录' + alt + '。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录、mirasim 数据目录'
1860
2143
  + (r.scannedDirs && r.scannedDirs.length ? '、' + r.scannedDirs.map(esc).join('、') : '') + '</div>';
1861
2144
  }
1862
2145
  box.innerHTML = html;
@@ -1998,6 +2281,219 @@ async function submitImport(password) {
1998
2281
  }
1999
2282
 
2000
2283
  // ── 绑定 ──
2284
+ // 全局组件开关:收起 Qoder / WorkBuddy / ZCode / 10Router 的标签页与仪表盘对应卡片
2285
+ $('btn-comp').innerHTML = I.tune;
2286
+
2287
+ // ── 卡片拖拽排序(产品面板 + 10Router):手动顺序优先级最高,落库 localStorage ──
2288
+ const dragState = { kind: null, id: null, vendor: null };
2289
+ function clearDropMarkers(root) {
2290
+ root.querySelectorAll('.drop-before, .drop-after').forEach((el) => el.classList.remove('drop-before', 'drop-after'));
2291
+ }
2292
+ /** 把 dragId 移到 targetId 的 before/after 位置;manual 为空时以当前默认顺序为底表 */
2293
+ function moveInManual(orderMap, key, baseIds, dragId, targetId, before) {
2294
+ const arr = (orderMap[key] && orderMap[key].length ? orderMap[key].slice() : baseIds.slice()).filter((id) => id !== dragId);
2295
+ const ti = arr.indexOf(targetId);
2296
+ arr.splice(ti < 0 ? arr.length : (before ? ti : ti + 1), 0, dragId);
2297
+ orderMap[key] = arr;
2298
+ }
2299
+ function bindCardDrag(container, { kind, onReorder }) {
2300
+ if (!container) return;
2301
+ container.addEventListener('dragstart', (e) => {
2302
+ const card = e.target.closest && e.target.closest('.card, .tr-card');
2303
+ if (!card) return;
2304
+ dragState.kind = kind;
2305
+ dragState.id = card.dataset.id || null;
2306
+ dragState.vendor = card.dataset.vendor || null;
2307
+ card.classList.add('dragging');
2308
+ e.dataTransfer.effectAllowed = 'move';
2309
+ try { e.dataTransfer.setData('text/plain', dragState.id || dragState.vendor || ''); } catch {}
2310
+ });
2311
+ container.addEventListener('dragend', (e) => {
2312
+ const card = e.target.closest && e.target.closest('.card, .tr-card');
2313
+ if (card) card.classList.remove('dragging');
2314
+ clearDropMarkers(container);
2315
+ dragState.kind = null; dragState.id = null; dragState.vendor = null;
2316
+ });
2317
+ container.addEventListener('dragover', (e) => {
2318
+ if (dragState.kind !== kind) return;
2319
+ const over = e.target.closest && e.target.closest('.card, .tr-card');
2320
+ if (!over) return;
2321
+ e.preventDefault();
2322
+ e.dataTransfer.dropEffect = 'move';
2323
+ clearDropMarkers(container);
2324
+ if ((over.dataset.id || null) === dragState.id) return;
2325
+ const rect = over.getBoundingClientRect();
2326
+ over.classList.add(e.clientY < rect.top + rect.height / 2 ? 'drop-before' : 'drop-after');
2327
+ });
2328
+ container.addEventListener('drop', (e) => {
2329
+ if (dragState.kind !== kind) return;
2330
+ const over = e.target.closest && e.target.closest('.card, .tr-card');
2331
+ if (!over) return;
2332
+ e.preventDefault();
2333
+ const before = over.classList.contains('drop-before');
2334
+ clearDropMarkers(container);
2335
+ const targetId = over.dataset.id || null;
2336
+ if (!targetId || targetId === dragState.id) return;
2337
+ onReorder(dragState, targetId, before);
2338
+ });
2339
+ }
2340
+ // 产品面板:拖拽账号卡 → state.cardOrder[prod]
2341
+ bindCardDrag($('list'), {
2342
+ kind: 'card',
2343
+ onReorder: (d, targetId, before) => {
2344
+ const prod = state.view;
2345
+ if (!PRODUCTS.some((p) => p.id === prod)) return;
2346
+ const baseIds = sortedAccounts(prod, ofProduct(prod)).map((a) => a.id);
2347
+ moveInManual(state.cardOrder, prod, baseIds, d.id, targetId, before);
2348
+ saveCardOrder();
2349
+ render();
2350
+ renderCompMenu();
2351
+ },
2352
+ });
2353
+ // 10Router:拖拽连接卡(单供应商视角按该供应商存序)与供应商汇总卡(全部视角存 __vendors)
2354
+ bindCardDrag($('tr-panel'), {
2355
+ kind: 'tr',
2356
+ onReorder: (d, targetId, before) => {
2357
+ if (!tr.quotas) return;
2358
+ const list = (tr.quotas.connections || []);
2359
+ const groups = trGroups(list);
2360
+ if (d.id) {
2361
+ // 连接卡:vendor 取自卡片或当前视图
2362
+ const vendor = d.vendor || state.trVendor || (groups.find((g) => g.items.some((x) => x.id === d.id)) || {}).name;
2363
+ if (!vendor) return;
2364
+ const g = groups.find((x) => x.name === vendor);
2365
+ if (!g) return;
2366
+ const baseIds = sortedConnections(vendor, g.items).map((c) => c.id);
2367
+ moveInManual(state.trOrder, vendor, baseIds, d.id, targetId, before);
2368
+ } else if (d.vendor) {
2369
+ // 供应商汇总卡
2370
+ const baseIds = sortedVendorGroups(groups).map((g) => g.name);
2371
+ moveInManual(state.trOrder, '__vendors', baseIds, d.vendor, targetId, before);
2372
+ } else return;
2373
+ saveTrOrder();
2374
+ renderTenrouter();
2375
+ },
2376
+ });
2377
+
2378
+ function syncCompMenu() { renderCompMenu(); }
2379
+ function applyComponents() {
2380
+ // 标签栏按组件设置里的顺序重排(仪表盘按钮始终最前)
2381
+ const nav = $('nav');
2382
+ for (const id of state.compOrder) {
2383
+ const btn = nav.querySelector('button[data-view="' + id + '"]');
2384
+ if (btn) nav.appendChild(btn);
2385
+ }
2386
+ for (const c of COMPONENTS) {
2387
+ const btn = document.querySelector('#nav button[data-view="' + c.id + '"]');
2388
+ if (btn) btn.style.display = compVisible(c.id) ? '' : 'none';
2389
+ }
2390
+ if (state.view !== 'dash' && !compVisible(state.view)) {
2391
+ history.replaceState(null, '', location.pathname + location.search);
2392
+ applyView();
2393
+ return; // applyView 内部已 render + renderStatus
2394
+ }
2395
+ render();
2396
+ renderStatus();
2397
+ }
2398
+
2399
+ // 右上角面板设置:组件显隐 + 组件顺序(↑↓,仪表盘与标签栏同步)+ 每产品账号排序(默认积分多者靠前,可手动固定)
2400
+ function renderCompMenu() {
2401
+ const compRows = state.compOrder.map((id) => {
2402
+ const c = COMPONENTS.find((x) => x.id === id);
2403
+ if (!c) return '';
2404
+ const vis = compVisible(id);
2405
+ return '<div class="comp-row' + (vis ? '' : ' off') + '">'
2406
+ + '<label class="check"><input type="checkbox" data-comp="' + id + '"' + (vis ? ' checked' : '') + '>' + esc(c.label) + '</label>'
2407
+ + '<span class="comp-ops">'
2408
+ + '<button class="icon-btn mini" data-move="up" data-comp="' + id + '" title="上移(仪表盘与标签栏同步)">' + I.chevUp + '</button>'
2409
+ + '<button class="icon-btn mini" data-move="down" data-comp="' + id + '" title="下移(仪表盘与标签栏同步)">' + I.chevDown + '</button>'
2410
+ + '</span></div>';
2411
+ }).join('');
2412
+ const sortSections = PRODUCTS.filter((p) => compVisible(p.id) && ofProduct(p.id).length).map((p) => {
2413
+ const manual = state.cardOrder[p.id];
2414
+ const rows = sortedAccounts(p.id, ofProduct(p.id)).map((a) => '<div class="sort-row" data-aid="' + esc(a.id) + '">'
2415
+ + '<span class="sort-name" title="' + esc(a.name || a.email || a.uid || a.id) + '">' + esc(a.name || a.email || ('#' + a.id.slice(0, 6))) + '</span>'
2416
+ + '<span class="comp-ops">'
2417
+ + '<button class="icon-btn mini" data-acc-move="up" data-prod="' + p.id + '" data-aid="' + esc(a.id) + '" title="上移">' + I.chevUp + '</button>'
2418
+ + '<button class="icon-btn mini" data-acc-move="down" data-prod="' + p.id + '" data-aid="' + esc(a.id) + '" title="下移">' + I.chevDown + '</button>'
2419
+ + '</span></div>').join('');
2420
+ return '<details class="sort-sec"' + (manual && manual.length ? ' open' : '') + '><summary>' + esc(p.label)
2421
+ + (manual && manual.length ? ' <span class="badge">手动排序</span>' : '')
2422
+ + '<span class="dim" style="font-size:11px">' + ofProduct(p.id).length + '</span></summary>'
2423
+ + '<div class="sort-hint">默认按剩余积分从多到少;拖拽卡片或用上下按钮固定顺序。</div>'
2424
+ + rows
2425
+ + (manual && manual.length ? '<button class="btn" data-acc-reset="' + p.id + '" style="margin-top:6px;width:100%">恢复默认排序</button>' : '')
2426
+ + '</details>';
2427
+ }).join('');
2428
+ $('comp-menu').innerHTML = '<div class="comp-menu-title">组件顺序(仪表盘与标签栏同步)</div>' + compRows
2429
+ + '<div class="comp-menu-title" style="margin-top:2px">账号卡片排序</div>'
2430
+ + (sortSections || '<div class="sort-hint">暂无账号</div>');
2431
+ }
2432
+
2433
+ /** 在手动排序列表里移动一个 id;列表不存在时先按当前生效顺序物化一份 */
2434
+ function moveAccountId(prod, aid, dir) {
2435
+ const baseIds = sortedAccounts(prod, ofProduct(prod)).map((a) => a.id);
2436
+ const arr = (state.cardOrder[prod] && state.cardOrder[prod].length ? state.cardOrder[prod].slice() : baseIds);
2437
+ const i = arr.indexOf(aid);
2438
+ if (i < 0) return;
2439
+ const j = dir === 'up' ? i - 1 : i + 1;
2440
+ if (j < 0 || j >= arr.length) return;
2441
+ [arr[i], arr[j]] = [arr[j], arr[i]];
2442
+ state.cardOrder[prod] = arr;
2443
+ saveCardOrder();
2444
+ }
2445
+ $('btn-comp').addEventListener('click', (e) => {
2446
+ e.stopPropagation();
2447
+ const m = $('comp-menu');
2448
+ if (m.hidden) renderCompMenu();
2449
+ m.hidden = !m.hidden;
2450
+ });
2451
+ document.addEventListener('click', (e) => {
2452
+ const m = $('comp-menu');
2453
+ if (!m.hidden && !e.target.closest('.comp-wrap')) m.hidden = true;
2454
+ });
2455
+ $('comp-menu').addEventListener('change', (e) => {
2456
+ const c = e.target.dataset && e.target.dataset.comp;
2457
+ if (!c) return;
2458
+ if (e.target.checked) state.hidden.delete(c); else state.hidden.add(c);
2459
+ saveHiddenComps();
2460
+ applyComponents();
2461
+ renderCompMenu();
2462
+ });
2463
+ $('comp-menu').addEventListener('click', (e) => {
2464
+ const move = e.target.closest && e.target.closest('[data-move]');
2465
+ if (move) {
2466
+ e.stopPropagation();
2467
+ const id = move.dataset.comp;
2468
+ const i = state.compOrder.indexOf(id);
2469
+ const j = move.dataset.move === 'up' ? i - 1 : i + 1;
2470
+ if (i < 0 || j < 0 || j >= state.compOrder.length) return;
2471
+ [state.compOrder[i], state.compOrder[j]] = [state.compOrder[j], state.compOrder[i]];
2472
+ saveCompOrder();
2473
+ applyComponents();
2474
+ renderCompMenu();
2475
+ return;
2476
+ }
2477
+ const accMove = e.target.closest && e.target.closest('[data-acc-move]');
2478
+ if (accMove) {
2479
+ e.stopPropagation();
2480
+ moveAccountId(accMove.dataset.prod, accMove.dataset.aid, accMove.dataset.accMove);
2481
+ render();
2482
+ renderCompMenu();
2483
+ return;
2484
+ }
2485
+ const reset = e.target.closest && e.target.closest('[data-acc-reset]');
2486
+ if (reset) {
2487
+ e.stopPropagation();
2488
+ delete state.cardOrder[reset.dataset.accReset];
2489
+ saveCardOrder();
2490
+ render();
2491
+ renderCompMenu();
2492
+ }
2493
+ });
2494
+ applyComponents();
2495
+ renderCompMenu();
2496
+
2001
2497
  $('btn-checkin-all').innerHTML = I.play + '全部领取';
2002
2498
  for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + '添加账号';
2003
2499
  for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + '导入'; $(id).onclick = pickImport; }
@@ -2023,6 +2519,8 @@ function applyView() {
2023
2519
  const raw = location.hash.slice(1);
2024
2520
  const [v, sub] = raw.split('/');
2025
2521
  state.view = VIEWS.includes(v) ? v : 'dash';
2522
+ // 被组件开关收起的页面回仪表盘
2523
+ if (state.view !== 'dash' && !compVisible(state.view)) state.view = 'dash';
2026
2524
  state.region = '';
2027
2525
  // 10Router 供应商标签可用 hash 直达:#tenrouter/<供应商名>
2028
2526
  state.trVendor = state.view === 'tenrouter' && sub ? decodeURIComponent(sub) : '';