creditdaddy 0.9.5 → 1.2.0

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,13 +43,23 @@
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); }
46
+ /* 面板设置(右上角齿轮 → 弹窗):访问密码 + 组件显隐与排序 + 账号卡片排序 */
47
+ .comp-menu { display: flex; flex-direction: column; gap: 8px; }
48
+ .comp-menu-title { font-size: 11px; color: var(--text-3); margin: 10px 0 4px; padding-top: 8px; border-top: 1px dashed var(--border-subtle); }
49
+ .comp-menu-title:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
51
50
  .comp-menu .check { margin-right: 0; font-size: 12.5px; color: var(--text); }
52
- #comp-menu { max-height: 72vh; overflow: auto; min-width: 236px; }
51
+ /* 产品页 notice 的功能分区行 */
52
+ .z-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
53
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); opacity: .45; flex: none; }
54
+ .dot.on { background: var(--ok); opacity: 1; box-shadow: 0 0 5px var(--ok); }
55
+ .dot.pulse { animation: dot-pulse 2.2s ease-out infinite; }
56
+ @keyframes dot-pulse { 0% { box-shadow: 0 0 0 0 rgba(46, 229, 157, .45); } 70% { box-shadow: 0 0 0 5px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
57
+ .z-row + .z-row { margin-top: 7px; padding-top: 7px; border-top: 1px dashed var(--border-subtle); }
58
+ .z-label { font-size: 11px; color: var(--text-3); flex: none; width: 68px; }
59
+ .z-text { flex: 1 1 320px; min-width: 0; }
60
+ /* 10Router 状态条分两行 */
61
+ .sec-status { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--text-3); }
62
+ .sec-status.on { color: var(--ok); border-color: rgba(var(--accent-rgb), .45); }
53
63
  .comp-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
54
64
  .comp-row.off .check { opacity: .45; }
55
65
  .comp-ops { display: inline-flex; gap: 2px; flex: none; }
@@ -95,9 +105,9 @@
95
105
  .notice { margin: 12px 16px 0; padding: 9px 12px; border-radius: 10px; background: var(--warn-soft); color: var(--warn); font-size: 12px; line-height: 1.6; }
96
106
 
97
107
  /* cards */
98
- .list { display: grid; grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 4px 10px; padding: 10px; }
99
- .card { position: relative; padding: 11px 12px 12px; border-radius: 12px; transition: background .12s; min-width: 0; }
100
- .card:hover { background: var(--hover); }
108
+ .list { display: grid; grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 10px; padding: 10px; }
109
+ .card { position: relative; padding: 11px 12px 12px; border-radius: 12px; background: var(--surface-2); border: 1px solid var(--border-subtle); transition: background .12s, border-color .12s; min-width: 0; }
110
+ .card:hover { background: var(--hover); border-color: var(--border-strong); }
101
111
  .row1 { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 28px; margin-bottom: 7px; }
102
112
  .name-group { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
103
113
  .name { font-size: 14px; font-weight: 600; cursor: text; border-radius: 5px; padding: 0 2px; margin: 0 -2px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -112,6 +122,10 @@
112
122
  html[data-theme="dark"] .badge.prod.workbuddy { color: #5eead4; }
113
123
  .badge.prod.zcode { color: #1d4ed8; background: rgba(37,99,235,.1); }
114
124
  html[data-theme="dark"] .badge.prod.zcode { color: #93c5fd; }
125
+ .badge.prod.catpaw { color: #c2410c; background: rgba(249,115,22,.14); }
126
+ html[data-theme="dark"] .badge.prod.catpaw { color: #fdba74; }
127
+ .badge.prod.trae { color: #047857; background: rgba(16,185,129,.14); }
128
+ html[data-theme="dark"] .badge.prod.trae { color: #6ee7b7; }
115
129
  .seg-ctl { display: inline-flex; gap: 2px; padding: 2px; border-radius: 9px; background: var(--hover); }
116
130
  .seg-ctl button { border: none; background: transparent; color: var(--text-2); font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 7px; cursor: pointer; }
117
131
  .seg-ctl button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
@@ -124,6 +138,8 @@
124
138
  .pill.fail { color: var(--err); border-color: var(--err-soft); }
125
139
  .pill.cur { color: #0f766e; border-color: rgba(20,184,166,.5); }
126
140
  html[data-theme="dark"] .pill.cur { color: #5eead4; }
141
+ .pill.gw { color: #4338ca; border-color: rgba(99,102,241,.45); }
142
+ html[data-theme="dark"] .pill.gw { color: #a5b4fc; }
127
143
  .ops { display: flex; gap: 6px; flex-shrink: 0; }
128
144
 
129
145
  .meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; font-size: 12px; }
@@ -144,16 +160,24 @@
144
160
  #cap-holder { margin-top: 10px; min-height: 0; }
145
161
  #cap-btn { margin-top: 10px; }
146
162
  .bar { display: flex; gap: 2px; width: 100%; height: 5px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); border-radius: 3px; overflow: hidden; }
147
- .seg { display: block; height: 5px; min-width: 3px; background: rgba(var(--accent-rgb), var(--a, 1)); transition: filter .15s; }
163
+ .seg { display: block; height: 5px; min-width: 3px; background: var(--ok); opacity: var(--a, 1); transition: filter .15s; }
148
164
  .seg.used { background: color-mix(in srgb, var(--text-3) 35%, transparent); }
149
165
  .seg:hover { filter: brightness(1.12); }
150
166
  .msg { margin-top: 7px; font-size: 11px; color: var(--text-3); line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
151
167
  .msg.err { color: var(--err); }
152
168
  .empty { grid-column: 1 / -1; text-align: center; color: var(--text-3); padding: 42px 12px; font-size: 13px; line-height: 1.9; }
169
+ .gw-line { text-align: center; color: var(--text-3); padding: 14px 16px 4px; font-size: 13px; line-height: 1.9; }
170
+ .kpi-label[title], .stat span[title] { cursor: help; }
153
171
 
154
172
  /* section card */
155
173
  .pcard { margin-top: 14px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 12px 14px; }
156
174
  .pcard-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; font-weight: 700; }
175
+ .fold-head { cursor: pointer; user-select: none; }
176
+ .fold-side { display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); }
177
+ .fold-ico { display: inline-flex; }
178
+ .fold-ico svg { width: 14px; height: 14px; }
179
+ .pcard .fold-body { display: none; margin-top: 4px; }
180
+ .pcard.open .fold-body { display: block; }
157
181
  .logs { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.65; max-height: 240px; overflow-y: auto; background: var(--tertiary); border-radius: 9px; padding: 8px 10px; }
158
182
  .logs .info { color: var(--text-2); } .logs .warn { color: var(--warn); } .logs .error { color: var(--err); } .logs .debug { color: var(--text-3); }
159
183
  .collapsed .logs { display: none; }
@@ -181,8 +205,9 @@
181
205
  .hint { font-size: 12px; color: var(--text-3); line-height: 1.7; }
182
206
  .hint a { color: #4f86ff; word-break: break-all; }
183
207
  .box { border: 1px solid var(--border-subtle); border-radius: 10px; padding: 10px 12px; margin-top: 10px; background: var(--surface-2); }
184
- .cand { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-subtle); }
185
- .cand:last-child { border-bottom: none; }
208
+ .cand { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); }
209
+ .cand + .cand { margin-top: 8px; }
210
+ .cand .row { flex: none; flex-wrap: nowrap; gap: 6px; }
186
211
  .cand-main { min-width: 0; }
187
212
  .cand-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
188
213
  .cand-sub { font-size: 11px; color: var(--text-3); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -200,8 +225,15 @@
200
225
  .view { display: none; } .view.active { display: block; }
201
226
 
202
227
  /* 仪表盘 */
203
- .dash-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
204
- .dash-title { font-size: 15px; font-weight: 700; }
228
+ .dash-panel { margin-bottom: 12px; }
229
+ .dash-title { font-size: 13px; font-weight: 700; }
230
+ .dash-panel .toolbar { padding: 8px 16px; }
231
+ .dash-panel .kpis { display: flex; gap: 0; margin: 0; padding: 0 10px 8px; }
232
+ .dash-panel .kpi { background: none; border: none; border-radius: 0; padding: 6px 14px; flex: 1 1 0; min-width: 0; }
233
+ .dash-panel .kpi-value { font-size: 22px; margin-top: 2px; }
234
+ .dash-panel .kpi-sub { margin-top: 2px; }
235
+ .dash-panel .kpi + .kpi { border-left: 1px dashed var(--border-subtle); }
236
+ @media (max-width: 900px) { .dash-panel .kpis { flex-wrap: wrap; } .dash-panel .kpi { flex: 1 1 40%; } .dash-panel .kpi + .kpi { border-left: none; } }
205
237
  .kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
206
238
  .kpi { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 14px 16px; min-width: 0; }
207
239
  .kpi-label { color: var(--text-3); font-size: 12px; font-weight: 500; }
@@ -215,7 +247,7 @@
215
247
  .prod-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
216
248
  .prod-name { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 700; }
217
249
  .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
218
- .dot.qoder { background: #7c3aed; } .dot.workbuddy { background: #14b8a6; } .dot.zcode { background: #2563eb; }
250
+ .dot.qoder { background: #7c3aed; } .dot.workbuddy { background: #14b8a6; } .dot.zcode { background: #2563eb; } .dot.mirasim { background: #64748b; } .dot.catpaw { background: #f97316; } .dot.trae { background: #10b981; }
219
251
  .prod-go { color: var(--text-3); font-size: 12px; white-space: nowrap; display: inline-flex; align-items: center; gap: 2px; }
220
252
  .prod-go svg { width: 12px; height: 12px; transition: transform .15s; }
221
253
  .prodcard:hover .prod-go { color: var(--text); }
@@ -223,7 +255,7 @@
223
255
  .prod-rows { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 7px 12px; font-size: 12px; align-items: center; }
224
256
  .prod-rows .val { text-align: right; }
225
257
  .meter { height: 5px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); border-radius: 3px; overflow: hidden; }
226
- .meter span { display: block; height: 100%; background: rgb(var(--accent-rgb)); border-radius: 3px; }
258
+ .meter span { display: block; height: 100%; background: var(--ok); border-radius: 3px; }
227
259
  .dash-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
228
260
  .dash-cols .pcard { margin-top: 0; min-width: 0; }
229
261
  .item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 4px; margin: 0 -4px; border-bottom: 1px solid var(--border-subtle); font-size: 12px; cursor: pointer; border-radius: 6px; }
@@ -247,10 +279,11 @@
247
279
  .tr-vrow { }
248
280
  .tr-vhead { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
249
281
  .tr-vhead .dim { font-size: 12px; }
250
- .tr-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 2px 12px 4px; }
282
+ .tr-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 12px 4px; }
251
283
  .tr-vcards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; align-items: stretch; }
252
284
  @media (max-width: 1180px) { .tr-vcards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
253
285
  @media (max-width: 820px) { .tr-vcards { grid-template-columns: 1fr; } }
286
+ .tr-card.flat { align-self: start; }
254
287
  .tr-card { border: 1px solid var(--border-subtle); border-radius: 12px; padding: 11px 12px 12px; background: var(--surface); min-width: 0; }
255
288
  .tr-card.off { opacity: .6; }
256
289
  .tr-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
@@ -262,7 +295,7 @@
262
295
  .tr-q-val { font-variant-numeric: tabular-nums; white-space: nowrap; }
263
296
  .tr-q-val.low { color: var(--err); font-weight: 600; }
264
297
  .tr-meter { height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); overflow: hidden; }
265
- .tr-meter span { display: block; height: 100%; border-radius: 3px; background: rgb(var(--accent-rgb)); }
298
+ .tr-meter span { display: block; height: 100%; border-radius: 3px; background: var(--ok); }
266
299
  .tr-meter span.low { background: var(--err); }
267
300
  .tr-meter span.mid { background: #d97706; }
268
301
  .tr-reset { color: var(--text-3); font-weight: 400; }
@@ -290,7 +323,6 @@
290
323
  .tr-packs-head { font-size: 11px; color: var(--text-3); margin-bottom: 2px; }
291
324
  .tr-packs-head b { color: var(--text-2); }
292
325
  .tr-more { margin-top: 7px; border: none; background: none; color: #4f86ff; font-size: 12px; cursor: pointer; padding: 0; }
293
- .tr-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); font-size: 12px; color: var(--text-2); }
294
326
  .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; font-size: 11px; background: var(--tertiary); color: var(--text-3); }
295
327
  .chip.on { background: rgba(var(--accent-rgb), .14); color: var(--text); }
296
328
  .setup { padding: 22px; max-width: 620px; }
@@ -317,10 +349,7 @@
317
349
  </div>
318
350
  <div class="head-right">
319
351
  <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>
352
+ <button class="icon-btn" id="btn-comp" title="面板设置:访问密码、组件显隐与排序、账号卡片排序"></button>
324
353
  <a class="icon-btn" id="btn-home" href="https://github.com/techysy/CreditDaddy" target="_blank" rel="noopener" title="项目主页(GitHub)"></a>
325
354
  <button class="icon-btn" id="btn-theme" title="切换亮色 / 暗色"></button>
326
355
  </div>
@@ -332,25 +361,29 @@
332
361
  <button data-view="workbuddy">WorkBuddy<span class="cnt" id="cnt-workbuddy">0</span></button>
333
362
  <button data-view="zcode">ZCode<span class="cnt" id="cnt-zcode">0</span></button>
334
363
  <button data-view="mirasim">mirasim<span class="cnt" id="cnt-mirasim">0</span></button>
364
+ <button data-view="catpaw">妙手<span class="cnt" id="cnt-catpaw">0</span></button>
365
+ <button data-view="trae">Trae<span class="cnt" id="cnt-trae">0</span></button>
335
366
  <button data-view="tenrouter">10Router<span class="cnt" id="cnt-tenrouter">–</span></button>
336
367
  </nav>
337
368
 
338
369
  <!-- 仪表盘 -->
339
370
  <section class="view active" id="view-dash">
340
- <div class="dash-head">
341
- <div class="dash-title">总览</div>
342
- <div class="actions">
343
- <button class="btn primary" id="btn-checkin-all"></button>
344
- <button class="btn" id="btn-add-dash"></button>
345
- <button class="btn" id="btn-import-dash"></button>
346
- <button class="btn" id="btn-export-dash"></button>
371
+ <div class="panel dash-panel">
372
+ <div class="toolbar">
373
+ <div class="summary"><span class="dash-title">总览</span></div>
374
+ <div class="actions">
375
+ <button class="btn primary" id="btn-checkin-all"></button>
376
+ <button class="btn" id="btn-add-dash"></button>
377
+ <button class="btn" id="btn-import-dash"></button>
378
+ <button class="btn" id="btn-export-dash"></button>
379
+ </div>
347
380
  </div>
381
+ <div class="kpis" id="kpis"></div>
348
382
  </div>
349
- <div class="kpis" id="kpis"></div>
350
383
  <div class="prods" id="prods"></div>
351
384
  <div class="dash-cols">
352
- <section class="pcard"><div class="pcard-title"><span>最近记录</span><span class="hint" id="last-tick"></span></div><div id="recent"></div></section>
353
- <section class="pcard"><div class="pcard-title"><span>需要处理</span><span class="hint" id="att-count"></span></div><div id="attention"></div></section>
385
+ <section class="pcard" data-fold="recent"><div class="pcard-title fold-head"><span>最近记录</span><span class="fold-side"><span class="hint" id="last-tick"></span><span class="fold-ico"></span></span></div><div class="fold-body"><div id="recent"></div></div></section>
386
+ <section class="pcard" data-fold="attention"><div class="pcard-title fold-head"><span>需要处理</span><span class="fold-side"><span class="hint" id="att-count"></span><span class="fold-ico"></span></span></div><div class="fold-body"><div id="attention"></div></div></section>
354
387
  </div>
355
388
  </section>
356
389
 
@@ -373,6 +406,15 @@
373
406
  </div>
374
407
  <div class="actions">
375
408
  <button class="btn primary" id="btn-checkin"></button>
409
+ <span id="z-actions" style="display:none">
410
+ <button class="btn" id="zt-ac"></button>
411
+ <button class="btn" id="zt-net"></button>
412
+ <button class="btn" id="zt-gw"></button>
413
+ <button class="btn" id="zt-open" style="display:none"></button>
414
+ </span>
415
+ <span id="t-actions" style="display:none">
416
+ <button class="btn" id="tt-open" style="display:none"></button>
417
+ </span>
376
418
  <button class="btn" id="btn-add"></button>
377
419
  <button class="btn" id="btn-import"></button>
378
420
  <button class="btn" id="btn-export"></button>
@@ -448,13 +490,13 @@
448
490
  <div class="modal-title"><span>导出账号</span><button class="close" data-close>✕</button></div>
449
491
  <div class="field">
450
492
  <label>范围</label>
451
- <select class="input" id="ex-provider"><option value="">全部账号</option><option value="p:qoder">Qoder(全部)</option><option value="qoder">仅 Qoder 国际版</option><option value="qoder-cn">仅 Qoder 国内版</option><option value="p:workbuddy">WorkBuddy(全部)</option><option value="workbuddy">仅 WorkBuddy 国内版</option><option value="workbuddy-intl">仅 WorkBuddy 国际版</option><option value="zcode">ZCode</option></select>
493
+ <select class="input" id="ex-provider"><option value="">全部账号</option><option value="p:qoder">Qoder(全部)</option><option value="qoder">仅 Qoder 国际版</option><option value="qoder-cn">仅 Qoder 国内版</option><option value="p:workbuddy">WorkBuddy(全部)</option><option value="workbuddy">仅 WorkBuddy 国内版</option><option value="workbuddy-intl">仅 WorkBuddy 国际版</option><option value="zcode">ZCode</option><option value="p:mirasim">mirasim</option><option value="p:catpaw">妙手</option><option value="p:trae">Trae</option></select>
452
494
  </div>
453
495
  <div class="field">
454
- <label>加密口令(可选)</label>
455
- <input class="input" id="ex-password" type="password" placeholder="至少 4 位;留空则导出明文">
496
+ <label>加密口令(必填)</label>
497
+ <input class="input" id="ex-password" type="password" placeholder="至少 4 位" autocomplete="new-password">
456
498
  </div>
457
- <div class="hint">设置口令后文件采用 10router 迁移格式加密(scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。</div>
499
+ <div class="hint">导出文件一律加密(10router 迁移格式,scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。<b>口令不会被保存</b>,请牢记——遗失后文件无法解开、无法导入。</div>
458
500
  <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-do-export">导出</button></div>
459
501
  </div>
460
502
  </div>
@@ -469,17 +511,38 @@
469
511
  </div>
470
512
  </div>
471
513
 
514
+ <!-- 面板设置(右上角齿轮) -->
515
+ <div class="mask" id="m-settings">
516
+ <div class="modal">
517
+ <div class="modal-title"><span>面板设置</span><button class="close" data-close>✕</button></div>
518
+ <div class="comp-menu" id="comp-menu"></div>
519
+ <div class="field" id="upd-entry" style="display:none;margin-top:12px">
520
+ <label>版本与更新</label>
521
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><span class="dim" id="upd-ver"></span><button class="btn" id="btn-update">检查更新</button></div>
522
+ </div>
523
+ <div class="modal-actions"><button class="btn" data-close>关闭</button></div>
524
+ </div>
525
+ </div>
526
+
527
+ <!-- 检查更新(桌面版):状态由主进程 update:state 推送 -->
528
+ <div class="mask" id="m-update">
529
+ <div class="modal sm">
530
+ <div class="modal-title"><span>检查更新</span><button class="close" data-close>✕</button></div>
531
+ <div id="upd-body"></div>
532
+ <div class="modal-actions" id="upd-actions"></div>
533
+ </div>
534
+ </div>
535
+
472
536
  <!-- 10Router 设置 -->
473
537
  <div class="mask" id="m-10r">
474
538
  <div class="modal">
475
539
  <div class="modal-title"><span>10Router 连接设置</span><button class="close" data-close>✕</button></div>
476
540
  <div class="hint">填 10Router 的访问地址和仪表盘里创建的<b>虚拟 key(sk-…)</b>。key 只保存在 CreditDaddy 守护进程的数据目录(0600),面板里只显示脱敏值。</div>
477
- <div class="field" style="margin-top:12px"><label>10Router 地址</label><input class="input mono" id="tr-endpoint" placeholder="http://127.0.0.1:20128 或 http://nas:20128"></div>
541
+ <div class="field" style="margin-top:12px"><label>10Router 地址</label><input class="input mono" id="tr-endpoint" placeholder="http://127.0.0.1:20128(站点根地址,不要带 /v1)"></div>
478
542
  <div class="field"><label>虚拟 key</label><input class="input mono" id="tr-key" placeholder="sk-…" autocomplete="off"></div>
479
- <div class="field"><label>用量同步(同 10router-sync 插件)</label>
480
- <div><label class="check"><input type="checkbox" id="tr-auto"> 每小时自动同步本机用量到 10Router</label></div>
481
- <div id="tr-sources" style="margin-top:6px"></div>
482
- <div class="hint" style="margin-top:4px">ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。10Router 按行签名去重,重复同步不会产生重复数据。</div>
543
+ <div class="field"><label>10Router 面板密码(可选)</label><input class="input mono" id="tr-admin-pw" type="password" placeholder="配置后「同步账号」走 OAuth 通道(带 refreshToken 自动续期);留空保持不变" autocomplete="new-password"></div>
544
+ <div class="field"><label>账号同步</label>
545
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="btn" id="tr-sync-accounts">同步账号到 10R</button><span class="hint">把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接;zcode 待反代就绪后支持</span></div>
483
546
  </div>
484
547
  <div class="field"><label>显示</label>
485
548
  <div><label class="check"><input type="checkbox" id="tr-hide-empty"> 「全部」视图隐藏没有额度 / 余额为 0 的供应商汇总卡</label></div>
@@ -493,6 +556,18 @@
493
556
  </div>
494
557
  </div>
495
558
 
559
+ <!-- 10Router 用量同步 -->
560
+ <div class="mask" id="m-trsync">
561
+ <div class="modal sm">
562
+ <div class="modal-title"><span>用量同步(同 10router-sync 插件)</span><button class="close" data-close>✕</button></div>
563
+ <div class="field"><label class="check"><input type="checkbox" id="tr-auto"> 每小时自动同步本机用量到 10Router</label></div>
564
+ <div class="field"><label>同步来源</label><div id="tr-sources"></div></div>
565
+ <div class="hint">ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。「10Router 本机」只把实例原生产生的行标记为网关同步。10Router 按行签名去重,重复同步不会产生重复数据。</div>
566
+ <div class="hint" id="tr-sync-status" style="margin-top:8px"></div>
567
+ <div class="modal-actions"><button class="btn" style="margin-right:auto" id="tr-sync-now">立即同步一次</button><button class="btn" data-close>取消</button><button class="btn primary" id="tr-sync-save">保存</button></div>
568
+ </div>
569
+ </div>
570
+
496
571
  <!-- ZCode 活动领取 -->
497
572
  <div class="mask" id="m-claim">
498
573
  <div class="modal">
@@ -512,6 +587,59 @@
512
587
  </div>
513
588
  </div>
514
589
 
590
+ <!-- ZCode 自动领取计划 -->
591
+ <div class="mask" id="m-zac">
592
+ <div class="modal sm">
593
+ <div class="modal-title"><span>ZCode 自动领取计划</span><button class="close" data-close>✕</button></div>
594
+ <div class="field">
595
+ <label>轮询周期(分钟,1–720)</label>
596
+ <input class="input" id="zac-interval" type="number" min="1" max="720" step="1">
597
+ </div>
598
+ <div class="field">
599
+ <label>单次运行时长(分钟,0 = 一直运行直到手动关闭)</label>
600
+ <input class="input" id="zac-window" type="number" min="0" max="1440" step="5">
601
+ </div>
602
+ <div class="hint">开启后按此周期检查活动资格并自动领取(首次检查在开启 2 分钟后开始)。活动包由服务端在每日 0 点左右刷新放出——若运行时段没有覆盖 0 点,会错过当次领取,建议把运行时长设为 0,或让时段跨过 0 点。需要验证码时无法自动通过,卡片上会提示手动领取。</div>
603
+ <div class="modal-actions"><button class="btn" id="zac-off" style="display:none">停用</button><button class="btn" data-close>取消</button><button class="btn primary" id="zac-ok">保存并启用</button></div>
604
+ </div>
605
+ </div>
606
+
607
+ <!-- ZCode 网络出口 -->
608
+ <div class="mask" id="m-znet">
609
+ <div class="modal sm">
610
+ <div class="modal-title"><span>ZCode 网络出口</span><button class="close" data-close>✕</button></div>
611
+ <div class="field">
612
+ <label>上游接口访问顺序(失败 / 被拦时自动回退另一条)</label>
613
+ <label class="check"><input type="radio" name="znet-order" id="znet-direct"> 直连优先(代理兜底)</label>
614
+ <label class="check"><input type="radio" name="znet-order" id="znet-proxy"> 代理优先(直连兜底)</label>
615
+ </div>
616
+ <div class="field">
617
+ <label>出口代理(http://host:port,支持口令 http://user:pass@host:port)</label>
618
+ <input class="input" id="znet-url" placeholder="http://192.168.31.101:7890">
619
+ </div>
620
+ <div class="hint">留空 = 清除已保存的代理,退回 HTTPS_PROXY 环境变量;取消 = 不改动。当前代理只显示脱敏后的地址。</div>
621
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="znet-ok">保存</button></div>
622
+ </div>
623
+ </div>
624
+
625
+ <!-- ZCode 体验包接口 -->
626
+ <div class="mask" id="m-zgw">
627
+ <div class="modal sm">
628
+ <div class="modal-title"><span>ZCode 体验包接口</span><button class="close" data-close>✕</button></div>
629
+ <div class="hint" id="zgw-url" style="margin-bottom:10px"></div>
630
+ <div class="field">
631
+ <label class="check"><input type="checkbox" id="zgw-on"> 启用体验包接口(/gateway/v1/messages)</label>
632
+ </div>
633
+ <div class="field" id="zgw-lan-fields">
634
+ <label class="check"><input type="checkbox" id="zgw-lan"> 对局域网开放(重启后绑定 0.0.0.0)</label>
635
+ <label>IP 白名单(每行一个,支持 192.168.31.* 通配)</label>
636
+ <textarea class="input" id="zgw-allow" rows="4" placeholder="192.168.31.101"></textarea>
637
+ </div>
638
+ <div class="hint">开启后,领到的体验包额度变成本机的一个补全接口——10Router 建一个 anthropic-compatible 节点指向本机 /gateway 即可调用(需要桌面版,隐藏窗口过验证码)。白名单内的机器<b>免密直连</b>:10Router 的 zcode-free 本就是免授权供应商,把 10Router 服务地址的 IP 加白即可;白名单外的请求一律拒绝。本机回环(127.0.0.1)始终放行。</div>
639
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="zgw-ok">保存</button></div>
640
+ </div>
641
+ </div>
642
+
515
643
  <input type="file" id="file-input" accept=".json,application/json" style="display:none">
516
644
  <div id="toast"></div>
517
645
 
@@ -542,7 +670,7 @@ const I = {
542
670
 
543
671
  const $ = (id) => document.getElementById(id);
544
672
  const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '' };
545
- const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'mirasim', 'tenrouter'];
673
+ const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'tenrouter'];
546
674
  // 10Router 集成:config 来自 /api/tenrouter,quotas 来自 /api/tenrouter/quotas
547
675
  const tr = { config: null, quotas: null, error: null, loading: false, syncing: false };
548
676
  const PRODUCTS = [
@@ -550,6 +678,8 @@ const PRODUCTS = [
550
678
  { id: 'workbuddy', label: 'WorkBuddy' },
551
679
  { id: 'zcode', label: 'ZCode' },
552
680
  { id: 'mirasim', label: 'mirasim' },
681
+ { id: 'catpaw', label: '妙手' },
682
+ { id: 'trae', label: 'Trae' },
553
683
  ];
554
684
  const prodLabel = (id) => (PRODUCTS.find((x) => x.id === id) || { label: id }).label;
555
685
 
@@ -559,16 +689,25 @@ const COMPONENTS = [
559
689
  { id: 'workbuddy', label: 'WorkBuddy' },
560
690
  { id: 'zcode', label: 'ZCode' },
561
691
  { id: 'mirasim', label: 'mirasim' },
692
+ { id: 'catpaw', label: '妙手' },
693
+ { id: 'trae', label: 'Trae' },
562
694
  { id: 'tenrouter', label: '10Router' },
563
695
  ];
564
696
  const HIDDEN_COMP_KEY = 'qd_hidden_components';
697
+ const DEFAULT_HIDDEN_COMP = ['catpaw']; // 妙手默认收起,右上角设置里可打开
565
698
  state.hidden = (() => {
699
+ let list = DEFAULT_HIDDEN_COMP;
700
+ const raw = localStorage.getItem(HIDDEN_COMP_KEY);
701
+ if (raw !== null) { try { list = JSON.parse(raw) || []; } catch { list = []; } }
702
+ // 一次性迁移:旧偏好是在「妙手默认隐藏」之前存的,补上默认值;此后完全尊重手动选择
703
+ if (raw !== null && localStorage.getItem('qd_hidden_comp_migrated') !== '1') {
704
+ list = [...new Set([...list, ...DEFAULT_HIDDEN_COMP])];
705
+ try { localStorage.setItem('qd_hidden_comp_migrated', '1'); } catch {}
706
+ }
566
707
  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 {}
708
+ for (const v of list) {
709
+ if (COMPONENTS.some((c) => c.id === v)) set.add(v);
710
+ }
572
711
  return set;
573
712
  })();
574
713
  const compVisible = (id) => !state.hidden.has(id);
@@ -671,10 +810,14 @@ const PROV = {
671
810
  'workbuddy-intl': { prod: 'workbuddy', prodLabel: 'WorkBuddy', region: '国际版', cls: 'intl' },
672
811
  zcode: { prod: 'zcode', prodLabel: 'ZCode', region: '', cls: '' },
673
812
  mirasim: { prod: 'mirasim', prodLabel: 'mirasim', region: '', cls: '' },
813
+ catpaw: { prod: 'catpaw', prodLabel: '妙手', region: '', cls: '' },
814
+ trae: { prod: 'trae', prodLabel: 'Trae', region: '', cls: '' },
674
815
  };
675
- // 支持每日签到的产品(ZCode / mirasim 额度靠活动或滚动窗口,不做签到)
676
- const CHECKIN_PRODUCTS = new Set(['qoder', 'workbuddy']);
677
- const CLIENT_LABEL = { workbuddy: 'WorkBuddy', zcode: 'ZCode', mirasim: 'mirasim' };
816
+ // 支持每日签到的产品(ZCode / mirasim / 妙手 额度靠套餐或滚动窗口发放,不做签到)
817
+ const CHECKIN_PRODUCTS = new Set(['qoder', 'workbuddy', 'trae']);
818
+ const CLIENT_LABEL = { qoder: 'Qoder', workbuddy: 'WorkBuddy', zcode: 'ZCode', mirasim: 'mirasim', catpaw: '妙手', trae: 'Trae' };
819
+ // 额度单位为 Credits/时长而非「积分」口径的产品(不计入剩余积分合计)
820
+ const CREDIT_VIEWS = new Set(['zcode', 'mirasim', 'catpaw']);
678
821
  const provOf = (p) => PROV[p] || { prod: 'qoder', prodLabel: p, region: '', cls: '' };
679
822
  const provBadges = (p, regionOnly, flavor) => {
680
823
  const v = provOf(p);
@@ -720,8 +863,8 @@ function toast(msg, ok) {
720
863
  }
721
864
 
722
865
  // ── API ──
723
- // 面板访问密码(fnOS / 设置了 CREDITDADDY_PASSWORD 时需要)。内存里的值优先:面板嵌在飞牛桌面窗口里是跨域 iframe,
724
- // 浏览器拦截第三方存储时 localStorage 不可用,只靠它会每个请求都 401
866
+ // 面板访问密码(在「设置」里开启,或 fnOS / CREDITDADDY_PASSWORD 环境变量注入时需要)。内存里的值优先:
867
+ // 面板嵌在飞牛桌面窗口里是跨域 iframe,浏览器拦截第三方存储时 localStorage 不可用,只靠它会每个请求都 401
725
868
  let memKey = null;
726
869
  function getKey() {
727
870
  if (memKey !== null) return memKey;
@@ -738,7 +881,8 @@ function promptKey(invalid) {
738
881
  keyPrompt = uiPrompt('输入面板访问密码', '', {
739
882
  type: 'password', okText: '进入',
740
883
  message: (invalid ? '密码不正确,请重新输入。\n' : '')
741
- + 'fnOS:安装时设置的面板访问密码(忘了可以在 fnOS「应用设置」里重设)。\n命令行部署:环境变量 CREDITDADDY_PASSWORD 的值。',
884
+ + '面板访问密码在右上角「设置」里管理。\n'
885
+ + 'fnOS:安装向导设置的密码(忘了可在 fnOS「应用设置」里重设)。\n命令行部署:环境变量 CREDITDADDY_PASSWORD 的值。',
742
886
  }).then((k) => { keyPrompt = null; if (k === null || !k.trim()) return false; setKey(k.trim()); return true; });
743
887
  }
744
888
  return keyPrompt;
@@ -766,6 +910,16 @@ async function api(p, opts = {}, attempt = 0) {
766
910
  // 10Router 汇总卡「隐藏无额度」偏好(面板本地)
767
911
  try { state.trHideEmpty = localStorage.getItem('qd_tr_hideempty') === '1'; } catch (e) {}
768
912
 
913
+ // 面板访问密码状态(/api/settings)。boot 后异步拉取,供设置弹窗显示
914
+ state.sec = { enabled: false, loaded: false };
915
+ async function loadSettings() {
916
+ try {
917
+ const d = await api('/api/settings');
918
+ state.sec = { enabled: Boolean(d.panelKeyEnabled), loaded: true };
919
+ if (!d.panelKeyEnabled) setKey(''); // 关闭后清掉本地缓存的旧密钥
920
+ } catch (e) { /* 401(密码开启但未输入)等状态由 promptKey 流程处理 */ }
921
+ }
922
+
769
923
  // ── 主题 ──
770
924
  function applyTheme(t) {
771
925
  document.documentElement.dataset.theme = t;
@@ -870,6 +1024,15 @@ function creditHtml(a) {
870
1024
  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>';
871
1025
  if (q.empty) return '<div class="credit-line"><span class="dim">无有效套餐(仅免费额度)</span></div><div class="bar"></div>';
872
1026
 
1027
+ // 妙手:Credits 钱包余额 + 当前版本(体验版 / 专业版),无「总量」口径,不画水位条
1028
+ if (a.product === 'catpaw') {
1029
+ const planBadge = q.plan ? '<span class="badge">' + esc(q.plan) + '</span>' : '';
1030
+ const expHtml = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + ' 到期</span>'
1031
+ : q.nextRefreshAt ? '<span class="dim">' + fmtTime(q.nextRefreshAt).slice(0, 10) + ' 下次刷新</span>' : '';
1032
+ return '<div class="credit-line"><span class="credit"><span class="lbl">可用额度</span><b>' + fmtNum(q.remaining) + '</b>Credits</span>'
1033
+ + '<span class="credit dim">' + planBadge + (planBadge && expHtml ? ' · ' : '') + expHtml + '</span></div>';
1034
+ }
1035
+
873
1036
  // 多窗口配额体系(mirasim 等):每个窗口平等等权,独立一行带分散对齐的名称、数值与三档细水位条
874
1037
  if (a.product === 'mirasim' && q.parts && q.parts.length) {
875
1038
  const isExhausted = q.exceeded || (q.remaining <= 0);
@@ -940,13 +1103,14 @@ function cardHtml(a) {
940
1103
  + '<span class="name" data-act="rename" title="点击修改备注名">' + esc(a.name || '(未命名)') + '</span>'
941
1104
  + provBadges(a.provider, true, a.flavor)
942
1105
  + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] || '') + ' 客户端当前登录的账号">客户端当前</span>' : '')
1106
+ + (a.product === 'zcode' && isGatewayCurrent(a) ? '<span class="pill gw" title="体验包接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>' : '')
943
1107
  + (a.isPat ? '<span class="badge">PAT</span>' : '')
944
1108
  + (a.verified === false ? '<span class="pill fail">token 无效</span>' : '')
945
1109
  + (st.text ? '<span class="pill ' + st.cls + '" title="' + esc(a.lastResult ? fmtTime(a.lastResult.at) + ' ' + (a.lastResult.message || a.lastResult.status) : '') + '">' + esc(st.text) + '</span>' : '')
946
1110
  + '</div><div class="ops">'
947
1111
  + (a.product === 'zcode' ? '<button class="icon-btn" data-act="claim" title="活动领取(billing/preview → claim,可能需过一次验证码)">' + I.gift + '</button>' : '')
948
1112
  + (canCheckin ? '<button class="icon-btn' + (state.busy[a.id] ? ' busy' : '') + '" data-act="checkin" title="' + (a.provider === 'workbuddy-intl' ? '发送活跃请求' : '立即领取') + '">' + (state.busy[a.id] ? I.refresh : I.play) + '</button>' : '')
949
- + (CLIENT_LABEL[a.product] && a.canSwitch && !isCurrent && st.cls !== 'fail' ? '<button class="icon-btn" data-act="switch" title="切换 ' + CLIENT_LABEL[a.product] + ' 客户端到此账号">' + I.swap + '</button>' : '')
1113
+ + (a.canSwitch && !isCurrent && st.cls !== 'fail' ? '<button class="icon-btn" data-act="switch" title="切换 ' + (CLIENT_LABEL[a.product] || prodLabel(a.product)) + ' 客户端到此账号">' + I.swap + '</button>' : '')
950
1114
  + '<button class="icon-btn" data-act="quota" title="刷新积分">' + I.refresh + '</button>'
951
1115
  + '<button class="icon-btn del" data-act="del" title="删除(点两次确认)">' + I.trash + '</button>'
952
1116
  + '</div></div>'
@@ -963,23 +1127,36 @@ function cardHtml(a) {
963
1127
 
964
1128
  const isDone = (a) => Boolean(state.status && state.status.todayDone && state.status.todayDone[a.id] === state.status.today);
965
1129
  const ofProduct = (prod) => state.accounts.filter((a) => a.product === prod);
966
- const currentUidOf = (prod) => state.status && (prod === 'workbuddy' ? state.status.workbuddyCurrentUid : prod === 'zcode' ? state.status.zcodeCurrentUid : null);
967
- // 客户端当前登录判定:WorkBuddy / mirasim 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
1130
+ const currentUidOf = (prod) => state.status && (prod === 'workbuddy' ? state.status.workbuddyCurrentUid : prod === 'zcode' ? state.status.zcodeCurrentUid : prod === 'catpaw' ? state.status.catpawCurrentUid : prod === 'trae' ? state.status.traeCurrentUid : null);
1131
+ /** 接口使用中判定:体验包接口最近一次补全用的账号(lastAccount 记的是 name || uid || id) */
1132
+ function isGatewayCurrent(a) {
1133
+ const last = state.zGatewayStats && state.zGatewayStats.lastAccount;
1134
+ return Boolean(last) && (a.name === last || a.uid === last || a.id === last);
1135
+ }
1136
+
1137
+ // 客户端当前登录判定:WorkBuddy / mirasim / 妙手 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
968
1138
  function isClientCurrent(a) {
969
1139
  const s = state.status;
970
1140
  if (!s) return false;
971
1141
  if (a.product === 'workbuddy') return Boolean(a.uid && s.workbuddyCurrentUid && s.workbuddyCurrentUid === a.uid);
1142
+ if (a.product === 'qoder') {
1143
+ const cur = s.qoderCurrent || {};
1144
+ return Boolean(a.uid && cur[a.provider] && String(cur[a.provider]) === String(a.uid));
1145
+ }
972
1146
  if (a.product === 'mirasim') return Boolean(a.uid && s.mirasimCurrentUid && s.mirasimCurrentUid === a.uid);
1147
+ if (a.product === 'catpaw') return Boolean(a.uid && s.catpawCurrentUid && s.catpawCurrentUid === a.uid);
1148
+ if (a.product === 'trae') return Boolean(a.uid && s.traeCurrentUid && s.traeCurrentUid === a.uid);
973
1149
  if (a.product === 'zcode') {
974
1150
  const id = s.zcodeCurrentIdentity;
975
1151
  return Boolean(id && ((a.uid && id.uid != null && String(id.uid) === String(a.uid)) || (a.email && id.email && id.email === a.email)));
976
1152
  }
977
1153
  return false;
978
1154
  }
979
- // 积分合计:只统计「积分」口径(ZCode 是 token 额度,单位不同,不相加)
1155
+ // 积分合计:只统计「积分」口径(ZCode / 妙手是 token 额度或 Credits 余额,单位不同,不相加)
980
1156
  function creditSum(list) {
981
1157
  let sum = 0, known = 0, loading = 0;
982
1158
  for (const a of list) {
1159
+ if (CREDIT_VIEWS.has(a.product)) continue;
983
1160
  const q = state.quotas[a.id];
984
1161
  if (!q) { loading++; continue; }
985
1162
  if (q.error) continue;
@@ -990,6 +1167,8 @@ function creditSum(list) {
990
1167
  }
991
1168
 
992
1169
  function render() {
1170
+ $('z-actions').style.display = 'none'; // 兜底:切到仪表盘 / 10Router 时 renderProd 不跑,先藏起来
1171
+ $('t-actions').style.display = 'none';
993
1172
  for (const p of PRODUCTS) $('cnt-' + p.id).textContent = ofProduct(p.id).length;
994
1173
  $('cnt-tenrouter').textContent = tr.quotas ? tr.quotas.connections.length : tr.config && tr.config.configured ? '…' : '–';
995
1174
  if (state.view === 'dash') renderDash();
@@ -1002,6 +1181,8 @@ const EMPTY_TIP = {
1002
1181
  workbuddy: '还没有 WorkBuddy 账号。<br>点「添加账号」可读取本机 WorkBuddy 客户端(当前 + 历史会话),或用浏览器登录 WorkBuddy / CodeBuddy 账号。',
1003
1182
  zcode: '还没有 ZCode 账号。<br>点「添加账号」可读取本机 ZCode 客户端已登录的账号,或用浏览器登录(BigModel / Z.ai),多个账号可一键切换。',
1004
1183
  mirasim: '还没有 mirasim 账号。<br>点「添加账号」可读取本机 mirasim 客户端当前登录的账号(本地解密,token 不出本机)。',
1184
+ catpaw: '还没有妙手账号。<br>点「添加账号」可读取本机妙手(美团 CatPaw)客户端当前登录的账号(本地解密凭据,token 不出本机)。',
1185
+ trae: '还没有 Trae 账号。<br>点「添加账号 → 本机导入」可读取本机 Trae 客户端当前登录的账号(本地解密 storage.json 凭据,token 不出本机)。多个账号需先在 Trae 里分别登录并逐个导入。',
1005
1186
  };
1006
1187
 
1007
1188
  function renderProd() {
@@ -1018,19 +1199,19 @@ function renderProd() {
1018
1199
  // 国内版 = 每日签到送积分;国际版 = 保持活跃(活跃不等于必有奖励)
1019
1200
  const cnAcc = acc.filter((a) => a.provider === 'workbuddy');
1020
1201
  const intlAcc = acc.filter((a) => a.provider === 'workbuddy-intl');
1021
- $('s-done-wrap').innerHTML = (cnAcc.length ? '<div class="stat"><span>领取(国内)</span><strong>' + cnAcc.filter(isDone).length + ' / ' + cnAcc.length + '</strong></div><div class="divider"></div>' : '')
1022
- + (intlAcc.length ? '<div class="stat"><span>活跃(国际)</span><strong>' + intlAcc.filter(isDone).length + ' / ' + intlAcc.length + '</strong></div><div class="divider"></div>' : '');
1202
+ $('s-done-wrap').innerHTML = (cnAcc.length ? '<div class="stat"><span title="国内版每日签到送积分">领取(国内)</span><strong>' + cnAcc.filter(isDone).length + ' / ' + cnAcc.length + '</strong></div><div class="divider"></div>' : '')
1203
+ + (intlAcc.length ? '<div class="stat"><span title="国际版定期发送活跃请求保持资格;活跃不等于必有奖励">活跃(国际)</span><strong>' + intlAcc.filter(isDone).length + ' / ' + intlAcc.length + '</strong></div><div class="divider"></div>' : '');
1023
1204
  } else {
1024
1205
  // 从 WorkBuddy 页切回来时 s-done 可能被 innerHTML 换没了,先重建结构再写值
1025
1206
  if (!$('s-done')) $('s-done-wrap').innerHTML = '<div class="stat"><span>今日已领</span><strong id="s-done"></strong></div><div class="divider"></div>';
1026
1207
  $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
1027
1208
  }
1028
1209
  const c = creditSum(acc);
1029
- $('s-credits-label').textContent = prod === 'zcode' || prod === 'mirasim' ? '有效套餐' : '剩余积分';
1030
- $('s-credits').textContent = (prod === 'zcode' || prod === 'mirasim')
1210
+ $('s-credits-label').textContent = CREDIT_VIEWS.has(prod) ? '有效套餐' : '剩余积分';
1211
+ $('s-credits').textContent = CREDIT_VIEWS.has(prod)
1031
1212
  ? acc.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length + ' / ' + acc.length
1032
- : c.known ? fmtNum(c.sum) : '-';
1033
- const hasRegions = prod !== 'zcode' && prod !== 'mirasim';
1213
+ : c.loading ? '…' : (c.known ? fmtNum(c.sum) : '-');
1214
+ const hasRegions = !CREDIT_VIEWS.has(prod) && prod !== 'trae'; // Trae 不分国际 / 国内版
1034
1215
  $('region').style.display = hasRegions ? '' : 'none';
1035
1216
  $('region-div').style.display = hasRegions ? '' : 'none';
1036
1217
  document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
@@ -1039,10 +1220,40 @@ function renderProd() {
1039
1220
  const hasIntl = acc.some((a) => a.provider === 'workbuddy-intl');
1040
1221
  $('btn-checkin').innerHTML = I.play + (prod === 'workbuddy' && hasIntl ? '领取 / 活跃全部' : '领取全部 ' + prodLabel(prod));
1041
1222
  }
1223
+ // 顶栏产品专属入口:ZCode 设置组、Trae 打开客户端,带状态灯(运行中绿点呼吸)
1224
+ const dot = (on, pulse) => '<span class="dot' + (on ? ' on' : '') + (on && pulse ? ' pulse' : '') + '"></span>';
1225
+ $('z-actions').style.display = prod === 'zcode' ? 'contents' : 'none';
1226
+ if (prod === 'zcode') {
1227
+ const acOn = state.zAutoClaim === true;
1228
+ const gwOn = state.zGateway === true;
1229
+ $('zt-ac').innerHTML = dot(acOn) + '自动领取';
1230
+ $('zt-ac').title = acOn ? '自动领取运行中——点我调整轮询周期 / 运行时长' : '自动领取已关闭——点我设置轮询周期与运行时长并开启';
1231
+ $('zt-net').innerHTML = '出口:<b style="font-weight:700;color:var(--text)">' + (state.zProxyFirst === true ? '代理优先' : '直连优先') + '</b>';
1232
+ $('zt-net').title = '网络出口:直连 / 代理顺序与代理地址';
1233
+ $('zt-gw').innerHTML = dot(gwOn) + '接口' + (gwOn && state.zGatewayLan === true ? '·局域网' : '');
1234
+ $('zt-gw').title = '体验包接口:开关、局域网开放与 IP 白名单';
1235
+ const zc = (state.status && state.status.zcodeClient) || {};
1236
+ const canOpen = zc.installed && window.creditdaddy && typeof window.creditdaddy.openZcodeClient === 'function';
1237
+ $('zt-open').style.display = canOpen ? '' : 'none';
1238
+ if (canOpen) {
1239
+ $('zt-open').innerHTML = dot(zc.running === true, true) + '打开客户端';
1240
+ $('zt-open').title = zc.running ? 'ZCode 客户端运行中,点我唤到前台' : 'ZCode 客户端未运行,点我启动';
1241
+ }
1242
+ }
1243
+ $('t-actions').style.display = prod === 'trae' ? 'contents' : 'none';
1244
+ if (prod === 'trae') {
1245
+ const tc = (state.status && state.status.traeClient) || {};
1246
+ const canOpen = tc.installed && window.creditdaddy && typeof window.creditdaddy.openTraeClient === 'function';
1247
+ $('tt-open').style.display = canOpen ? '' : 'none';
1248
+ if (canOpen) {
1249
+ $('tt-open').innerHTML = dot(tc.running === true, true) + '打开客户端';
1250
+ $('tt-open').title = tc.running ? 'Trae 客户端运行中,点我唤到前台' : 'Trae 客户端未运行,点我启动';
1251
+ }
1252
+ }
1042
1253
  }
1043
1254
 
1044
- function kpi(label, value, sub) {
1045
- return '<div class="kpi"><div class="kpi-label">' + label + '</div><div class="kpi-value">' + value + '</div><div class="kpi-sub" title="' + esc(sub.replace(/<[^>]+>/g, '')) + '">' + sub + '</div></div>';
1255
+ function kpi(label, value, sub, labelTitle) {
1256
+ return '<div class="kpi"><div class="kpi-label"' + (labelTitle ? ' title="' + esc(labelTitle) + '"' : '') + '>' + label + '</div><div class="kpi-value">' + value + '</div><div class="kpi-sub" title="' + esc(sub.replace(/<[^>]+>/g, '')) + '">' + sub + '</div></div>';
1046
1257
  }
1047
1258
  const meter = (done, total) => '<div class="meter"><span style="width:' + (total ? Math.round(done / total * 100) : 0) + '%"></span></div>';
1048
1259
 
@@ -1068,8 +1279,9 @@ function renderDash() {
1068
1279
  : done + ' <small>/ ' + signable.length + '</small>',
1069
1280
  !signable.length ? '暂无可领取的账号'
1070
1281
  : (doneDom === domAcc.length && doneIntl === intlAcc.length ? (intlAcc.length ? '今天全部完成(国际为活跃请求,不保证奖励)' : '今天全部签完')
1071
- : '待办 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : '')))
1072
- + kpi('剩余积分', credit.known ? fmtNum(credit.sum) : '-', credit.loading ? '读取中(' + credit.loading + ' 个账号)…' : creditByProd)
1282
+ : '待办 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : '')),
1283
+ '领取 = 国内版每日签到;活跃 = 国际版定期活跃请求(保持资格,不保证奖励)')
1284
+ + kpi('剩余积分', credit.loading ? '…' : (credit.known ? fmtNum(credit.sum) : '-'), credit.loading ? '正在读取各账号额度…' : creditByProd)
1073
1285
  + kpi('下次自动领取', sch.ticking ? '领取中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
1074
1286
  sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : '每 ~2 小时一轮');
1075
1287
 
@@ -1103,6 +1315,11 @@ function renderDash() {
1103
1315
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1104
1316
  rows = '<span class="lbl">额度体系</span><span class="val dim">5h / 7d 滚动窗口</span>'
1105
1317
  + '<span class="lbl">有效套餐</span><span class="val">' + planned + ' / ' + list.length + '</span>';
1318
+ } else if (p.id === 'catpaw') {
1319
+ const withPlan = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && q.plan; });
1320
+ const credits = withPlan.reduce((s, a) => s + (Number(state.quotas[a.id].remaining) || 0), 0);
1321
+ rows = '<span class="lbl">额度体系</span><span class="val dim">Credits 余额 + 套餐版本</span>'
1322
+ + (withPlan.length ? '<span class="lbl">可用额度</span><span class="val">' + fmtNum(credits) + ' Credits(' + esc(withPlan.map((a) => state.quotas[a.id].plan).join(' / ')) + ')</span>' : '');
1106
1323
  } else {
1107
1324
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1108
1325
  // ZCode 没有每日签到:不展示签到语义的行
@@ -1118,6 +1335,8 @@ function renderDash() {
1118
1335
  const live = cur ? esc(cur.name || cur.email || cur.uid)
1119
1336
  : zid && (zid.email || zid.uid || zid.username) ? '未导入的账号(' + esc(zid.email || zid.username || zid.uid) + ')'
1120
1337
  : p.id === 'mirasim' && s.mirasimCurrentUid ? '未导入的账号'
1338
+ : p.id === 'catpaw' && s.catpawClient && s.catpawClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1339
+ : p.id === 'trae' && s.traeClient && s.traeClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1121
1340
  : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? '未导入的账号' : '—');
1122
1341
  rows += '<span class="lbl">客户端当前</span><span class="val">' + live + '</span>';
1123
1342
  }
@@ -1190,22 +1409,28 @@ function renderStatus() {
1190
1409
  // 产品相关提示只在对应标签页出现
1191
1410
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
1192
1411
  const n = $('notice');
1193
- n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || (hasIntl && Boolean(s.riskIdentity)));
1194
- if (state.view === 'mirasim') {
1412
+ n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || state.view === 'catpaw' || state.view === 'trae' || (hasIntl && Boolean(s.riskIdentity)));
1413
+ if (state.view === 'trae') {
1414
+ n.style.display = '';
1415
+ n.innerHTML = '<div class="z-row"><span class="z-label">每日签到</span><span class="z-text">每日签到领约 150 通用积分(7 天有效,多资源包自动归并)。凭据由本机 Trae 客户端解密导入,支持多账号快照一键切换。</span></div>';
1416
+ } else if (state.view === 'catpaw') {
1417
+ n.style.display = '';
1418
+ n.innerHTML = '妙手(美团 CatPaw)按套餐发放 Credits(体验版 / 专业版…),没有每日签到。凭据由妙手客户端登录管理:读取额度用本机解密出的登录 token 直连桌面网关,token 失效时到妙手客户端重新登录后再「本机导入」即可。'
1419
+ + '多账号切换:写入本机凭据后重启妙手客户端即生效(切换会自动保存当前登录,不丢号)。'
1420
+ + catpawClientChip(s);
1421
+ } else if (state.view === 'mirasim') {
1195
1422
  n.style.display = '';
1196
1423
  n.innerHTML = 'mirasim(原生 AI 编程开发环境)额度按「平台额度」(5h 滚动窗口、7d 全局上限与分模型上限)分配。Token 约 1 小时过期,读取额度时会自动用 refreshToken 静默续期并同步回客户端,无需手动处理。'
1197
1424
  + '切换账号需要至少两个已导入的账号(当前登录 + 目标账号):先在「添加账号 → 本机导入」把各账号分别导入,切换时当前登录会自动保存、目标账号写入客户端,桌面版切完自动重启 mirasim。'
1198
1425
  + mirasimClientChip(s);
1199
1426
  } else if (state.view === 'zcode') {
1200
1427
  n.style.display = '';
1201
- const pf = state.zProxyFirst === true;
1202
- const pu = state.zProxyUrl;
1203
1428
  const ac = state.zAutoClaim === true;
1204
- n.innerHTML = 'ZCode 没有每日签到,积分靠「活动领取」。打开「自动领取」后,每轮签到结束会轮询可领活动并尝试领取(桌面版静默过验证码,需要人工时弹验证码窗口);平时保持关闭即可,活动期前再开。也可点卡片上的礼物图标手动领取。'
1205
- + ' <button class="btn" id="btn-zac" style="margin-left:6px" title="ZCode 活动自动轮询领取(每轮签到后执行一次)">' + (ac ? '自动领取:开' : '自动领取:关') + '</button>'
1206
- + ' <button class="btn" id="btn-znet" style="margin-left:4px" title="ZCode 上游接口的访问顺序:失败 / 被拦时自动回退另一条">出口:' + (pf ? '代理优先' : '直连优先') + '</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);
1429
+ const ziv = state.zClaimIntervalMin || 2;
1430
+ const zwin = state.zClaimWindowMin === 0 ? 0 : (state.zClaimWindowMin || 60);
1431
+ n.innerHTML = '<div class="z-row"><span class="z-label">活动领取</span><span class="z-text">活动包积分,每日 0 点左右刷新(无每日签到)。自动领取'
1432
+ + (ac ? '每 ' + ziv + ' 分钟检查资格' + (zwin > 0 ? ',本次运行 ' + zwin + ' 分钟' : ',持续运行') : '未开启(右上角设置轮询计划)')
1433
+ + ';礼物图标 = 手动临时领。</span></div>';
1209
1434
  } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
1210
1435
  n.style.display = '';
1211
1436
  const busy = s.umid.installing || umidBusy;
@@ -1224,15 +1449,6 @@ function renderStatus() {
1224
1449
  }
1225
1450
  }
1226
1451
 
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
1452
  // mirasim 客户端状态芯片 + 「打开客户端」按钮
1237
1453
  function mirasimClientChip(s) {
1238
1454
  const c = s.mirasimClient || {};
@@ -1242,6 +1458,16 @@ function mirasimClientChip(s) {
1242
1458
  + (canOpen ? ' <button class="btn" id="btn-miraopen" style="margin-left:4px" title="打开本机 Mirasim 客户端">打开客户端</button>' : '');
1243
1459
  }
1244
1460
 
1461
+
1462
+ // 妙手客户端状态芯片 + 「打开客户端」按钮
1463
+ function catpawClientChip(s) {
1464
+ const c = s.catpawClient || {};
1465
+ if (!c.installed) return ' <span class="dim">客户端:未安装(本机导入 / 切换需要 Windows 妙手客户端)</span>';
1466
+ const canOpen = window.creditdaddy && typeof window.creditdaddy.openCatpawClient === 'function';
1467
+ return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + (c.signedIn ? '' : ' · 未登录') + '</span>'
1468
+ + (canOpen ? ' <button class="btn" id="btn-cpopen" style="margin-left:4px" title="打开本机妙手客户端">打开客户端</button>' : '');
1469
+ }
1470
+
1245
1471
  // Qoder 设备身份组件(Linux / fnOS)
1246
1472
  const umidOffer = (s) => Boolean(s && s.umid && s.umid.supported && !s.umid.installed);
1247
1473
  let umidBusy = false;
@@ -1255,8 +1481,9 @@ async function installUmid() {
1255
1481
  await loadStatus(); render(); loadLogs();
1256
1482
  }
1257
1483
  $('notice').addEventListener('click', (e) => { if (e.target.closest('#btn-umid')) installUmid(); });
1258
- $('notice').addEventListener('click', async (e) => {
1259
- if (e.target.closest('#btn-zopen')) {
1484
+ // 覆盖 #notice(各产品横幅)与 #z-actions(ZCode 工具栏按钮组)的点击
1485
+ document.addEventListener('click', async (e) => {
1486
+ if (e.target.closest('#zt-open')) {
1260
1487
  try {
1261
1488
  const r = await window.creditdaddy.openZcodeClient();
1262
1489
  if (r && r.ok) toast('已启动 ZCode 客户端');
@@ -1272,33 +1499,53 @@ $('notice').addEventListener('click', async (e) => {
1272
1499
  } catch (err) { toast(String(err && err.message || err), false); }
1273
1500
  return;
1274
1501
  }
1275
- if (e.target.closest('#btn-zac')) {
1502
+ if (e.target.closest('#btn-cpopen')) {
1276
1503
  try {
1277
- const r = await api('/api/zcode/net', { method: 'PUT', json: { autoClaim: state.zAutoClaim !== true } });
1278
- state.zAutoClaim = r.autoClaim;
1279
- renderStatus();
1280
- toast('ZCode 自动轮询领取:' + (r.autoClaim ? '已开启(每轮签到后执行)' : '已关闭'));
1281
- } catch (err) { toast(err.message, false); }
1504
+ const r = await window.creditdaddy.openCatpawClient();
1505
+ if (r && r.ok) toast('已启动妙手客户端');
1506
+ else toast((r && r.error) || '无法打开妙手客户端', false);
1507
+ } catch (err) { toast(String(err && err.message || err), false); }
1282
1508
  return;
1283
1509
  }
1284
- if (e.target.closest('#btn-znet')) {
1510
+ if (e.target.closest('#tt-open')) {
1285
1511
  try {
1286
- const r = await api('/api/zcode/net', { method: 'PUT', json: { proxyFirst: state.zProxyFirst !== true } });
1287
- state.zProxyFirst = r.proxyFirst;
1288
- renderStatus();
1289
- toast('ZCode 出口:' + (r.proxyFirst ? '代理优先(直连兜底)' : '直连优先(代理兜底)'));
1290
- } catch (err) { toast(err.message, false); }
1512
+ const r = await window.creditdaddy.openTraeClient();
1513
+ if (r && r.ok) toast('已启动 Trae 客户端');
1514
+ else toast((r && r.error) || '无法打开 Trae 客户端', false);
1515
+ } catch (err) { toast(String(err && err.message || err), false); }
1291
1516
  return;
1292
1517
  }
1293
- if (e.target.closest('#btn-zproxy')) {
1294
- const cur = state.zProxyUrl || '';
1295
- const v = await uiPrompt('ZCode 出口代理(http://host:port,支持口令 http://user:pass@host:port)\n留空 = 清除,退回 HTTPS_PROXY 环境变量', cur, { okText: '保存' });
1296
- if (v === null) return;
1297
- try {
1298
- await api('/api/zcode/net', { method: 'PUT', json: { proxyUrl: v.trim() } });
1299
- await loadZNet(); renderStatus();
1300
- toast(v.trim() ? '代理已保存' : '代理已清除');
1301
- } catch (err) { toast(err.message, false); }
1518
+ if (e.target.closest('#zt-ac')) {
1519
+ // 自动领取计划弹窗:周期 / 运行时长由用户决定
1520
+ $('zac-interval').value = state.zClaimIntervalMin || 2;
1521
+ $('zac-window').value = state.zClaimWindowMin === 0 ? 0 : (state.zClaimWindowMin || 60);
1522
+ $('zac-off').style.display = state.zAutoClaim === true ? '' : 'none';
1523
+ $('zac-ok').textContent = state.zAutoClaim === true ? '保存' : '保存并启用';
1524
+ openModal('m-zac');
1525
+ return;
1526
+ }
1527
+ if (e.target.closest('#zt-gw')) {
1528
+ // 体验包接口弹窗:开关 + 局域网 + 白名单一次设置;白名单默认预填 10Router 服务地址的主机
1529
+ $('zgw-on').checked = state.zGateway === true;
1530
+ $('zgw-lan').checked = state.zGatewayLan === true;
1531
+ const allow = Array.isArray(state.zGatewayAllow) ? state.zGatewayAllow : [];
1532
+ $('zgw-allow').value = (allow.length ? allow : (state.zGatewaySuggest ? [state.zGatewaySuggest] : [])).join('\n');
1533
+ const st = state.zGatewayStats;
1534
+ $('zgw-url').innerHTML = '接口地址 <code>POST http://127.0.0.1:' + location.port + '/gateway/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)'
1535
+ + (st && st.lastCallAt
1536
+ ? '<br>最近调用 ' + esc(String(st.lastCallAt).slice(11, 19)) + ' · 共 ' + st.calls + ' 次' + (st.lastAccount ? ' · 最后用号 ' + esc(st.lastAccount) : '')
1537
+ : '<br>尚未有调用');
1538
+ syncZgwLanFields();
1539
+ openModal('m-zgw');
1540
+ return;
1541
+ }
1542
+ if (e.target.closest('#zt-net')) {
1543
+ // 出口弹窗:顺序 + 代理地址一起保存;代理只回填到 placeholder(脱敏),留空即清除
1544
+ (state.zProxyFirst === true ? $('znet-proxy') : $('znet-direct')).checked = true;
1545
+ $('znet-url').value = '';
1546
+ $('znet-url').placeholder = state.zProxyUrl || (state.zHasEnvProxy ? '跟随环境变量 HTTPS_PROXY' : 'http://192.168.31.101:7890');
1547
+ openModal('m-znet');
1548
+ return;
1302
1549
  }
1303
1550
  });
1304
1551
  async function loadZNet() {
@@ -1307,8 +1554,22 @@ async function loadZNet() {
1307
1554
  state.zProxyFirst = d.proxyFirst === true;
1308
1555
  state.zProxyUrl = d.proxyUrlMasked || null;
1309
1556
  state.zAutoClaim = d.autoClaim === true;
1557
+ state.zClaimIntervalMin = d.claimIntervalMin;
1558
+ state.zClaimWindowMin = d.claimWindowMin;
1310
1559
  state.zHasEnvProxy = d.hasEnvProxy === true;
1311
- if (state.view === 'zcode') renderStatus();
1560
+ if (state.view === 'zcode') { renderStatus(); render(); }
1561
+ } catch (e) {}
1562
+ }
1563
+ async function loadZGateway() {
1564
+ try {
1565
+ const d = await api('/api/zcode-gateway');
1566
+ state.zGateway = d.enabled === true;
1567
+ state.zGatewayLan = d.lan === true;
1568
+ state.zGatewayAllow = d.allow || [];
1569
+ state.zGatewaySuggest = d.suggestAllow || '';
1570
+ state.zGatewayHasCaptcha = d.hasCaptcha === true;
1571
+ state.zGatewayStats = d.stats || null;
1572
+ if (state.view === 'zcode') { renderStatus(); render(); }
1312
1573
  } catch (e) {}
1313
1574
  }
1314
1575
 
@@ -1441,7 +1702,7 @@ function trCardHtml(c) {
1441
1702
  + trPacksHtml(c, packs);
1442
1703
  }
1443
1704
  }
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">'
1705
+ return '<div class="tr-card' + (c.isActive ? '' : ' off') + (body && body.startsWith('<div class="msg') ? ' flat' : '') + '" draggable="true" data-id="' + esc(c.id) + '" data-vendor="' + esc(c.providerName || c.provider || '') + '"><div class="row1"><div class="name-group">'
1445
1706
  + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1446
1707
  + (TR_REGION[c.provider] ? '<span class="badge ' + TR_REGION[c.provider][1] + '">' + TR_REGION[c.provider][0] + '</span>' : '')
1447
1708
  + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
@@ -1486,74 +1747,51 @@ function trVendorCardHtml(g) {
1486
1747
  const splitOf = items.map((x) => ({ x, s: splitQuotas(x) }));
1487
1748
  // Total Points / 含 Cycle|Capacity 的包是全包汇总:连接级聚合跳过它们,避免与子包重复计数
1488
1749
  const isAgg = (q) => isAggregatePack(q && q.name);
1489
- const primaries = splitOf.filter(({ s }) => s.primary && !isAgg(s.primary)).map(({ s }) => s.primary);
1490
- let primary = null;
1491
- if (primaries.length) {
1492
- const name = primaries[0].name;
1493
- const same = primaries.filter((p) => p.name === name);
1494
- const total = same.reduce((n, p) => n + (p.total > 0 ? p.total : 0), 0);
1495
- // 源数据本身是百分比口径(percentScale / remainingPercentage)→ 聚合也用 %(反重力这类窗口间平等)
1496
- const isPct = same[0].percentScale || Number.isFinite(same[0].remainingPercentage) || !(total > 0);
1497
- if (isPct) {
1498
- // 限时百分比窗口(如 Claude 5h):跨连接取平均百分比,而不是加总
1499
- const pcts = same.map((p) => quotaPct(p)).filter((x) => x !== null);
1500
- primary = {
1501
- name,
1502
- total: 0,
1503
- remaining: 0,
1504
- used: 0,
1505
- unit: '',
1506
- percentScale: true,
1507
- remainingPercentage: pcts.length ? pcts.reduce((a, b) => a + b, 0) / pcts.length : null,
1508
- recurring: same[0].recurring,
1509
- resetAt: same.map((p) => p.resetAt).filter(Boolean).sort()[0] || null,
1510
- };
1511
- } else {
1512
- primary = {
1513
- name,
1514
- total,
1515
- remaining: same.reduce((n, p) => n + quotaRemaining(p), 0),
1516
- used: same.reduce((n, p) => n + (p.used || 0), 0),
1517
- unit: same[0].unit || '',
1518
- percentScale: same[0].percentScale === true,
1519
- recurring: same[0].recurring,
1520
- resetAt: same.map((p) => p.resetAt).filter(Boolean).sort()[0] || null,
1521
- };
1522
- }
1750
+ // 非包行按「名称」归并求和,不论它在该连接卡里扮演主额度还是窗口:各连接主行
1751
+ // 名称不一致时(Qoder CN 有的账号有 Plan Credits 行、有的没有),只按第一行的
1752
+ // 名字取主行会把其余连接的主行静默丢掉。
1753
+ // detailOnly / summarizesDetail:10Router 新版把汇总行拆出的逐包明细行(每资源包
1754
+ // 一条)——父行已含其数值,供应商级聚合跳过,避免重复计数。
1755
+ const isDetail = (q) => q && q.detailOnly === true;
1756
+ let headlineName = null;
1757
+ for (const { s } of splitOf) {
1758
+ if (s.primary && !isAgg(s.primary) && !isDetail(s.primary)) { headlineName = s.primary.name; break; }
1523
1759
  }
1524
-
1525
- // 周期窗口:同名加总;本身是百分比口径的行(percentScale / remainingPercentage)按 % 均值聚合
1526
- const winMap = new Map();
1760
+ const rowMap = new Map();
1527
1761
  const packs = [];
1528
1762
  for (const { s } of splitOf) {
1529
- for (const q of s.windows.filter((x) => !isAgg(x))) {
1530
- if (!winMap.has(q.name)) winMap.set(q.name, { name: q.name, total: 0, rem: 0, anyTotal: false, pctScale: false, resetAt: null, pcts: [] });
1531
- const w = winMap.get(q.name);
1763
+ for (const q of [s.primary, ...s.windows]) {
1764
+ if (!q || isAgg(q) || isDetail(q)) continue;
1765
+ if (!rowMap.has(q.name)) rowMap.set(q.name, { name: q.name, total: 0, rem: 0, anyTotal: false, pctScale: false, resetAt: null, pcts: [], unit: '' });
1766
+ const w = rowMap.get(q.name);
1532
1767
  const isPctRow = q.percentScale || Number.isFinite(q.remainingPercentage);
1533
1768
  if (isPctRow) w.pctScale = true;
1534
1769
  const pct = quotaPct(q);
1535
1770
  if (pct !== null) w.pcts.push(pct);
1536
- if (!isPctRow && q.total > 0) { w.anyTotal = true; w.total += q.total; w.rem += quotaRemaining(q); }
1771
+ if (!isPctRow && q.total > 0) { w.anyTotal = true; w.total += q.total; w.rem += quotaRemaining(q); if (!w.unit) w.unit = q.unit || ''; }
1537
1772
  if (q.resetAt && (!w.resetAt || String(q.resetAt) < String(w.resetAt))) w.resetAt = q.resetAt;
1538
1773
  }
1539
- packs.push(...s.packs.filter((x) => !isAgg(x)));
1774
+ packs.push(...s.packs.filter((x) => !isAgg(x))); // 明细行照常进「积分包×N」简讯:只作计数与剩余提示,不参与行求和
1540
1775
  }
1541
- const wins = [...winMap.values()];
1542
- const alivePacks = packs.filter((q) => quotaRemaining(q) > 0);
1543
- const packRem = alivePacks.reduce((n, q) => n + quotaRemaining(q), 0);
1544
-
1545
- // 周 / 月度等窗口额度也给进度条:百分比口径显示 % 均值;固定额度加总数字
1776
+ // 百分比口径(限时窗口)→ % 均值;固定额度 → 数字加总;无总量 → % 兜底
1546
1777
  const avgPct = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null);
1547
- const winRows = wins.map((w) => trRow(w.pctScale
1778
+ const toRowObj = (w) => (w.pctScale
1548
1779
  ? { name: w.name, total: 0, remainingPercentage: avgPct(w.pcts), percentScale: true, resetAt: w.resetAt, recurring: true }
1549
1780
  : (w.anyTotal
1550
- ? { name: w.name, total: w.total, remaining: w.rem, used: w.total - w.rem, unit: '', percentScale: false, resetAt: w.resetAt, recurring: true }
1551
- : { name: w.name, total: 0, remainingPercentage: avgPct(w.pcts), percentScale: true, resetAt: w.resetAt, recurring: true }))).join('');
1781
+ ? { name: w.name, total: w.total, remaining: w.rem, used: w.total - w.rem, unit: w.unit, percentScale: false, resetAt: w.resetAt, recurring: true }
1782
+ : { name: w.name, total: 0, remainingPercentage: avgPct(w.pcts), percentScale: true, resetAt: w.resetAt, recurring: true }));
1783
+ const headline = headlineName ? rowMap.get(headlineName) : null;
1784
+ if (headline) rowMap.delete(headlineName);
1785
+ const primary = headline ? toRowObj(headline) : null;
1786
+ const others = [...rowMap.values()];
1787
+ const winRows = others.map((w) => trRow(toRowObj(w))).join('');
1552
1788
 
1789
+ const alivePacks = packs.filter((q) => quotaRemaining(q) > 0);
1790
+ const packRem = alivePacks.reduce((n, q) => n + quotaRemaining(q), 0);
1553
1791
  const briefs = [];
1554
1792
  if (alivePacks.length) briefs.push('积分包×' + alivePacks.length + ' 剩 ' + fmtNum(packRem));
1555
1793
  else if (packs.length) briefs.push('积分包已用完');
1556
- const hasQuotas = Boolean(primary || wins.length || packs.length);
1794
+ const hasQuotas = Boolean(primary || others.length || packs.length);
1557
1795
  let quotaHtml;
1558
1796
  if (errs === items.length) {
1559
1797
  quotaHtml = '<div class="msg err">全部连接查询失败</div>';
@@ -1567,7 +1805,7 @@ function trVendorCardHtml(g) {
1567
1805
  ? '<div class="tr-summary dim"><span class="tr-sum-txt">' + briefs.join(' · ') + '</span></div>'
1568
1806
  : '');
1569
1807
  }
1570
- return '<div class="tr-card" draggable="true" data-vendor="' + esc(g.name) + '"><div class="row1"><div class="name-group">'
1808
+ return '<div class="tr-card' + (quotaHtml && quotaHtml.startsWith('<div class="msg') ? ' flat' : '') + '" draggable="true" data-vendor="' + esc(g.name) + '"><div class="row1"><div class="name-group">'
1571
1809
  + '<span class="badge prod tenrouter">' + esc(g.name) + '</span>'
1572
1810
  + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + TR_REGION[prov][0] + '</span>' : '')
1573
1811
  + '<span class="badge">' + items.length + ' 个账号</span>'
@@ -1580,7 +1818,8 @@ function trSetupHtml() {
1580
1818
  return '<div class="setup"><h3>接入 10Router</h3>'
1581
1819
  + '<div class="hint">填 10Router 的地址和一个<b>虚拟 key</b>(10Router 仪表盘 → API Keys 创建的 sk-…),就能在这里:</div>'
1582
1820
  + '<div class="hint" style="margin:6px 0 12px">· 查看 10Router 里其他供应商(Claude / Codex / Kiro / GLM …)的额度卡片(需要 10Router 1.2.1+)<br>'
1583
- + '· 把本机 ZCode / OpenCode / mirasim / 小米 MiMo 的用量同步进 10Router 的用量统计(同 10router-sync 插件,自动每小时一次)</div>'
1821
+ + '· 把本机 ZCode / OpenCode / mirasim / 小米 MiMo / 10Router 的用量同步进 10Router 的用量统计(同 10router-sync 插件,自动每小时一次)<br>'
1822
+ + '· 「10Router 本机」= 本机 10Router 实例自己记的账(如 zcode-free 经它中转的行)同步到聚合端;地址指向本机时自动跳过,避免自己导自己</div>'
1584
1823
  + '<button class="btn primary" data-tr="settings">' + I.plus + '配置 10Router</button></div>';
1585
1824
  }
1586
1825
 
@@ -1588,15 +1827,15 @@ function renderTenrouter() {
1588
1827
  const box = $('tr-panel');
1589
1828
  const c = tr.config;
1590
1829
  if (!c) { box.innerHTML = '<div class="empty">加载中…</div>'; return; }
1591
- if (!c.configured) { box.innerHTML = trSetupHtml(); return; }
1830
+ const gwLine = state.zGateway === true
1831
+ ? '<div class="gw-line"><span class="dot on pulse"></span> ZCode 体验包接口运行中'
1832
+ + (state.zGatewayStats && state.zGatewayStats.lastCallAt ? ' · 最近调用 ' + esc(String(state.zGatewayStats.lastCallAt).slice(11, 19)) + ' · ' + state.zGatewayStats.calls + ' 次 · 最后用号 ' + esc(state.zGatewayStats.lastAccount || '-') : ' · 尚未有调用')
1833
+ + ' · 模型 <code>zcode-free/glm-5.3-flash</code></div>'
1834
+ : '';
1835
+ if (!c.configured) { box.innerHTML = gwLine + trSetupHtml(); return; }
1592
1836
  const list = (tr.quotas && tr.quotas.connections) || [];
1593
1837
  const low = list.filter((x) => x.isActive && !x.error && quotaLow(x)).length;
1594
1838
  const failed = list.filter((x) => x.error).length;
1595
- const ls = c.lastSync;
1596
- const srcChips = (c.sources || []).map((x) => {
1597
- const on = x.found && c.sync.sources.includes(x.id);
1598
- return '<span class="chip' + (on ? ' on' : '') + '" title="' + (x.found ? (x.supported ? '已检测到' : '需要 Node 22.5+') : '本机未检测到') + '">' + esc(x.label) + (x.found ? '' : ' ·无') + '</span>';
1599
- }).join('');
1600
1839
  let grid;
1601
1840
  let tabStrip = '';
1602
1841
  const groups = list.length ? trGroups(list) : [];
@@ -1644,15 +1883,11 @@ function renderTenrouter() {
1644
1883
  + '<div class="stat"><span>额度告急</span><strong>' + (tr.quotas ? low : '-') + '</strong></div><div class="divider"></div>'
1645
1884
  + '<div class="stat"><span>查询失败</span><strong>' + (tr.quotas ? failed : '-') + '</strong></div>'
1646
1885
  + '</div><div class="actions">'
1647
- + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + I.refresh + (tr.loading ? '刷新中…' : '刷新额度') + '</button>'
1648
- + '<button class="btn" data-tr="sync"' + (tr.syncing || c.syncing ? ' disabled' : '') + '>' + I.upload + (tr.syncing || c.syncing ? '同步中…' : '同步用量') + '</button>'
1886
+ + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + (tr.loading ? '刷新中…' : '刷新额度') + '</button>'
1887
+ + '<button class="btn" data-tr="sync" title="用量同步:自动开关、同步来源与手动同步(点我打开)"' + (tr.syncing || c.syncing ? ' disabled' : '') + '>'
1888
+ + '<span class="dot' + (c.sync.enabled ? ' on' : '') + '"></span>' + (tr.syncing || c.syncing ? '同步中…' : '用量同步') + '</button>'
1649
1889
  + '<button class="btn" data-tr="settings">设置</button>'
1650
1890
  + '</div></div>'
1651
- + '<div class="tr-bar"><span title="' + esc(c.endpoint) + '">' + esc(c.endpoint) + ' · ' + esc(c.keyMasked) + '</span>'
1652
- + '<span>用量同步:' + (c.sync.enabled ? '每小时自动' : '手动') + '</span>' + srcChips
1653
- + '<span style="margin-left:auto" title="' + esc(ls ? ls.summary : '') + '">' + (ls ? '上次同步 ' + esc(fmtTime(ls.at).slice(5)) + ' · ' + esc(ls.summary.slice(0, 40)) : '还没有同步过') + '</span>'
1654
- + (tr.quotas ? '<span>额度更新于 ' + esc(fmtTime(tr.quotas.generatedAt).slice(11)) + '</span>' : '')
1655
- + '</div>'
1656
1891
  + tabStrip
1657
1892
  + '<div class="tr-grid">' + grid + '</div>';
1658
1893
  }
@@ -1700,22 +1935,38 @@ function openTrSettings() {
1700
1935
  $('tr-endpoint').value = c.endpoint || '';
1701
1936
  $('tr-key').value = '';
1702
1937
  $('tr-key').placeholder = c.keyMasked ? '已保存 ' + c.keyMasked + '(留空保持不变)' : 'sk-…';
1703
- $('tr-auto').checked = c.configured ? c.sync.enabled : true;
1938
+ $('tr-admin-pw').value = '';
1939
+ $('tr-admin-pw').placeholder = c.hasAdminPassword ? '已保存(留空保持不变)' : '可选:10Router 面板密码';
1704
1940
  $('tr-hide-empty').checked = state.trHideEmpty === true;
1705
- $('tr-sources').innerHTML = (c.sources || []).map((x) => '<label class="check"><input type="checkbox" data-src="' + x.id + '"'
1706
- + ((c.configured ? c.sync.sources.includes(x.id) : true) ? ' checked' : '') + (x.supported ? '' : ' disabled') + '> ' + esc(x.label)
1707
- + '<span class="dim">' + (x.found ? '' : '(本机未检测到)') + (x.supported ? '' : '(需要 Node 22.5+)') + '</span></label>').join('');
1708
1941
  $('tr-disconnect').style.display = c.configured ? '' : 'none';
1709
1942
  $('tr-test-msg').textContent = '';
1710
1943
  openModal('m-10r');
1711
1944
  setTimeout(() => $(c.configured ? 'tr-key' : 'tr-endpoint').focus(), 30);
1712
1945
  }
1946
+ // 用量同步弹窗:自动开关 + 来源勾选 + 手动同步
1947
+ function openTrSync() {
1948
+ const c = tr.config || { sync: { enabled: true, sources: [] }, sources: [] };
1949
+ $('tr-auto').checked = c.configured ? c.sync.enabled : true;
1950
+ $('tr-sources').innerHTML = (c.sources || []).map((x) => '<label class="check"><input type="checkbox" data-src="' + x.id + '"'
1951
+ + ((c.configured ? c.sync.sources.includes(x.id) : true) ? ' checked' : '') + (x.supported ? '' : ' disabled') + '> ' + esc(x.label)
1952
+ + '<span class="dim">' + (x.found ? '' : '(本机未检测到)') + (x.supported ? '' : '(需要 Node 22.5+)') + '</span></label>').join('');
1953
+ const ls = c.lastSync;
1954
+ $('tr-sync-status').textContent = ls ? '上次同步 ' + fmtTime(ls.at).slice(5) + ':' + ls.summary : '还没有同步过';
1955
+ $('tr-sync-now').disabled = tr.syncing || c.syncing === true;
1956
+ openModal('m-trsync');
1957
+ }
1958
+ async function trSyncSave(close) {
1959
+ try {
1960
+ tr.config = await api('/api/tenrouter', { method: 'PUT', json: { syncEnabled: $('tr-auto').checked, sources: [...document.querySelectorAll('#tr-sources input[data-src]:checked')].map((x) => x.dataset.src) } });
1961
+ if (close) { closeModal('m-trsync'); toast('用量同步设置已保存'); render(); }
1962
+ } catch (e) { toast(e.message, false); throw e; }
1963
+ }
1713
1964
  function trForm() {
1714
1965
  return {
1715
- endpoint: $('tr-endpoint').value.trim(),
1966
+ // 从 LLM 客户端复制的 base URL 常带 /v1 后缀——10Router 只认站点根地址,带 /v1 会让所有 /api/* 调用 404、误报「需要 1.2.1+」
1967
+ endpoint: $('tr-endpoint').value.trim().replace(/\/+$/, '').replace(/\/v1$/, ''),
1716
1968
  key: $('tr-key').value.trim(),
1717
- syncEnabled: $('tr-auto').checked,
1718
- sources: [...document.querySelectorAll('#tr-sources input[data-src]:checked')].map((x) => x.dataset.src),
1969
+ adminPassword: $('tr-admin-pw').value, // 留空 = 服务端保持原值
1719
1970
  };
1720
1971
  }
1721
1972
  async function trTest() {
@@ -1749,13 +2000,26 @@ async function trDisconnect() {
1749
2000
  try { tr.config = await api('/api/tenrouter', { method: 'DELETE' }); tr.quotas = null; tr.error = null; closeModal('m-10r'); render(); }
1750
2001
  catch (e) { toast(e.message, false); }
1751
2002
  }
2003
+ async function trSyncAccounts() {
2004
+ if (!(tr.config && tr.config.configured)) return toast('请先保存 10Router 设置', false);
2005
+ const btn = $('tr-sync-accounts');
2006
+ const pw = $('tr-admin-pw').value;
2007
+ btn.disabled = true;
2008
+ try {
2009
+ const r = await api('/api/tenrouter/sync-accounts', { method: 'POST', json: pw ? { adminPassword: pw } : {} });
2010
+ toast(r.summary, !/失败/.test(r.summary));
2011
+ if (pw) await loadTrConfig();
2012
+ loadLogs();
2013
+ } catch (e) { toast(e.message, false); }
2014
+ btn.disabled = false;
2015
+ }
1752
2016
  $('tr-panel').addEventListener('click', (e) => {
1753
2017
  const b = e.target.closest('[data-tr]');
1754
2018
  if (!b) return;
1755
2019
  if (b.dataset.tr === 'toggle') { state.trOpen = Object.assign({}, state.trOpen, { [b.dataset.id]: !(state.trOpen && state.trOpen[b.dataset.id]) }); render(); return; }
1756
2020
  if (b.dataset.tr === 'settings') openTrSettings();
1757
2021
  else if (b.dataset.tr === 'refresh') loadTrQuotas(true);
1758
- else if (b.dataset.tr === 'sync') trSync();
2022
+ else if (b.dataset.tr === 'sync') openTrSync();
1759
2023
  else if (b.dataset.tr === 'vendor') {
1760
2024
  const v = b.dataset.v || '';
1761
2025
  state.trVendor = v;
@@ -1764,12 +2028,33 @@ $('tr-panel').addEventListener('click', (e) => {
1764
2028
  }
1765
2029
  });
1766
2030
  $('tr-test').onclick = trTest;
2031
+ $('tr-sync-save').onclick = () => trSyncSave(true);
2032
+ $('tr-sync-now').onclick = async () => {
2033
+ try { await trSyncSave(false); } catch (e) { return; }
2034
+ closeModal('m-trsync');
2035
+ trSync();
2036
+ };
2037
+ $('tr-sync-accounts').onclick = trSyncAccounts;
1767
2038
  $('tr-save').onclick = trSave;
1768
2039
  $('tr-disconnect').onclick = trDisconnect;
1769
2040
 
1770
2041
  // ── 数据加载 ──
1771
2042
  async function loadStatus() {
1772
2043
  try { state.status = await api('/api/status'); renderStatus(); } catch (e) {}
2044
+ syncAfterZcodeClaims();
2045
+ }
2046
+ // 后台自动领取成功后,立即刷新这些账号的额度与卡片(不必等 2 分钟一轮的余额刷新)
2047
+ let seenZcodeTickAt = null;
2048
+ async function syncAfterZcodeClaims() {
2049
+ const z = state.status && state.status.scheduler && state.status.scheduler.zcode;
2050
+ const t = z && z.lastTick;
2051
+ if (!t || t.at === seenZcodeTickAt) return;
2052
+ const first = seenZcodeTickAt === null;
2053
+ seenZcodeTickAt = t.at;
2054
+ if (first || !(t.claimedIds && t.claimedIds.length)) return; // 首次加载已全量拉取
2055
+ try { state.accounts = (await api('/api/accounts')).accounts; } catch (e) {}
2056
+ loadQuotas(t.claimedIds);
2057
+ loadZGateway();
1773
2058
  }
1774
2059
  async function loadAccounts() {
1775
2060
  const data = await api('/api/accounts');
@@ -1779,8 +2064,10 @@ async function loadAccounts() {
1779
2064
  loadQuotas();
1780
2065
  }
1781
2066
  let quotaRun = 0;
2067
+ let quotaLoadedAt = 0;
1782
2068
  async function loadQuotas(ids) {
1783
2069
  const run = ++quotaRun;
2070
+ quotaLoadedAt = Date.now();
1784
2071
  const queue = (ids || state.accounts.map((a) => a.id)).slice();
1785
2072
  if (ids) for (const id of ids) delete state.quotas[id];
1786
2073
  const worker = async () => {
@@ -1809,7 +2096,9 @@ async function checkinOne(id) {
1809
2096
  try {
1810
2097
  const d = await api('/api/accounts/' + encodeURIComponent(id) + '/checkin', { method: 'POST' });
1811
2098
  const r = d.results && d.results[0];
1812
- toast(r ? (r.account + ':' + (r.status === 'checked-in' ? '领取成功 +' + r.claimedAmount + ' Credits' : (r.error || r.message || r.status))) : d.summary, !(r && r.status === 'failed'));
2099
+ const accObj = state.accounts.find((x) => x.id === id);
2100
+ const accLabel = (r && r.account) || (accObj && (accObj.name || accObj.id)) || '该账号';
2101
+ toast(r ? (accLabel + ':' + (r.status === 'checked-in' ? '领取成功 +' + r.claimedAmount + ' Credits' : (r.error || r.message || r.status))) : d.summary, !(r && r.status === 'failed'));
1813
2102
  } catch (e) { toast(e.message, false); }
1814
2103
  delete state.busy[id];
1815
2104
  await loadStatus(); await loadAccounts(); loadLogs();
@@ -1834,19 +2123,25 @@ async function onCardClick(e) {
1834
2123
  if (act === 'claim') return openClaim(id);
1835
2124
  if (act === 'switch') {
1836
2125
  const a = state.accounts.find((x) => x.id === id);
1837
- const client = CLIENT_LABEL[a && a.product] || '客户端';
2126
+ const client = CLIENT_LABEL[a && a.product] || (a && prodLabel(a.product)) || '客户端';
1838
2127
  const tip = a && a.product === 'zcode'
1839
2128
  ? '\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1840
2129
  : a && a.product === 'mirasim'
1841
2130
  ? '\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1842
- : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
2131
+ : a && a.product === 'catpaw'
2132
+ ? '\n需先退出妙手;切换后重新打开妙手即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2133
+ : a && a.product === 'trae'
2134
+ ? '\n需先退出 Trae;切换后重新打开 Trae 即为新账号。当前登录会先自动快照保存到 CreditDaddy,不会丢号。'
2135
+ : a && a.product === 'qoder'
2136
+ ? '\n需先退出 Qoder;切换后重新打开 Qoder 即为新账号。运行中的 Qoder 退出时会把旧登录写回去,可选「关闭并强制切换」代为关进程。'
2137
+ : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
1843
2138
  if (!(await uiConfirm('把 ' + client + ' 切换到「' + (a && a.name || id) + '」?' + tip, '切换账号', '切换'))) return;
1844
2139
  const doSwitch = (force) => api('/api/accounts/' + encodeURIComponent(id) + '/switch', { json: { force } });
1845
2140
  try {
1846
2141
  let r;
1847
2142
  try { r = await doSwitch(false); }
1848
2143
  catch (err) {
1849
- if (err.code !== 'ZCODE_RUNNING' && err.code !== 'MIRASIM_RUNNING') throw err;
2144
+ if (err.code !== 'ZCODE_RUNNING' && err.code !== 'MIRASIM_RUNNING' && err.code !== 'CATPAW_RUNNING' && err.code !== 'TRAE_RUNNING' && err.code !== 'QODER_RUNNING') throw err;
1850
2145
  if (!(await uiConfirm(err.message + '\n\n仍要强制切换吗?强制切换会先关闭客户端再写入登录,重新打开客户端即为新账号。', client + ' 正在运行', '关闭并强制切换'))) return;
1851
2146
  r = await doSwitch(true);
1852
2147
  }
@@ -1871,6 +2166,23 @@ async function onCardClick(e) {
1871
2166
  } catch (e) {}
1872
2167
  }, 800);
1873
2168
  }
2169
+ if (a && a.product === 'catpaw' && r && !r.alreadyActive && window.creditdaddy && typeof window.creditdaddy.openCatpawClient === 'function') {
2170
+ setTimeout(async () => {
2171
+ try {
2172
+ const re = await window.creditdaddy.openCatpawClient();
2173
+ if (re && re.ok) toast('已重新启动妙手客户端');
2174
+ } catch (e) {}
2175
+ }, 800);
2176
+ }
2177
+ if (a && a.product === 'trae' && r && !r.alreadyActive && window.creditdaddy && typeof window.creditdaddy.openTraeClient === 'function') {
2178
+ setTimeout(async () => {
2179
+ try {
2180
+ const re = await window.creditdaddy.openTraeClient();
2181
+ if (re && re.ok) toast('已重新启动 Trae 客户端');
2182
+ else toast((re && re.error) || '无法打开 Trae 客户端(桌面版才能自动拉起)', false);
2183
+ } catch (e) {}
2184
+ }, 800);
2185
+ }
1874
2186
  } catch (err) { toast(err.message, false); }
1875
2187
  return;
1876
2188
  }
@@ -2068,6 +2380,135 @@ const uiPrompt = (title, value, opts = {}) => uiDialog({ title, message: opts.me
2068
2380
  function okDialog() { const v = $('dlg-field').style.display === 'none' ? true : $('dlg-input').value; $('m-dialog').classList.remove('show'); settleDialog(v); }
2069
2381
  $('dlg-ok').addEventListener('click', okDialog);
2070
2382
  $('dlg-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') okDialog(); });
2383
+
2384
+ // ── 检查更新(桌面版):主进程推状态(update:state),面板只管渲染 ──
2385
+ const updBridge = window.creditdaddy && window.creditdaddy.update ? window.creditdaddy.update : null;
2386
+ let updState = null;
2387
+ function updActBtn(id, text, cls, fn) {
2388
+ return '<button class="btn' + (cls ? ' ' + cls : '') + '" id="' + id + '">' + text + '</button>';
2389
+ }
2390
+ function renderUpdateModal(s) {
2391
+ updState = s;
2392
+ const body = $('upd-body');
2393
+ const acts = $('upd-actions');
2394
+ const cur = '当前 v' + (s.current || '?');
2395
+ const rel = '<button class="btn" id="upd-rel">打开 Releases</button>';
2396
+ const close = '<button class="btn" data-close-uu>稍后</button>';
2397
+ switch (s.phase) {
2398
+ case 'unsupported':
2399
+ body.innerHTML = '<div class="hint">当前环境不支持应用内更新(开发模式 / Portable / 未签名的 macOS 版),请从 GitHub Releases 手动下载。</div>';
2400
+ acts.innerHTML = rel + '<button class="btn" data-close-uu>取消</button>';
2401
+ break;
2402
+ case 'checking':
2403
+ body.innerHTML = '<div class="hint">正在检查更新…</div>';
2404
+ acts.innerHTML = close;
2405
+ break;
2406
+ case 'latest':
2407
+ body.innerHTML = '<div class="hint"><span class="dot on"></span> 已是最新版本(' + cur + ')</div>';
2408
+ acts.innerHTML = '<button class="btn primary" data-close-uu>好的</button>';
2409
+ break;
2410
+ case 'available':
2411
+ body.innerHTML = s.via === 'updater'
2412
+ ? '<div class="hint">发现新版本 <b>v' + esc(s.version) + '</b>(' + cur + ')——正在后台下载,完成后退出 CreditDaddy 时自动安装。</div>'
2413
+ : '<div class="hint">发现新版本 <b>v' + esc(s.version) + '</b>(' + cur + (s.sizeMB ? ',约 ' + s.sizeMB + ' MB' : '') + '),可由本机直接下载安装。</div>';
2414
+ acts.innerHTML = (s.via === 'github' ? updActBtn('upd-go', '立即下载安装', 'primary') : '') + rel + close;
2415
+ break;
2416
+ case 'downloading':
2417
+ body.innerHTML = '<div class="hint">正在下载 v' + esc(s.version) + '</div>'
2418
+ + '<div class="meter" style="margin:8px 0 4px"><span style="width:' + (s.percent || 0) + '%"></span></div>'
2419
+ + '<div class="hint">' + (s.percent != null ? s.percent + '%' : '') + '(' + (s.mb || '…') + ' MB)' + (s.percent == null ? ' 获取大小中…' : '') + '</div>';
2420
+ acts.innerHTML = '<button class="btn" data-close-uu>后台继续</button>';
2421
+ break;
2422
+ case 'downloaded':
2423
+ body.innerHTML = '<div class="hint"><span class="dot on pulse"></span> <b>v' + esc(s.version) + '</b> '
2424
+ + (s.via === 'updater' ? '已下载完成,重启即完成更新(退出时也会自动安装)。' : '安装包已下载完成,安装时 CreditDaddy 会先退出。') + '</div>';
2425
+ acts.innerHTML = updActBtn('upd-go', s.via === 'updater' ? '立即重启更新' : '立即安装', 'primary') + close;
2426
+ break;
2427
+ case 'error':
2428
+ default:
2429
+ body.innerHTML = '<div class="hint" style="color:var(--err)">检查更新失败:' + esc(s.error || '未知错误') + '</div>';
2430
+ acts.innerHTML = updActBtn('upd-retry', '重试', 'primary') + rel + '<button class="btn" data-close-uu>取消</button>';
2431
+ break;
2432
+ }
2433
+ const relBtn = $('upd-rel');
2434
+ if (relBtn) relBtn.onclick = () => updBridge.openReleases();
2435
+ const goBtn = $('upd-go');
2436
+ if (goBtn) { goBtn.disabled = true; goBtn.textContent = '处理中…'; updBridge.install().then(renderUpdateModal).catch((e) => { goBtn.disabled = false; goBtn.textContent = '重试'; }); }
2437
+ const retryBtn = $('upd-retry');
2438
+ if (retryBtn) retryBtn.onclick = () => updBridge.check().then(renderUpdateModal).catch(() => {});
2439
+ acts.querySelectorAll('[data-close-uu]').forEach((b) => { b.onclick = () => closeModal('m-update'); });
2440
+ }
2441
+ async function openUpdateModal(autoCheck) {
2442
+ if (!updBridge) return;
2443
+ openModal('m-update');
2444
+ try {
2445
+ updState = await updBridge.state();
2446
+ renderUpdateModal(updState);
2447
+ if (autoCheck) renderUpdateModal(await updBridge.check());
2448
+ } catch (e) {
2449
+ renderUpdateModal({ phase: 'error', error: String((e && e.message) || e), current: (updState && updState.current) || '' });
2450
+ }
2451
+ }
2452
+ if (updBridge) {
2453
+ updBridge.onState((s) => { updState = s; $('upd-ver') && ($('upd-ver').textContent = '当前 v' + (s.current || '?')); if (document.getElementById('m-update').classList.contains('show')) renderUpdateModal(s); });
2454
+ updBridge.onOpen(() => openUpdateModal(true));
2455
+ }
2456
+ $('btn-update').onclick = () => openUpdateModal(true);
2457
+
2458
+ // ZCode 自动领取计划:保存(按新配置启用)/ 停用
2459
+ $('zac-ok').addEventListener('click', async () => {
2460
+ try {
2461
+ const r = await api('/api/zcode/net', { method: 'PUT', json: { autoClaim: true, claimIntervalMin: Number($('zac-interval').value), claimWindowMin: Number($('zac-window').value) } });
2462
+ state.zAutoClaim = r.autoClaim === true;
2463
+ state.zClaimIntervalMin = r.claimIntervalMin;
2464
+ state.zClaimWindowMin = r.claimWindowMin;
2465
+ renderStatus(); render();
2466
+ closeModal('m-zac');
2467
+ toast('自动领取已开启:每 ' + r.claimIntervalMin + ' 分钟检查' + (r.claimWindowMin > 0 ? ',运行 ' + r.claimWindowMin + ' 分钟后自动关闭' : ',一直运行直到手动关闭'));
2468
+ } catch (err) { toast(err.message, false); }
2469
+ });
2470
+ $('zac-off').addEventListener('click', async () => {
2471
+ try {
2472
+ const r = await api('/api/zcode/net', { method: 'PUT', json: { autoClaim: false } });
2473
+ state.zAutoClaim = r.autoClaim === true;
2474
+ renderStatus(); render();
2475
+ closeModal('m-zac');
2476
+ toast('ZCode 自动领取:已关闭');
2477
+ } catch (err) { toast(err.message, false); }
2478
+ });
2479
+
2480
+ // ZCode 体验包接口:开关 + 局域网开放 + 免密 IP 白名单一次保存
2481
+ function syncZgwLanFields() {
2482
+ const off = !$('zgw-on').checked;
2483
+ $('zgw-lan-fields').querySelectorAll('input, textarea').forEach((el) => { el.disabled = off; });
2484
+ $('zgw-lan-fields').style.opacity = off ? '.45' : '';
2485
+ }
2486
+ $('zgw-on').addEventListener('change', syncZgwLanFields);
2487
+ $('zgw-ok').addEventListener('click', async () => {
2488
+ const allow = $('zgw-allow').value.split('\n').map((x) => x.trim()).filter(Boolean);
2489
+ try {
2490
+ const r = await api('/api/zcode-gateway', { method: 'PUT', json: { enabled: $('zgw-on').checked, lan: $('zgw-lan').checked, allow } });
2491
+ state.zGateway = r.enabled === true;
2492
+ state.zGatewayLan = r.lan === true;
2493
+ state.zGatewayAllow = r.allow || [];
2494
+ renderStatus(); render();
2495
+ closeModal('m-zgw');
2496
+ toast('体验包接口:' + (r.enabled ? '已开启' : '已关闭')
2497
+ + (r.enabled && r.lan ? ',局域网开放' + (r.allow && r.allow.length ? ',白名单 ' + r.allow.join('、') : ',白名单为空——白名单外需虚拟 key') + ';绑定改动重启后生效' : ''));
2498
+ } catch (err) { toast(err.message, false); }
2499
+ });
2500
+
2501
+ // ZCode 网络出口:顺序 + 代理地址保存
2502
+ $('znet-ok').addEventListener('click', async () => {
2503
+ try {
2504
+ const body = { proxyFirst: $('znet-proxy').checked, proxyUrl: $('znet-url').value.trim() };
2505
+ const r = await api('/api/zcode/net', { method: 'PUT', json: body });
2506
+ await loadZNet();
2507
+ renderStatus(); render();
2508
+ closeModal('m-znet');
2509
+ toast('ZCode 出口:' + (r.proxyFirst ? '代理优先(直连兜底)' : '直连优先(代理兜底)') + (body.proxyUrl === '' ? ',代理已清除' : ''));
2510
+ } catch (err) { toast(err.message, false); }
2511
+ });
2071
2512
  document.querySelectorAll('.mask').forEach((m) => {
2072
2513
  m.addEventListener('mousedown', (e) => { if (e.target === m) closeModal(m.id); });
2073
2514
  m.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => closeModal(m.id)));
@@ -2086,10 +2527,13 @@ const LOCAL_HINT = {
2086
2527
  workbuddy: '读取本机 WorkBuddy 客户端的当前会话与历史会话(CodeBuddyExtension 凭据目录),token 不出本机。',
2087
2528
  zcode: '读取本机 ZCode 客户端当前登录的账号(本地解密 ~/.zcode/v2/credentials.json),token 不出本机。',
2088
2529
  mirasim: '读取本机 mirasim 客户端当前登录的账号(本地解密 ~/.mirasim/setting.json),token 不出本机。',
2530
+ catpaw: '读取本机妙手(美团 CatPaw)客户端当前登录的账号(本地解密 %APPDATA%\\catpaw-moon\\catx-credential.json),token 不出本机。',
2531
+ trae: '读取本机 Trae 客户端当前登录的账号(本地解密 <客户端数据目录>\\User\\globalStorage\\storage.json 的 tc 信封),token 不出本机。',
2089
2532
  };
2090
2533
  function openAdd(product) {
2091
2534
  state.addProduct = product || '';
2092
- const tabShown = { local: true, device: product !== 'mirasim', token: product !== 'zcode' && product !== 'mirasim' };
2535
+ // Trae 凭据只能从本机客户端 storage.json 解密:不支持浏览器登录 / 粘贴 Token
2536
+ const tabShown = { local: true, device: product !== 'mirasim' && product !== 'catpaw' && product !== 'trae', token: product !== 'zcode' && product !== 'mirasim' && product !== 'catpaw' && product !== 'trae' };
2093
2537
  document.querySelectorAll('#m-add .tab').forEach((t) => { t.style.display = tabShown[t.dataset.tab] ? '' : 'none'; });
2094
2538
  $('local-hint').textContent = LOCAL_HINT[state.addProduct];
2095
2539
  let first = null;
@@ -2118,7 +2562,7 @@ function openAdd(product) {
2118
2562
  async function localScan() {
2119
2563
  const box = $('scan-result');
2120
2564
  $('scan-status').textContent = '扫描中(最多约 10 秒)…';
2121
- box.innerHTML = '<div class="hint">正在读取本机 Qoder 客户端凭据…</div>';
2565
+ box.innerHTML = '<div class="hint">正在读取本机客户端凭据(Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae)…</div>';
2122
2566
  try {
2123
2567
  const r = await api('/api/local/scan', { method: 'POST' });
2124
2568
  $('scan-status').textContent = '';
@@ -2135,11 +2579,13 @@ async function localScan() {
2135
2579
  : '<button class="btn" data-cid="' + esc(c.id) + '" data-prov="qoder">导入·国际</button><button class="btn" data-cid="' + esc(c.id) + '" data-prov="qoder-cn">导入·国内</button>';
2136
2580
  html += '<div class="cand"><div class="cand-main"><div class="cand-name">' + title + prov + '</div><div class="cand-sub">' + sub + '</div></div><div class="row">' + btns + '</div></div>';
2137
2581
  }
2138
- for (const e of r.errors || []) html += '<div class="hint" style="color:var(--err)">读取失败:' + esc(e.file) + ':' + esc(e.error) + '</div>';
2582
+ for (const e of r.errors || []) {
2583
+ html += '<div class="hint" style="color:var(--err)">读取失败:' + esc(e.file) + ':' + esc(e.error) + '</div>';
2584
+ }
2139
2585
  if (!cands.length) {
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 数据目录'
2586
+ const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae';
2587
+ const alt = state.addProduct === 'zcode' || state.addProduct === 'mirasim' || state.addProduct === 'catpaw' ? '' : ',或改用「浏览器登录」「粘贴 Token」';
2588
+ html += '<div class="hint">未发现已登录的 ' + where + ' 账号。请先在客户端登录' + alt + '。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录、mirasim 数据目录、妙手凭据目录、Trae 数据目录'
2143
2589
  + (r.scannedDirs && r.scannedDirs.length ? '、' + r.scannedDirs.map(esc).join('、') : '') + '</div>';
2144
2590
  }
2145
2591
  box.innerHTML = html;
@@ -2232,20 +2678,21 @@ async function addToken() {
2232
2678
  // 导出
2233
2679
  async function doExport() {
2234
2680
  const password = $('ex-password').value;
2235
- if (password && password.length < 4) return toast('口令至少 4 位', false);
2681
+ if (!password.trim()) return toast('导出必须设置加密口令', false);
2682
+ if (password.length < 4) return toast('口令至少 4 位', false);
2236
2683
  try {
2237
2684
  const sel = $('ex-provider').value;
2238
2685
  const scope = sel.startsWith('p:') ? { product: sel.slice(2) } : { provider: sel || undefined };
2239
- const payload = await api('/api/export', { json: Object.assign({ password: password || undefined }, scope) });
2686
+ const payload = await api('/api/export', { json: Object.assign({ password }, scope) });
2240
2687
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
2241
2688
  const a = document.createElement('a');
2242
2689
  a.href = URL.createObjectURL(blob);
2243
- a.download = 'creditdaddy-' + (sel.replace('p:', '') || 'all') + '-' + new Date().toISOString().slice(0, 10) + (password ? '.secure' : '') + '.json';
2690
+ a.download = 'creditdaddy-' + (sel.replace('p:', '') || 'all') + '-' + new Date().toISOString().slice(0, 10) + '.secure.json';
2244
2691
  a.click();
2245
2692
  URL.revokeObjectURL(a.href);
2246
2693
  $('ex-password').value = '';
2247
2694
  closeModal('m-export');
2248
- toast(password ? '已导出加密文件(可导入 10router)' : '已导出明文文件,内含 token,请妥善保管');
2695
+ toast('已导出加密文件(可在 10router 或本面板导入)');
2249
2696
  } catch (e) { toast(e.message, false); }
2250
2697
  }
2251
2698
 
@@ -2396,7 +2843,54 @@ function applyComponents() {
2396
2843
  renderStatus();
2397
2844
  }
2398
2845
 
2399
- // 右上角面板设置:组件显隐 + 组件顺序(↑↓,仪表盘与标签栏同步)+ 每产品账号排序(默认积分多者靠前,可手动固定)
2846
+ // 右上角面板设置:访问密码 + 组件显隐与排序 + 每产品账号排序(默认积分多者靠前,可手动固定)
2847
+ function secStatusHtml() {
2848
+ if (!state.sec.loaded) return '<span class="sec-status">…</span>';
2849
+ return state.sec.enabled ? '<span class="sec-status on">已开启</span>' : '<span class="sec-status">未开启</span>';
2850
+ }
2851
+ function renderSecActions() {
2852
+ if (!state.sec.loaded) return '';
2853
+ return state.sec.enabled
2854
+ ? '<button class="btn" data-sec-action="change">修改密码</button><button class="btn" data-sec-action="disable">关闭</button>'
2855
+ : '<button class="btn primary" data-sec-action="enable">开启面板密码</button>';
2856
+ }
2857
+ async function applyPanelKey(next) {
2858
+ setKey(next);
2859
+ state.sec = { enabled: Boolean(next), loaded: true };
2860
+ }
2861
+ async function doSecAction(action) {
2862
+ if (action === 'enable' || action === 'change') {
2863
+ const pw = await uiPrompt(action === 'enable' ? '设置面板访问密码' : '输入新密码', '', {
2864
+ type: 'password', okText: action === 'enable' ? '开启' : '保存',
2865
+ placeholder: '至少 4 位,不能包含空格',
2866
+ message: '设置后每次打开面板都需要输入此密码。导出账号的加密口令是独立设置,始终必填。',
2867
+ });
2868
+ if (!pw) return;
2869
+ const val = String(pw).trim();
2870
+ if (val.length < 4) return toast('面板访问密码至少 4 位', false);
2871
+ if (/\s/.test(val)) return toast('面板访问密码不能包含空格', false);
2872
+ try {
2873
+ const r = await api('/api/settings', { method: 'PUT', json: { panelKey: val } });
2874
+ await applyPanelKey(val);
2875
+ renderCompMenu();
2876
+ toast(r.panelKeyEnabled ? '已设置面板访问密码,下次打开面板需要输入' : '已保存', true);
2877
+ } catch (e) { toast(e.message, false); }
2878
+ return;
2879
+ }
2880
+ if (action === 'disable') {
2881
+ const ok = await uiConfirm(
2882
+ '关闭后打开面板不再需要密码(局域网 / 公网访问将无保护,仅建议本机使用)。账号导出仍必须设置加密口令。\n\n确定关闭面板访问密码吗?',
2883
+ '关闭面板访问密码', '关闭');
2884
+ if (!ok) return;
2885
+ try {
2886
+ const r = await api('/api/settings', { method: 'PUT', json: { disable: true } });
2887
+ await loadSettings();
2888
+ await applyPanelKey(r.panelKeyEnabled ? getKey() : '');
2889
+ renderCompMenu();
2890
+ toast(r.panelKeyEnabled ? '该部署环境(fnOS / 环境变量)强制启用访问密码,无法在面板内关闭' : '已关闭面板访问密码', true);
2891
+ } catch (e) { toast(e.message, false); }
2892
+ }
2893
+ }
2400
2894
  function renderCompMenu() {
2401
2895
  const compRows = state.compOrder.map((id) => {
2402
2896
  const c = COMPONENTS.find((x) => x.id === id);
@@ -2425,11 +2919,29 @@ function renderCompMenu() {
2425
2919
  + (manual && manual.length ? '<button class="btn" data-acc-reset="' + p.id + '" style="margin-top:6px;width:100%">恢复默认排序</button>' : '')
2426
2920
  + '</details>';
2427
2921
  }).join('');
2428
- $('comp-menu').innerHTML = '<div class="comp-menu-title">组件顺序(仪表盘与标签栏同步)</div>' + compRows
2429
- + '<div class="comp-menu-title" style="margin-top:2px">账号卡片排序</div>'
2922
+ $('comp-menu').innerHTML =
2923
+ '<div class="comp-menu-title">面板访问密码 ' + secStatusHtml() + '</div>'
2924
+ + '<div class="hint">开启后每次打开面板都需要输入密码;密码可随时在此修改或关闭。'
2925
+ + (state.sec.enabled ? '' : '账号导出不受影响(导出口令始终必填)。') + '</div>'
2926
+ + '<div class="row">' + renderSecActions() + '</div>'
2927
+ + '<div class="comp-menu-title">自动签到</div>'
2928
+ + '<div class="hint">' + autoCheckinHint() + '</div>'
2929
+ + '<div class="comp-menu-title">组件顺序(仪表盘与标签栏同步)</div>' + compRows
2930
+ + '<div class="comp-menu-title">账号卡片排序</div>'
2430
2931
  + (sortSections || '<div class="sort-hint">暂无账号</div>');
2431
2932
  }
2432
2933
 
2934
+ /** 自动签到口径说明:调度周期 + 各产品业务日刷新时点 */
2935
+ function autoCheckinHint() {
2936
+ const sch = state.status && state.status.scheduler;
2937
+ const next = sch && sch.nextTickAt ? ',下次执行 ' + esc(String(sch.nextTickAt).slice(11, 16)) : '';
2938
+ const zc = sch && sch.zcode;
2939
+ const zcText = zc && zc.enabled
2940
+ ? 'ZCode 自动领取运行中(每 ' + (zc.intervalMin || 2) + ' 分钟' + (zc.autoOffAt ? ',约 ' + Math.max(1, Math.ceil((new Date(zc.autoOffAt).getTime() - Date.now()) / 60000)) + ' 分钟后自动关闭' : ',一直运行') + '),周期可在 ZCode 页「自动领取计划」里改'
2941
+ : 'ZCode 活动领取默认关闭,可在 ZCode 页「自动领取计划」里开启并配置轮询周期';
2942
+ return '守护进程每 ~2 小时自动扫描执行一轮全部账号' + next + '。各产品「业务日」刷新时点(UTC+8):<b>Qoder 每日 10:00</b>(每台设备每日限领一个国际版账号)、<b>WorkBuddy 每日 00:00</b>,其余产品按 00:00;已领成功的账号按对应业务日记忆跳过,跨过刷新点后当天照常重试。' + esc(zcText) + '。';
2943
+ }
2944
+
2433
2945
  /** 在手动排序列表里移动一个 id;列表不存在时先按当前生效顺序物化一份 */
2434
2946
  function moveAccountId(prod, aid, dir) {
2435
2947
  const baseIds = sortedAccounts(prod, ofProduct(prod)).map((a) => a.id);
@@ -2442,15 +2954,17 @@ function moveAccountId(prod, aid, dir) {
2442
2954
  state.cardOrder[prod] = arr;
2443
2955
  saveCardOrder();
2444
2956
  }
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;
2957
+ // 右上角齿轮 → 面板设置弹窗(访问密码 + 组件显隐/排序 + 账号卡片排序)
2958
+ $('btn-comp').addEventListener('click', async () => {
2959
+ renderCompMenu();
2960
+ openModal('m-settings');
2961
+ await loadSettings();
2962
+ renderCompMenu();
2963
+ // 桌面版才有检查更新入口(走 preload 桥到主进程的更新状态机)
2964
+ if (updBridge) {
2965
+ $('upd-entry').style.display = '';
2966
+ try { const s = await updBridge.state(); updState = s; $('upd-ver').textContent = '当前 v' + (s.current || '?'); } catch {}
2967
+ }
2454
2968
  });
2455
2969
  $('comp-menu').addEventListener('change', (e) => {
2456
2970
  const c = e.target.dataset && e.target.dataset.comp;
@@ -2461,6 +2975,8 @@ $('comp-menu').addEventListener('change', (e) => {
2461
2975
  renderCompMenu();
2462
2976
  });
2463
2977
  $('comp-menu').addEventListener('click', (e) => {
2978
+ const secBtn = e.target.closest && e.target.closest('[data-sec-action]');
2979
+ if (secBtn) { doSecAction(secBtn.dataset.secAction); return; }
2464
2980
  const move = e.target.closest && e.target.closest('[data-move]');
2465
2981
  if (move) {
2466
2982
  e.stopPropagation();
@@ -2503,7 +3019,12 @@ $('btn-checkin').onclick = () => checkinAll($('btn-checkin'), state.view);
2503
3019
  $('btn-add-dash').onclick = () => openAdd('');
2504
3020
  $('btn-add').onclick = () => openAdd(state.view);
2505
3021
  $('btn-export-dash').onclick = () => { $('ex-provider').value = ''; openModal('m-export'); };
2506
- $('btn-export').onclick = () => { $('ex-provider').value = state.view === 'zcode' ? 'zcode' : 'p:' + state.view; openModal('m-export'); };
3022
+ $('btn-export').onclick = () => {
3023
+ const v = state.view;
3024
+ // ZCode 是单一 provider,直接按 provider 选;其余产品页按产品线(p:);仪表盘 / 10Router 导出全部
3025
+ $('ex-provider').value = v === 'zcode' ? 'zcode' : (v === 'qoder' || v === 'workbuddy' || v === 'mirasim' || v === 'catpaw' || v === 'trae') ? 'p:' + v : '';
3026
+ openModal('m-export');
3027
+ };
2507
3028
  $('btn-do-export').onclick = doExport;
2508
3029
  $('btn-pass-ok').onclick = () => submitImport($('pass-input').value);
2509
3030
  $('pass-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitImport($('pass-input').value); });
@@ -2528,6 +3049,7 @@ function applyView() {
2528
3049
  $('view-dash').classList.toggle('active', state.view === 'dash');
2529
3050
  $('view-prod').classList.toggle('active', state.view !== 'dash' && state.view !== 'tenrouter');
2530
3051
  $('view-10r').classList.toggle('active', state.view === 'tenrouter');
3052
+ if (state.view === 'tenrouter') loadZGateway();
2531
3053
  if (state.view === 'tenrouter' && tr.config && tr.config.configured && !tr.quotas && !tr.loading) loadTrQuotas();
2532
3054
  render();
2533
3055
  renderStatus();
@@ -2550,7 +3072,8 @@ $('region').addEventListener('click', (e) => {
2550
3072
  state.region = b.dataset.r;
2551
3073
  render();
2552
3074
  renderStatus();
2553
- });('change', () => {
3075
+ });
3076
+ $('f-provider').addEventListener('change', () => {
2554
3077
  $('f-token').placeholder = $('f-provider').value.startsWith('workbuddy')
2555
3078
  ? 'WorkBuddy / CodeBuddy 登录 token(eyJ…,可在 CodeBuddyExtension 的 auth/*.info 中找到)'
2556
3079
  : 'dt-… / jt-… 或 PAT(pt-…,自动兑换)';
@@ -2561,6 +3084,27 @@ $('btn-logs').onclick = () => {
2561
3084
  if (!c) { loadLogs(); $('logs').scrollTop = $('logs').scrollHeight; }
2562
3085
  };
2563
3086
 
3087
+ // ── 仪表盘底部两卡:默认收起,点标题展开,展开状态存本地 ──
3088
+ const DASH_FOLD_KEY = 'qd_dash_fold';
3089
+ state.dashFold = (() => { try { return JSON.parse(localStorage.getItem(DASH_FOLD_KEY) || '{}'); } catch { return {}; } })();
3090
+ function applyDashFold() {
3091
+ document.querySelectorAll('.dash-cols .pcard[data-fold]').forEach((el) => {
3092
+ const open = state.dashFold[el.dataset.fold] === true;
3093
+ el.classList.toggle('open', open);
3094
+ el.querySelector('.fold-ico').innerHTML = open ? I.chevUp : I.chevDown;
3095
+ });
3096
+ }
3097
+ document.querySelectorAll('.dash-cols .fold-head').forEach((h) => {
3098
+ h.title = '展开 / 收起';
3099
+ h.addEventListener('click', () => {
3100
+ const k = h.closest('.pcard').dataset.fold;
3101
+ state.dashFold[k] = state.dashFold[k] !== true;
3102
+ try { localStorage.setItem(DASH_FOLD_KEY, JSON.stringify(state.dashFold)); } catch {}
3103
+ applyDashFold();
3104
+ });
3105
+ });
3106
+ applyDashFold();
3107
+
2564
3108
  // ── 启动:先直接请求(本机模式无需密钥);只有服务端返回 401 时才询问密钥 ──
2565
3109
  initTheme();
2566
3110
  $('btn-home').innerHTML = I.github;
@@ -2568,12 +3112,19 @@ applyView();
2568
3112
  (function boot() {
2569
3113
  function start() {
2570
3114
  loadStatus().then(loadAccounts).catch((e) => toast(e.message, false));
3115
+ loadSettings().then(renderCompMenu);
2571
3116
  loadZNet();
3117
+ loadZGateway();
2572
3118
  loadTrConfig().then(() => loadTrQuotas());
2573
3119
  setInterval(() => loadTrQuotas(), 5 * 60 * 1000);
3120
+ // 账号余额(ZCode 剩余 token 等)自动刷新:可见时每 2 分钟一轮;从托盘/后台切回时若超过 1 分钟没刷过立即补一轮
3121
+ setInterval(() => { if (!document.hidden) loadQuotas(); }, 2 * 60 * 1000);
3122
+ document.addEventListener('visibilitychange', () => {
3123
+ if (!document.hidden && Date.now() - quotaLoadedAt > 60 * 1000) loadQuotas();
3124
+ });
2574
3125
  loadLogs();
2575
3126
  setInterval(loadLogs, 10000);
2576
- setInterval(async () => { await loadStatus(); render(); }, 30000);
3127
+ setInterval(async () => { await loadStatus(); await loadZNet(); await loadZGateway(); render(); }, 30000);
2577
3128
  }
2578
3129
  // 先直接请求(本机模式无需密码);需要密码时 api() 会弹框并重试
2579
3130
  api('/api/status').then(start).catch((e) => toast(e.code === 'PANEL_KEY'