creditdaddy 0.9.2 → 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) => {
@@ -660,9 +906,12 @@ function creditHtml(a) {
660
906
  return base + (x.expiresAt ? '(' + fmtTime(x.expiresAt).slice(5, 16) + (x.recurring ? ' 重置)' : ' 到期)') : '');
661
907
  }).join(' · ');
662
908
  const pct = q.total > 0 ? Math.min(1, q.remaining / q.total) : 0;
909
+ // 满额(100%)不再渲染灰色「已使用」尾段——它只占 0 宽也会造成几像素的灰边
663
910
  return '<div class="credit-line"><span class="credit"><span class="lbl">剩余</span><b>' + fmtNum(q.remaining) + '</b>' + esc(unit) + '</span>'
664
911
  + '<span class="credit dim" title="' + items + '">' + (q.plan ? esc(q.plan) + ' · ' : '') + items + '</span></div>'
665
- + '<div class="bar"><span class="seg" style="flex:' + pct.toFixed(4) + ' 1 0"></span><span class="seg used" style="flex:' + (1 - pct).toFixed(4) + ' 1 0"></span></div>';
912
+ + '<div class="bar"><span class="seg" style="flex:' + pct.toFixed(4) + ' 1 0"></span>'
913
+ + (pct < 0.9999 ? '<span class="seg used" style="flex:' + (1 - pct).toFixed(4) + ' 1 0"></span>' : '')
914
+ + '</div>';
666
915
  }
667
916
  const total = q.total || 0;
668
917
  let bar = '';
@@ -686,10 +935,10 @@ function cardHtml(a) {
686
935
  const ident = a.email || a.phone || (a.uid ? 'UID ' + a.uid.slice(0, 8) : a.tokenMasked);
687
936
  const isCurrent = isClientCurrent(a);
688
937
  const canCheckin = CHECKIN_PRODUCTS.has(a.product);
689
- return '<div class="card" data-id="' + esc(a.id) + '">'
938
+ return '<div class="card" draggable="true" data-id="' + esc(a.id) + '">'
690
939
  + '<div class="row1"><div class="name-group">'
691
940
  + '<span class="name" data-act="rename" title="点击修改备注名">' + esc(a.name || '(未命名)') + '</span>'
692
- + provBadges(a.provider, true)
941
+ + provBadges(a.provider, true, a.flavor)
693
942
  + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] || '') + ' 客户端当前登录的账号">客户端当前</span>' : '')
694
943
  + (a.isPat ? '<span class="badge">PAT</span>' : '')
695
944
  + (a.verified === false ? '<span class="pill fail">token 无效</span>' : '')
@@ -703,7 +952,8 @@ function cardHtml(a) {
703
952
  + '</div></div>'
704
953
  + '<div class="meta">'
705
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>'
706
- + '<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>'
707
957
  + '</div>'
708
958
  + creditHtml(a)
709
959
  + (st.msg && !(state.quotas[a.id] && state.quotas[a.id].error && state.quotas[a.id].error.includes(st.msg.slice(-24)))
@@ -714,11 +964,12 @@ function cardHtml(a) {
714
964
  const isDone = (a) => Boolean(state.status && state.status.todayDone && state.status.todayDone[a.id] === state.status.today);
715
965
  const ofProduct = (prod) => state.accounts.filter((a) => a.product === prod);
716
966
  const currentUidOf = (prod) => state.status && (prod === 'workbuddy' ? state.status.workbuddyCurrentUid : prod === 'zcode' ? state.status.zcodeCurrentUid : null);
717
- // 客户端当前登录判定:WorkBuddy 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
967
+ // 客户端当前登录判定:WorkBuddy / mirasim 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
718
968
  function isClientCurrent(a) {
719
969
  const s = state.status;
720
970
  if (!s) return false;
721
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);
722
973
  if (a.product === 'zcode') {
723
974
  const id = s.zcodeCurrentIdentity;
724
975
  return Boolean(id && ((a.uid && id.uid != null && String(id.uid) === String(a.uid)) || (a.email && id.email && id.email === a.email)));
@@ -750,6 +1001,7 @@ const EMPTY_TIP = {
750
1001
  qoder: '还没有 Qoder 账号。<br>点「添加账号」可一键读取本机 Qoder / Qoder CN 客户端已登录的账号,或用浏览器登录。',
751
1002
  workbuddy: '还没有 WorkBuddy 账号。<br>点「添加账号」可读取本机 WorkBuddy 客户端(当前 + 历史会话),或用浏览器登录 WorkBuddy / CodeBuddy 账号。',
752
1003
  zcode: '还没有 ZCode 账号。<br>点「添加账号」可读取本机 ZCode 客户端已登录的账号,或用浏览器登录(BigModel / Z.ai),多个账号可一键切换。',
1004
+ mirasim: '还没有 mirasim 账号。<br>点「添加账号」可读取本机 mirasim 客户端当前登录的账号(本地解密,token 不出本机)。',
753
1005
  };
754
1006
 
755
1007
  function renderProd() {
@@ -758,7 +1010,7 @@ function renderProd() {
758
1010
  const acc = all.filter((a) => !state.region || provOf(a.provider).cls === state.region);
759
1011
  const list = $('list');
760
1012
  if (!acc.length) list.innerHTML = '<div class="empty">' + (all.length ? '该地区下没有账号。' : EMPTY_TIP[prod]) + '</div>';
761
- else list.innerHTML = acc.map(cardHtml).join('');
1013
+ else list.innerHTML = sortedAccounts(prod, acc).map(cardHtml).join('');
762
1014
  const canCheckin = CHECKIN_PRODUCTS.has(prod);
763
1015
  $('s-count').textContent = acc.length;
764
1016
  $('s-done-wrap').style.display = canCheckin ? 'contents' : 'none';
@@ -774,11 +1026,11 @@ function renderProd() {
774
1026
  $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
775
1027
  }
776
1028
  const c = creditSum(acc);
777
- $('s-credits-label').textContent = prod === 'zcode' ? '有套餐' : '剩余积分';
778
- $('s-credits').textContent = prod === 'zcode'
1029
+ $('s-credits-label').textContent = prod === 'zcode' || prod === 'mirasim' ? '有效套餐' : '剩余积分';
1030
+ $('s-credits').textContent = (prod === 'zcode' || prod === 'mirasim')
779
1031
  ? acc.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length + ' / ' + acc.length
780
1032
  : c.known ? fmtNum(c.sum) : '-';
781
- const hasRegions = prod !== 'zcode';
1033
+ const hasRegions = prod !== 'zcode' && prod !== 'mirasim';
782
1034
  $('region').style.display = hasRegions ? '' : 'none';
783
1035
  $('region-div').style.display = hasRegions ? '' : 'none';
784
1036
  document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
@@ -797,12 +1049,13 @@ const meter = (done, total) => '<div class="meter"><span style="width:' + (total
797
1049
  function renderDash() {
798
1050
  const s = state.status || {};
799
1051
  const sch = s.scheduler || {};
1052
+ const visibleProducts = PRODUCTS.filter((p) => compVisible(p.id));
800
1053
  const signable = state.accounts.filter((a) => CHECKIN_PRODUCTS.has(a.product));
801
1054
  const done = signable.filter(isDone).length;
802
1055
  const failed = signable.filter((a) => statusOf(a).cls === 'fail').length;
803
1056
  const credit = creditSum(state.accounts.filter((a) => a.product !== 'zcode'));
804
- const byProd = PRODUCTS.map((p) => p.label + ' ' + ofProduct(p.id).length).join(' · ');
805
- 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(' · ');
806
1059
  // 今日进度拆「国内领取 / 国际活跃」:国际版 WorkBuddy 是保持活跃(活跃不等于必有奖励)
807
1060
  const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl');
808
1061
  const domAcc = signable.filter((a) => a.provider !== 'workbuddy-intl');
@@ -820,7 +1073,8 @@ function renderDash() {
820
1073
  + kpi('下次自动领取', sch.ticking ? '领取中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
821
1074
  sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : '每 ~2 小时一轮');
822
1075
 
823
- $('prods').innerHTML = PRODUCTS.map((p) => {
1076
+ // 仪表盘产品卡按组件设置里的顺序渲染(与标签栏同步),10Router 汇总卡按其顺序插入
1077
+ const prodCardHtml = (p) => {
824
1078
  const list = ofProduct(p.id);
825
1079
  let rows = '';
826
1080
  if (!list.length) {
@@ -845,6 +1099,10 @@ function renderDash() {
845
1099
  }
846
1100
  rows += '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
847
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>';
848
1106
  } else {
849
1107
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
850
1108
  // ZCode 没有每日签到:不展示签到语义的行
@@ -859,12 +1117,18 @@ function renderDash() {
859
1117
  const zid = p.id === 'zcode' ? s.zcodeCurrentIdentity : null;
860
1118
  const live = cur ? esc(cur.name || cur.email || cur.uid)
861
1119
  : zid && (zid.email || zid.uid || zid.username) ? '未导入的账号(' + esc(zid.email || zid.username || zid.uid) + ')'
1120
+ : p.id === 'mirasim' && s.mirasimCurrentUid ? '未导入的账号'
862
1121
  : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? '未导入的账号' : '—');
863
1122
  rows += '<span class="lbl">客户端当前</span><span class="val">' + live + '</span>';
864
1123
  }
865
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
866
- + '<span class="badge">' + list.length + ' 个账号</span></span><span class="prod-go">查看 →</span></div><div class="prod-rows">' + rows + '</div></div>';
867
- }).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('');
868
1132
 
869
1133
  // 需要处理:token 失效 / 即将过期、领取失败、国际版缺风控身份、10Router 额度告急 / 查询失败
870
1134
  const att = [];
@@ -881,20 +1145,24 @@ function renderDash() {
881
1145
  if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: a.provider === 'workbuddy-intl' ? '活跃失败' : '领取失败', title: st.msg }, base));
882
1146
  }
883
1147
  for (const c of (tr.quotas && tr.quotas.connections) || []) {
1148
+ if (!compVisible('tenrouter')) break;
884
1149
  const base = { view: 'tenrouter', name: c.name || c.email || c.providerName, badges: '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>' };
885
1150
  if (c.error) att.push(Object.assign({ pill: 'fail', text: '额度查询失败', title: c.error }, base));
886
1151
  else if (c.isActive && quotaLow(c)) att.push(Object.assign({ pill: 'limited', text: '额度告急' }, base));
887
1152
  }
888
- $('att-count').textContent = att.length ? att.length + ' 项' : '';
889
- $('attention').innerHTML = att.length
890
- ? 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>'
891
1158
  + '<span class="item-side"><span class="pill ' + x.pill + '">' + esc(x.text) + '</span></span></div>').join('')
892
- + (att.length > 8 ? '<div class="muted-empty">还有 ' + (att.length - 8) + ' 项</div>' : '')
893
- : '<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>';
894
1161
 
895
1162
  // 最近领取记录
896
1163
  const RES = { 'checked-in': ['ok', '领取成功'], already: ['ok', '已领过'], limited: ['limited', '本机已领'], 'no-activity': ['pending', '无可领活动'], failed: ['fail', '失败'] };
897
- 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);
898
1166
  $('last-tick').textContent = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) : '';
899
1167
  $('recent').innerHTML = recent.length
900
1168
  ? recent.map((a) => {
@@ -922,8 +1190,13 @@ function renderStatus() {
922
1190
  // 产品相关提示只在对应标签页出现
923
1191
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
924
1192
  const n = $('notice');
925
- n.classList.toggle('info', state.view === 'zcode' || (hasIntl && Boolean(s.riskIdentity)));
926
- 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') {
927
1200
  n.style.display = '';
928
1201
  const pf = state.zProxyFirst === true;
929
1202
  const pu = state.zProxyUrl;
@@ -931,7 +1204,8 @@ function renderStatus() {
931
1204
  n.innerHTML = 'ZCode 没有每日签到,积分靠「活动领取」。打开「自动领取」后,每轮签到结束会轮询可领活动并尝试领取(桌面版静默过验证码,需要人工时弹验证码窗口);平时保持关闭即可,活动期前再开。也可点卡片上的礼物图标手动领取。'
932
1205
  + ' <button class="btn" id="btn-zac" style="margin-left:6px" title="ZCode 活动自动轮询领取(每轮签到后执行一次)">' + (ac ? '自动领取:开' : '自动领取:关') + '</button>'
933
1206
  + ' <button class="btn" id="btn-znet" style="margin-left:4px" title="ZCode 上游接口的访问顺序:失败 / 被拦时自动回退另一条">出口:' + (pf ? '代理优先' : '直连优先') + '</button>'
934
- + ' <button class="btn" id="btn-zproxy" style="margin-left:4px" title="设置 ZCode 出口用的 http 代理(留空则清除;不设置时使用 HTTPS_PROXY 环境变量)">代理:' + esc(pu || (state.zHasEnvProxy ? '环境变量' : '未设置')) + '</button>';
1207
+ + ' <button class="btn" id="btn-zproxy" style="margin-left:4px" title="设置 ZCode 出口用的 http 代理(留空则清除;不设置时使用 HTTPS_PROXY 环境变量)">代理:' + esc(pu || (state.zHasEnvProxy ? '环境变量' : '未设置')) + '</button>'
1208
+ + zcodeClientChip(s);
935
1209
  } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
936
1210
  n.style.display = '';
937
1211
  const busy = s.umid.installing || umidBusy;
@@ -950,6 +1224,24 @@ function renderStatus() {
950
1224
  }
951
1225
  }
952
1226
 
1227
+ // ZCode 客户端状态芯片 + 「打开客户端」按钮(仅桌面版:面板里有 Electron 桥才显示按钮)
1228
+ function zcodeClientChip(s) {
1229
+ const c = s.zcodeClient || {};
1230
+ if (!c.installed) return ' <span class="dim">客户端:未安装</span>';
1231
+ const canOpen = window.creditdaddy && typeof window.creditdaddy.openZcodeClient === 'function';
1232
+ return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + '</span>'
1233
+ + (canOpen ? ' <button class="btn" id="btn-zopen" style="margin-left:4px" title="打开本机 ZCode 客户端">打开客户端</button>' : '');
1234
+ }
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
+
953
1245
  // Qoder 设备身份组件(Linux / fnOS)
954
1246
  const umidOffer = (s) => Boolean(s && s.umid && s.umid.supported && !s.umid.installed);
955
1247
  let umidBusy = false;
@@ -964,6 +1256,22 @@ async function installUmid() {
964
1256
  }
965
1257
  $('notice').addEventListener('click', (e) => { if (e.target.closest('#btn-umid')) installUmid(); });
966
1258
  $('notice').addEventListener('click', async (e) => {
1259
+ if (e.target.closest('#btn-zopen')) {
1260
+ try {
1261
+ const r = await window.creditdaddy.openZcodeClient();
1262
+ if (r && r.ok) toast('已启动 ZCode 客户端');
1263
+ else toast((r && r.error) || '无法打开 ZCode 客户端', false);
1264
+ } catch (err) { toast(String(err && err.message || err), false); }
1265
+ return;
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
+ }
967
1275
  if (e.target.closest('#btn-zac')) {
968
1276
  try {
969
1277
  const r = await api('/api/zcode/net', { method: 'PUT', json: { autoClaim: state.zAutoClaim !== true } });
@@ -1040,37 +1348,11 @@ function quotaValue(q) {
1040
1348
  }
1041
1349
  const resetLabel = (q) => (q.resetAt ? fmtTime(q.resetAt).slice(5) + (q.recurring === false ? ' 到期' : ' 重置') : '');
1042
1350
 
1043
- // 相对重置标签(CLIProxyAPI 管理中心风格):「3 天 后重置」「45 分钟 后到期」;一小时内标红,悬停看绝对时间
1044
- function relReset(q) {
1045
- if (!q.resetAt) return null;
1046
- const ms = new Date(q.resetAt).getTime() - Date.now();
1047
- if (!Number.isFinite(ms)) return null;
1048
- if (ms <= 0) return { text: '即将' + (q.recurring === false ? '到期' : '重置'), soon: true };
1049
- const m = Math.ceil(ms / 60000);
1050
- const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
1051
- const dur = d > 0 ? d + ' 天' + (h ? h + ' 小时' : '') : h > 0 ? h + ' 小时' + (mm ? mm + ' 分' : '') : Math.max(1, mm) + ' 分钟';
1052
- return { text: dur + ' 后' + (q.recurring === false ? '到期' : '重置'), soon: ms < 3600e3 };
1053
- }
1054
- function relResetHtml(q) {
1055
- const r = relReset(q);
1056
- return r ? '<span class="tr-reset' + (r.soon ? ' soon' : '') + '" title="' + esc(fmtTime(q.resetAt)) + '">' + esc(r.text) + '</span>' : '';
1057
- }
1058
- // 三档着色水位条(≥70 绿 / ≥30 琥珀 / <30 红)
1059
- const meterCls = (pct) => (pct >= 70 ? 'hi' : pct >= 30 ? 'mid' : 'low');
1060
- function trMeter(pct) {
1061
- if (pct === null) return '';
1062
- const w = Math.min(100, Math.max(0, pct)).toFixed(1);
1063
- return '<div class="tr-meter"><span class="' + meterCls(pct) + '" style="width:' + w + '%"></span></div>';
1064
- }
1065
1351
  // 明细整行:名称 + 数值/相对重置 + 水位条(CLIProxyAPI quotaRow 版式)
1066
1352
  function trRow(q) {
1067
1353
  return trLine(q.name, lineVal(q)) + trMeter(quotaPct(q));
1068
1354
  }
1069
1355
 
1070
- // 紧凑行(周期额度 / 聚合积分包):名称在左、数值在右,不带进度条
1071
- function trLine(name, valHtml, title) {
1072
- 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>';
1073
- }
1074
1356
  function lineVal(q) {
1075
1357
  const pct = quotaPct(q);
1076
1358
  // 百分比口径(限时限额额度):一律显示 %,节约空间;积分类保持实际数字
@@ -1159,7 +1441,7 @@ function trCardHtml(c) {
1159
1441
  + trPacksHtml(c, packs);
1160
1442
  }
1161
1443
  }
1162
- 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">'
1163
1445
  + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1164
1446
  + (TR_REGION[c.provider] ? '<span class="badge ' + TR_REGION[c.provider][1] + '">' + TR_REGION[c.provider][0] + '</span>' : '')
1165
1447
  + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
@@ -1285,7 +1567,7 @@ function trVendorCardHtml(g) {
1285
1567
  ? '<div class="tr-summary dim"><span class="tr-sum-txt">' + briefs.join(' · ') + '</span></div>'
1286
1568
  : '');
1287
1569
  }
1288
- 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">'
1289
1571
  + '<span class="badge prod tenrouter">' + esc(g.name) + '</span>'
1290
1572
  + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + TR_REGION[prov][0] + '</span>' : '')
1291
1573
  + '<span class="badge">' + items.length + ' 个账号</span>'
@@ -1345,10 +1627,10 @@ function renderTenrouter() {
1345
1627
  else if (!list.length) grid = '<div class="empty">10Router 里还没有可查额度的供应商连接(OAuth 或支持额度查询的 API Key)。</div>';
1346
1628
  else if (activeVendor) {
1347
1629
  const g = groups.find((x) => x.name === activeVendor) || { items: [] };
1348
- 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>';
1349
1631
  } else {
1350
1632
  grid = shown.length
1351
- ? '<div class="tr-vcards">' + shown.map(trVendorCardHtml).join('') + '</div>'
1633
+ ? '<div class="tr-vcards">' + sortedVendorGroups(shown).map(trVendorCardHtml).join('') + '</div>'
1352
1634
  : '<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>';
1353
1635
  }
1354
1636
  const h = tr.health;
@@ -1385,7 +1667,7 @@ function trDashCard() {
1385
1667
  + '<span class="lbl">额度告急</span><span class="val' + (low ? ' warn' : '') + '">' + (tr.quotas ? low : '-') + '</span>'
1386
1668
  + '<span class="lbl">用量同步</span><span class="val">' + (ls ? esc(fmtTime(ls.at).slice(5)) : c.sync.enabled ? '等待首次同步' : '手动') + '</span>';
1387
1669
  return '<div class="prodcard" data-view="tenrouter"><div class="prod-head"><span class="prod-name"><span class="dot tenrouter"></span>10Router</span>'
1388
- + '<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>';
1389
1671
  }
1390
1672
 
1391
1673
  async function loadTrConfig() {
@@ -1555,19 +1837,40 @@ async function onCardClick(e) {
1555
1837
  const client = CLIENT_LABEL[a && a.product] || '客户端';
1556
1838
  const tip = a && a.product === 'zcode'
1557
1839
  ? '\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1558
- : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
1840
+ : a && a.product === 'mirasim'
1841
+ ? '\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1842
+ : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
1559
1843
  if (!(await uiConfirm('把 ' + client + ' 切换到「' + (a && a.name || id) + '」?' + tip, '切换账号', '切换'))) return;
1560
1844
  const doSwitch = (force) => api('/api/accounts/' + encodeURIComponent(id) + '/switch', { json: { force } });
1561
1845
  try {
1562
1846
  let r;
1563
1847
  try { r = await doSwitch(false); }
1564
1848
  catch (err) {
1565
- if (err.code !== 'ZCODE_RUNNING') throw err;
1566
- 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;
1567
1851
  r = await doSwitch(true);
1568
1852
  }
1569
- 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);
1570
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
+ }
1571
1874
  } catch (err) { toast(err.message, false); }
1572
1875
  return;
1573
1876
  }
@@ -1778,14 +2081,15 @@ function switchTab(name) {
1778
2081
 
1779
2082
  // 添加账号:在产品页打开时只显示该产品可用的方式
1780
2083
  const LOCAL_HINT = {
1781
- '': '读取本机 Qoder 客户端(本地解密)、WorkBuddy 客户端(当前 + 历史会话)、ZCode 客户端(本地解密)已登录的账号,token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
2084
+ '': '读取本机 Qoder 客户端(本地解密)、WorkBuddy 客户端(当前 + 历史会话)、ZCode 客户端(本地解密)、mirasim 客户端(本地解密)已登录的账号,token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
1782
2085
  qoder: '读取本机 Qoder / Qoder CN 客户端已登录的账号(本地解密 auth.v1.dat),token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
1783
2086
  workbuddy: '读取本机 WorkBuddy 客户端的当前会话与历史会话(CodeBuddyExtension 凭据目录),token 不出本机。',
1784
2087
  zcode: '读取本机 ZCode 客户端当前登录的账号(本地解密 ~/.zcode/v2/credentials.json),token 不出本机。',
2088
+ mirasim: '读取本机 mirasim 客户端当前登录的账号(本地解密 ~/.mirasim/setting.json),token 不出本机。',
1785
2089
  };
1786
2090
  function openAdd(product) {
1787
2091
  state.addProduct = product || '';
1788
- const tabShown = { local: true, device: true, token: product !== 'zcode' };
2092
+ const tabShown = { local: true, device: product !== 'mirasim', token: product !== 'zcode' && product !== 'mirasim' };
1789
2093
  document.querySelectorAll('#m-add .tab').forEach((t) => { t.style.display = tabShown[t.dataset.tab] ? '' : 'none'; });
1790
2094
  $('local-hint').textContent = LOCAL_HINT[state.addProduct];
1791
2095
  let first = null;
@@ -1833,9 +2137,9 @@ async function localScan() {
1833
2137
  }
1834
2138
  for (const e of r.errors || []) html += '<div class="hint" style="color:var(--err)">读取失败:' + esc(e.file) + ':' + esc(e.error) + '</div>';
1835
2139
  if (!cands.length) {
1836
- const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode';
1837
- const alt = state.addProduct === 'zcode' ? ',或改用「浏览器登录」' : ',或改用「浏览器登录」「粘贴 Token」';
1838
- 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 数据目录'
1839
2143
  + (r.scannedDirs && r.scannedDirs.length ? '、' + r.scannedDirs.map(esc).join('、') : '') + '</div>';
1840
2144
  }
1841
2145
  box.innerHTML = html;
@@ -1977,6 +2281,219 @@ async function submitImport(password) {
1977
2281
  }
1978
2282
 
1979
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
+
1980
2497
  $('btn-checkin-all').innerHTML = I.play + '全部领取';
1981
2498
  for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + '添加账号';
1982
2499
  for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + '导入'; $(id).onclick = pickImport; }
@@ -2002,6 +2519,8 @@ function applyView() {
2002
2519
  const raw = location.hash.slice(1);
2003
2520
  const [v, sub] = raw.split('/');
2004
2521
  state.view = VIEWS.includes(v) ? v : 'dash';
2522
+ // 被组件开关收起的页面回仪表盘
2523
+ if (state.view !== 'dash' && !compVisible(state.view)) state.view = 'dash';
2005
2524
  state.region = '';
2006
2525
  // 10Router 供应商标签可用 hash 直达:#tenrouter/<供应商名>
2007
2526
  state.trVendor = state.view === 'tenrouter' && sub ? decodeURIComponent(sub) : '';