creditdaddy 1.3.2 → 1.4.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,11 +43,56 @@
43
43
  .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
44
44
  .logo { width: 36px; height: 36px; flex-shrink: 0; display: block; }
45
45
  .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; }
46
- .ver { color: var(--text-3); font-size: 11px; line-height: 1.3; font-variant-numeric: tabular-nums; }
47
- .head-right { display: flex; align-items: center; gap: 10px; color: var(--text-3); font-size: 12px; }
46
+ .ver { color: var(--text-3); font-size: 11px; line-height: 1.55; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
47
+ .head-right { display: flex; align-items: center; gap: 8px; color: var(--text-3); font-size: 12px; }
48
+ /* 状态胶囊 + 分组分隔线:把「下一次调度」这类只读状态与右侧操作按钮区分开 */
49
+ .sched-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px 5px 9px; border-radius: 999px; background: var(--tertiary); color: var(--text-3); font-size: 12px; white-space: nowrap; }
50
+ .sched-chip svg { width: 13px; height: 13px; flex-shrink: 0; opacity: .8; }
51
+ .sched-chip b { font-weight: 650; color: var(--text-2); font-variant-numeric: tabular-nums; }
52
+ .sched-chip.busy { background: rgba(var(--accent-rgb), .14); color: var(--accent); }
53
+ .sched-chip.busy b { color: var(--accent); }
54
+ .sched-chip:empty { display: none; }
55
+ .head-sep { width: 1px; height: 18px; background: var(--border); flex: none; }
56
+ /* 右上角聚合菜单:锚定在按钮下方右对齐的下拉(不是居中弹窗)。
57
+ 宽高固定、内部分区(工作区 / 外观 / 关于)——一眼能分清「设置值」与「一次性动作」。 */
58
+ .hmenu { position: relative; display: inline-flex; }
59
+ /* 面板设置弹出层:与聚合菜单同一视觉语言,紧贴按钮右对齐展开,不再是全屏遮罩的居中弹窗 */
60
+ .head-setpop { position: relative; display: inline-flex; }
61
+ .setpop { position: absolute; right: 0; top: calc(100% + 10px); z-index: 60; width: min(560px, calc(100vw - 24px)); display: none; }
62
+ .setpop.show { display: block; }
63
+ .setpop .modal { width: 100%; max-height: calc(100vh - 120px); border: 1px solid var(--border-subtle); padding: 16px 18px; }
64
+ .hmenu-pop { position: absolute; right: 0; top: calc(100% + 10px); z-index: 60; width: 252px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 7px; box-shadow: var(--shadow); display: none; }
65
+ .hmenu-pop.show { display: block; }
66
+ /* 分区:块级流,不用 flex —— 否则分段控件会被当成 flex 子项压扁(曾出现与下一分区重叠) */
67
+ .hmenu-group { padding: 3px 0; }
68
+ .hmenu-group + .hmenu-group { margin-top: 4px; padding-top: 7px; border-top: 1px solid var(--border-subtle); }
69
+ .hmenu-title { padding: 4px 10px 5px; font-size: 10.5px; font-weight: 700; letter-spacing: .6px; color: var(--text-3); text-transform: uppercase; }
70
+ .hmenu-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-2); font-size: 13px; font-weight: 600; cursor: pointer; text-align: left; font-family: inherit; text-decoration: none; }
71
+ .hmenu-item:hover { background: var(--hover); color: var(--text); }
72
+ .hmenu-item svg { width: 15px; height: 15px; flex-shrink: 0; }
73
+ .hmenu-item.danger { color: var(--err); }
74
+ .hmenu-item.danger:hover { background: var(--err-soft); color: var(--err); }
75
+ .hmenu-clip { margin-left: auto; color: var(--text-3); font-size: 11px; font-weight: 600; max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
76
+ /* 外观 / 语言:三态分段控件(跟随系统 / 亮色 / 暗色),当前态高亮 —— 主题只有这一个真相源。
77
+ inline-flex + 外层留白:分段是块级流里的一个整体,高度由按钮撑开(不能是 flex 子项被压扁)。
78
+ 类名必须是 mode-seg:.seg 已被额度水位条的分段占用(见下方 .bar 段),同名会互相覆盖——
79
+ 分段控件会被压成 5px 绿条,水位条的分段则会各带 12px 横向 margin 而溢出裁切。 */
80
+ .mode-seg { display: inline-flex; gap: 3px; margin: 2px 6px 4px; padding: 3px; border-radius: 9px; background: var(--tertiary); }
81
+ /* 语言五档:等宽铺满弹窗宽度,避免中/英/日/韩标签长短不一导致右侧留白 */
82
+ .lang-seg { display: flex; width: calc(100% - 12px); }
83
+ .lang-seg .seg-btn { flex: 1 1 0; padding: 6px 0; }
84
+ .seg-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-3); font-size: 12px; font-weight: 600; line-height: 1; cursor: pointer; font-family: inherit; white-space: nowrap; }
85
+ .seg-btn svg { width: 13px; height: 13px; flex-shrink: 0; display: block; }
86
+ .seg-btn:hover { color: var(--text); }
87
+ .seg-btn.on { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
88
+ html[data-theme="dark"] .seg-btn.on { background: var(--active); box-shadow: none; }
89
+ .hmenu-ver { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 10px 4px; margin-top: 4px; border-top: 1px solid var(--border-subtle); color: var(--text-3); font-size: 11px; }
90
+ .hmenu-ver a { color: var(--text-3); text-decoration: none; }
91
+ .hmenu-ver a:hover { color: var(--text); }
92
+ @media (max-width: 720px) { .sched-chip { display: none; } .head-sep { display: none; } }
48
93
  /* 面板设置(右上角齿轮 → 弹窗):访问密码 + 组件显隐与排序 + 账号卡片排序 */
49
94
  .comp-menu { display: flex; flex-direction: column; gap: 8px; }
50
- .comp-menu-title { font-size: 11px; color: var(--text-3); margin: 10px 0 4px; padding-top: 8px; border-top: 1px dashed var(--border-subtle); }
95
+ .comp-menu-title { font-size: 12px; color: var(--text-2); font-weight: 650; margin: 12px 0 4px; padding-top: 10px; border-top: 1px dashed var(--border-subtle); }
51
96
  .comp-menu-title:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
52
97
  .comp-menu .check { margin-right: 0; font-size: 12.5px; color: var(--text); }
53
98
  /* 产品页 notice 的功能分区行 */
@@ -65,12 +110,25 @@
65
110
  .comp-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
66
111
  .comp-row.off .check { opacity: .45; }
67
112
  .comp-ops { display: inline-flex; gap: 2px; flex: none; }
113
+ /* 横向组件 chips:一行放下全部产品,不再一条一行占半屏 */
114
+ .comp-chips { display: flex; flex-wrap: wrap; gap: 6px; }
115
+ .comp-chip { display: inline-flex; align-items: center; gap: 0; padding: 3px 4px 3px 10px; border-radius: 999px; background: var(--hover); font-size: 12px; font-weight: 600; color: var(--text); }
116
+ .comp-chip.off { opacity: .5; filter: grayscale(.9); }
117
+ .comp-chip[data-comp-toggle] { cursor: pointer; }
118
+ .comp-chip .sort-name { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
119
+ .comp-chip .icon-btn.mini { width: 20px; height: 20px; }
120
+ .comp-chip[draggable] { cursor: grab; }
121
+ .comp-chip.dragging { opacity: .4; }
122
+ .comp-chip.drop-before { box-shadow: -3px 0 0 0 var(--ok); }
123
+ .comp-chip.drop-after { box-shadow: 3px 0 0 0 var(--ok); }
124
+ .drag-grip { color: var(--text-3); display: inline-flex; }
125
+ .drag-grip svg { width: 12px; height: 12px; }
68
126
  .icon-btn.mini { width: 20px; height: 20px; border-radius: 6px; font-size: 11px; line-height: 1; padding: 0; }
69
127
  .icon-btn.mini svg { width: 12px; height: 12px; }
70
128
  .sort-sec { border-top: 1px dashed var(--border-subtle); padding-top: 6px; font-size: 12px; }
71
129
  .sort-sec summary { cursor: pointer; color: var(--text-2); user-select: none; display: flex; align-items: center; gap: 6px; }
72
130
  .sort-sec summary::-webkit-details-marker { display: none; }
73
- .sort-hint { font-size: 11px; color: var(--text-3); margin: 5px 0 3px; }
131
+ .sort-hint { font-size: 11px; color: var(--text-3); line-height: 1.65; margin: 4px 0 3px; }
74
132
  .sort-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 2px 0; }
75
133
  .sort-row .sort-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
76
134
  .card.dragging, .tr-card.dragging { opacity: .45; }
@@ -98,6 +156,8 @@
98
156
  .btn svg, .icon-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
99
157
  .icon-btn { width: 28px; height: 28px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid transparent; background: var(--hover); color: var(--text-2); cursor: pointer; transition: all .15s; padding: 0; }
100
158
  .icon-btn:hover { background: var(--active); color: var(--text); }
159
+ .icon-btn:active { transform: scale(.9); }
160
+ .btn:active, .seg-btn:active, .hmenu-item:active { transform: translateY(1px); }
101
161
  .icon-btn.del:hover { background: #ffecec; color: #f53f3f; }
102
162
  html[data-theme="dark"] .icon-btn.del:hover { background: rgba(245,63,63,.15); }
103
163
  .icon-btn.del.armed { background: #f53f3f; color: #fff; }
@@ -195,11 +255,23 @@
195
255
  html[data-theme="dark"] .log-filter button.active { background: rgba(255,255,255,.1); }
196
256
  .collapsed .logs { display: none; }
197
257
 
258
+ /* 首次加载态:HTML 静态可见(无需等 JS/词条),数据首屏后淡出 */
259
+ #boot-busy { position: fixed; inset: 0; z-index: 95; display: flex; align-items: center; justify-content: center; background: var(--bg); transition: opacity .25s; }
260
+ #boot-busy.fade { opacity: 0; pointer-events: none; }
261
+ .lb-card.boot { padding: 18px 26px; font-size: 15px; }
262
+
263
+ /* 语言切换加载态:点击分段按钮立即给反馈,落盘 + 整页刷新期间盖住 */
264
+ #locale-busy { position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center; background: var(--mask); }
265
+ #locale-busy[hidden] { display: none; }
266
+ .lb-card { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 14px 20px; box-shadow: var(--shadow); font-size: 13px; font-weight: 600; color: var(--text); }
267
+ .lb-spin { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--border-subtle); border-top-color: var(--ok); animation: spin .8s linear infinite; }
268
+
198
269
  /* modal */
199
270
  .mask { position: fixed; inset: 0; z-index: 50; background: var(--mask); display: none; align-items: center; justify-content: center; padding: 16px; }
200
271
  .mask.show { display: flex; }
201
272
  .modal { width: 560px; max-width: 100%; max-height: 90vh; overflow-y: auto; background: var(--surface); border-radius: 14px; padding: 16px 18px; box-shadow: var(--shadow); }
202
273
  .modal.sm { width: 360px; }
274
+ .changelog-sec + .changelog-sec { border-top: 1px dashed var(--border-subtle); margin-top: 10px; padding-top: 10px; }
203
275
  .modal-title { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 700; margin-bottom: 12px; }
204
276
  .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
205
277
  .close { border: none; background: none; color: var(--text-3); font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
@@ -209,15 +281,15 @@
209
281
  .tab:hover { color: var(--text); }
210
282
  .tab.active { background: var(--btn-bg); color: var(--btn-fg); }
211
283
  .pane { display: none; } .pane.active { display: block; }
212
- .field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
213
- .field label { font-size: 12px; color: var(--text-2); font-weight: 600; }
284
+ .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
285
+ .field label { font-size: 12.5px; color: var(--text-2); font-weight: 650; }
214
286
  .input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); color: var(--text); outline: none; transition: border-color .15s; }
215
287
  .input::placeholder { color: var(--text-3); opacity: .7; }
216
288
  .input:focus { border-color: #4f86ff; }
217
289
  .input:disabled { opacity: .6; cursor: not-allowed; }
218
290
  .input.mono { font-family: ui-monospace, Consolas, monospace; }
219
291
  .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
220
- .hint { font-size: 12px; color: var(--text-3); line-height: 1.7; }
292
+ .hint { font-size: 11px; color: var(--text-3); line-height: 1.65; }
221
293
  .hint a { color: #4f86ff; word-break: break-all; }
222
294
  .box { border: 1px solid var(--border-subtle); border-radius: 10px; padding: 10px 12px; margin-top: 10px; background: var(--surface-2); }
223
295
  .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); }
@@ -353,30 +425,132 @@
353
425
  </style>
354
426
  </head>
355
427
  <body>
428
+ <!-- 首次加载态:数据首屏后由 boot 隐藏 -->
429
+ <div id="boot-busy"><div class="lb-card boot"><span class="lb-spin"></span><strong>CreditDaddy</strong></div></div>
356
430
  <div class="wrap">
357
431
  <header class="head">
358
432
  <div class="brand">
359
433
  <img class="logo" alt="" src="data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPjxwYXRoIGQ9Ik0zNzIuNDggMTM4LjY2NjY2N0ExMjggMTI4IDAgMCAwIDI5OC42NjY2NjcgMTAxLjU0NjY2N2ExMTcuNzYgMTE3Ljc2IDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzNsNzEuMjUzMzMzIDkxLjUyLTgxLjI4IDI4LjM3MzMzM2ExMC42NjY2NjcgMTAuNjY2NjY3IDAgMCAwLTIuMzQ2NjY3IDE4Ljk4NjY2NyAyNDIuNTYgMjQyLjU2IDAgMCAwIDY5Ljc2IDMyLjg1MzMzMyAyNzMuNzA2NjY3IDI3My43MDY2NjcgMCAwIDAgNTYuOTYgOC4zMnoiIGZpbGw9IiNGRjhBMTQiPjwvcGF0aD48cGF0aCBkPSJNMjQ1Ljc2IDEzOC4wMjY2NjdhMTMwLjk4NjY2NyAxMzAuOTg2NjY3IDAgMCAxIDUyLjA1MzMzMyAyLjEzMzMzM0ExNDguMDUzMzMzIDE0OC4wNTMzMzMgMCAwIDEgMzY2LjcyIDE3MC42NjY2NjdsNS43Ni0zMi44NTMzMzRBMTI4IDEyOCAwIDAgMCAyOTguNjY2NjY3IDEwMS41NDY2NjdhMTE3Ljk3MzMzMyAxMTcuOTczMzMzIDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzN6IiBmaWxsPSIjRkZBQTU0Ij48L3BhdGg+PHBhdGggZD0iTTIzNC42NjY2NjcgOTcwLjY2NjY2N2EyNzcuMzMzMzMzIDMyIDAgMSAwIDU1NC42NjY2NjYgMCAyNzcuMzMzMzMzIDMyIDAgMSAwLTU1NC42NjY2NjYgMFoiIGZpbGw9IiM0NTQxM0MiIG9wYWNpdHk9Ii4xNSI+PC9wYXRoPjxwYXRoIGQ9Ik02MjAuMzczMzMzIDQ2MC41ODY2NjdzMTUxLjA0LTkwLjY2NjY2NyAxODEuMzMzMzM0LTc0LjY2NjY2Ny0xMi41ODY2NjcgMTc0LjI5MzMzMy0xMDYuNjY2NjY3IDIxMy4zMzMzMzMtNzQuNjY2NjY3LTEzOC42NjY2NjctNzQuNjY2NjY3LTEzOC42NjY2NjZ6IiBmaWxsPSIjRkZFNTAwIj48L3BhdGg+PHBhdGggZD0iTTYyMC4zNzMzMzMgNTEzLjkyczE1MS4wNC05MC42NjY2NjcgMTgxLjMzMzMzNC03NC42NjY2NjdhMTQuNzIgMTQuNzIgMCAwIDEgNS4zMzMzMzMgNS45NzMzMzRjNi44MjY2NjctMjkuODY2NjY3IDUuMzMzMzMzLTUzLjU0NjY2Ny01LjMzMzMzMy01OS4zMDY2NjctMzAuMjkzMzMzLTE2LTE4MS4zMzMzMzMgNzQuNjY2NjY3LTE4MS4zMzMzMzQgNzQuNjY2NjY3YTM4Ni45ODY2NjcgMzg2Ljk4NjY2NyAwIDAgMCAwIDYxLjg2NjY2NmMtMC40MjY2NjctNS4zMzMzMzMgMC04LjUzMzMzMyAwLTguNTMzMzMzeiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik00MDMuNjI2NjY3IDQ2MC41ODY2NjdzLTE1MS4wNC05MC42NjY2NjctMTgxLjMzMzMzNC03NC42NjY2NjcgMTIuMzczMzMzIDE3NC4yOTMzMzMgMTA2LjY2NjY2NyAyMTMuMzMzMzMzIDc0LjY2NjY2Ny0xMzguNjY2NjY3IDc0LjY2NjY2Ny0xMzguNjY2NjY2eiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik0yMjIuMjkzMzMzIDQzOS4yNTMzMzNjMzAuMjkzMzMzLTE2IDE4MS4zMzMzMzMgNzQuNjY2NjY3IDE4MS4zMzMzMzQgNzQuNjY2NjY3djguNTMzMzMzYTM3My41NDY2NjcgMzczLjU0NjY2NyAwIDAgMCAwLTYxLjg2NjY2NnMtMTUxLjA0LTkwLjY2NjY2Ny0xODEuMzMzMzM0LTc0LjY2NjY2N2MtMTAuNjY2NjY3IDUuNzYtMTIuMTYgMjkuNDQtNS4zMzMzMzMgNTkuMzA2NjY3YTEzLjQ0IDEzLjQ0IDAgMCAxIDUuMzMzMzMzLTUuOTczMzM0eiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik0yODggNjUwLjY2NjY2N2EyNjYuNjY2NjY3IDIyNCA5MCAxIDAgNDQ4IDAgMjY2LjY2NjY2NyAyMjQgOTAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik02ODAuMTA2NjY3IDQ3NC4yNGEyMjQgMjI0IDAgMCAxLTMzNi4yMTMzMzQgMEEyMTAuNTYgMjEwLjU2IDAgMCAxIDUxMiAzODRhMjEwLjU2IDIxMC41NiAwIDAgMSAxNjguMTA2NjY3IDkwLjI0eiIgZmlsbD0iI0VCQ0IwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMjY2LjY2NjY2N20tMjI0IDBhMjI0IDIyNCAwIDEgMCA0NDggMCAyMjQgMjI0IDAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMTAxLjk3MzMzM2EyMjQuMjEzMzMzIDIyNC4yMTMzMzMgMCAwIDEgMjIxLjg2NjY2NyAxOTQuMzQ2NjY3IDIyNCAyMjQgMCAxIDAtNDQzLjczMzMzNCAwQTIyMy43ODY2NjcgMjIzLjc4NjY2NyAwIDAgMSA1MTIgMTAxLjk3MzMzM3oiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48cGF0aCBkPSJNODMzLjQ5MzMzMyA1OTguODI2NjY3bC03MC40IDcwLjRhMTguMzQ2NjY3IDE4LjM0NjY2NyAwIDAgMS0yNS44MTMzMzMgMEw2NDQuMDUzMzMzIDU3NkExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAwIDYxOC42NjY2NjcgNTc2bC05My4wMTMzMzQgOTMuMjI2NjY3YTE4Ljc3MzMzMyAxOC43NzMzMzMgMCAwIDEtMjYuMDI2NjY2IDBMNDA1LjMzMzMzMyA1NzZhMTguMTMzMzMzIDE4LjEzMzMzMyAwIDAgMC0yNS44MTMzMzMgMGwtOTMuMjI2NjY3IDkzLjIyNjY2N2ExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwbC03MC40LTcwLjRhOS4xNzMzMzMgOS4xNzMzMzMgMCAwIDAtMTUuNzg2NjY3IDYuODI2NjY2QzE4OS44NjY2NjcgODQxLjgxMzMzMyAzMzQuOTMzMzMzIDk2MCA1MTIgOTYwczMyMi4xMzMzMzMtMTE4LjE4NjY2NyAzMzcuMjgtMzU0LjM0NjY2N2E5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZ6IiBmaWxsPSIjRjBGMEYwIj48L3BhdGg+PHBhdGggZD0iTTE5MC41MDY2NjcgNjY1LjgxMzMzM2w3MC40IDYxLjIyNjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAwIDI1LjgxMzMzMyAwbDkzLjIyNjY2Ny04MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMSAyNS44MTMzMzMgMGw5My4yMjY2NjcgODEuMDY2NjY3YTIxLjMzMzMzMyAyMS4zMzMzMzMgMCAwIDAgMjYuMDI2NjY2IDBsOTMuNjUzMzM0LTgxLjA2NjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAxIDI2LjAyNjY2NiAwbDkzLjIyNjY2NyA4MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMCAyNS44MTMzMzMgMGw3MC40LTYxLjIyNjY2N2E5LjgxMzMzMyA5LjgxMzMzMyAwIDAgMSA4LjUzMzMzNC0xLjkyIDUzNi4xMDY2NjcgNTM2LjEwNjY2NyAwIDAgMCA3LjI1MzMzMy01OC4yNCA5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZsLTcwLjQgNzAuNGExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwTDY0NC4wNTMzMzMgNTc2QTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDAgNjE4LjY2NjY2NyA1NzZsLTkzLjAxMzMzNCA5My4yMjY2NjdhMTguNzczMzMzIDE4Ljc3MzMzMyAwIDAgMS0yNi4wMjY2NjYgMEw0MDUuMzMzMzMzIDU3NmExOC4xMzMzMzMgMTguMTMzMzMzIDAgMCAwLTI1LjgxMzMzMyAwbC05My4yMjY2NjcgOTMuMjI2NjY3YTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDEtMjUuODEzMzMzIDBsLTcwLjQtNzAuNGE5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjcgNi44MjY2NjYgNTM3LjE3MzMzMyA1MzcuMTczMzMzIDAgMCAwIDcuMDQgNTguMjQgMTAuMDI2NjY3IDEwLjAyNjY2NyAwIDAgMSA5LjE3MzMzNCAxLjkyeiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPjxwYXRoIGQ9Ik00OTguMTMzMzMzIDE4NC41MzMzMzNtLTI5LjAxMzMzMyAwYTI5LjAxMzMzMyAyOS4wMTMzMzMgMCAxIDAgNTguMDI2NjY3IDAgMjkuMDEzMzMzIDI5LjAxMzMzMyAwIDEgMC01OC4wMjY2NjcgMFoiIGZpbGw9IiM0NTQxM0MiPjwvcGF0aD48cGF0aCBkPSJNNTc2IDI5MC45ODY2NjdjMCAxMS43MzMzMzMtMTUuNzg2NjY3IDIxLjMzMzMzMy0zNC45ODY2NjcgMjEuMzMzMzMzcy0zNS4yLTkuNi0zNS4yLTIxLjMzMzMzMyAxNS43ODY2NjctMjEuMzMzMzMzIDM1LjItMjEuMzMzMzM0IDM0Ljk4NjY2NyA5LjYgMzQuOTg2NjY3IDIxLjMzMzMzNHoiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48L3N2Zz4=">
360
434
  <div>
361
435
  <div class="title">CreditDaddy</div>
362
- <div class="ver" id="ver">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取</div>
436
+ <div class="ver" id="ver" data-i18n="Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取</div>
363
437
  </div>
364
438
  </div>
365
439
  <div class="head-right">
366
- <span id="sched" class="num"></span>
367
- <button class="icon-btn" id="btn-comp" title="面板设置:访问密码、组件显隐与排序、账号卡片排序"></button>
368
- <a class="icon-btn" id="btn-home" href="https://github.com/techysy/CreditDaddy" target="_blank" rel="noopener" title="项目主页(GitHub)"></a>
369
- <button class="icon-btn" id="btn-theme" title="切换亮色 / 暗色"></button>
440
+ <!-- 只读状态:下一次自动领取(领取中变强调色)。窄屏自动隐藏,操作按钮优先 -->
441
+ <span id="sched" class="sched-chip"></span>
442
+ <span class="head-sep"></span>
443
+ <button class="icon-btn" id="btn-theme" title="主题"></button>
444
+ <div class="head-setpop">
445
+ <button class="icon-btn" id="btn-comp" title="面板设置:访问密码、组件显隐与排序、账号卡片排序"></button>
446
+ <!-- 面板设置(右上角齿轮) -->
447
+ <div class="setpop" id="m-settings">
448
+ <div class="modal">
449
+ <div class="modal-title"><span data-i18n="面板设置">面板设置</span><button class="close" data-close>✕</button></div>
450
+ <div class="tabs" id="set-tabs">
451
+ <button class="tab active" data-set-tab="net" data-i18n="通用">通用</button>
452
+ <button class="tab" data-set-tab="comp" data-i18n="组件与排序">组件与排序</button>
453
+ </div>
454
+
455
+ <div class="pane active" data-set-pane="net">
456
+ <div class="field">
457
+ <label data-i18n="全局自动签到">全局自动签到</label>
458
+ <label class="check"><input type="checkbox" id="cfg-auto-checkin"> <span data-i18n="开启自动签到">开启自动签到</span></label>
459
+ <div style="display:flex;gap:8px;align-items:center;margin-top:6px">
460
+ <span class="hint" data-i18n="签到频率">签到频率</span>
461
+ <select class="input" id="cfg-checkin-interval" style="width:auto">
462
+ <option value="60" data-i18n="每 1 小时">每 1 小时</option>
463
+ <option value="120" data-i18n="每 2 小时">每 2 小时</option>
464
+ <option value="240" data-i18n="每 4 小时">每 4 小时</option>
465
+ <option value="480" data-i18n="每 8 小时">每 8 小时</option>
466
+ </select>
467
+ </div>
468
+ <div class="hint" data-i18n="作用于全部产品的定时签到轮(ZCode 活动领取在自身页签单独配置);修改立即生效,下一轮按新间隔执行。">作用于全部产品的定时签到轮(ZCode 活动领取在自身页签单独配置);修改立即生效,下一轮按新间隔执行。</div>
469
+ </div>
470
+ <div class="field">
471
+ <label data-i18n="WorkBuddy 国际版 活跃请求">WorkBuddy 国际版 活跃请求</label>
472
+ <label class="check"><input type="checkbox" id="cfg-wb-activity"> <span data-i18n="定期发送活跃请求(保持资格,不保证奖励)">定期发送活跃请求(保持资格,不保证奖励)</span></label>
473
+ <div class="hint" data-i18n="关闭后本机不再对国际版账号自动/手动发活跃请求;卡片与仪表盘的「活跃」入口一同隐藏。">关闭后本机不再对国际版账号自动/手动发活跃请求;卡片与仪表盘的「活跃」入口一同隐藏。</div>
474
+ </div>
475
+ <div class="field">
476
+ <label data-i18n="面板访问密码">面板访问密码</label>
477
+ <div id="sec-block"></div>
478
+ </div>
479
+ <div class="field">
480
+ <label data-i18n="日志显示">日志显示</label>
481
+ <select class="input" id="cfg-logs-display" style="width:auto">
482
+ <option value="expanded" data-i18n="默认展开">默认展开</option>
483
+ <option value="collapsed" data-i18n="默认收起">默认收起</option>
484
+ </select>
485
+ </div>
486
+ <div class="field">
487
+ <label data-i18n="网络出口(全局)">网络出口(全局)</label>
488
+ <label class="check"><input type="radio" name="znet-order" id="znet-direct"> 直连优先(代理兜底)</label>
489
+ <label class="check"><input type="radio" name="znet-order" id="znet-proxy"> 代理优先(直连兜底)</label>
490
+ <label style="margin-top:6px" data-i18n="出口代理(http://host:port,支持口令 http://user:pass@host:port)">出口代理(http://host:port,支持口令 http://user:pass@host:port)</label>
491
+ <input class="input" id="znet-url" placeholder="http://192.168.31.101:7890">
492
+ <div class="hint" data-i18n="作用于所有出网请求:ZCode 上游、Qoder 国际版登录与凭据探测(按 IP 识别地区,国内直连会被拦)等。留空 = 清除代理,退回 HTTPS_PROXY 环境变量。">作用于所有出网请求:ZCode 上游、Qoder 国际版登录与凭据探测(按 IP 识别地区,国内直连会被拦)等。留空 = 清除代理,退回 HTTPS_PROXY 环境变量。</div>
493
+ <div style="display:flex;gap:8px;align-items:center;margin-top:6px"><button class="btn primary" id="znet-ok" data-i18n="保存出口">保存出口</button><span class="hint" id="znet-state"></span></div>
494
+ </div>
495
+ </div>
496
+
497
+ <div class="pane" data-set-pane="comp">
498
+ <div class="comp-menu" id="comp-menu"></div>
499
+ </div>
500
+
501
+ <div class="modal-actions"><button class="btn" data-close>关闭</button></div>
502
+ </div>
503
+ </div>
504
+
505
+ </div>
506
+ <!-- 聚合菜单(最右侧):工作区 / 外观 / 语言 / 关于四分区,下拉紧贴按钮(图标由 JS 注入,Lucide 描边同款)。
507
+ 主题两处入口联动:菜单分段控件(跟随系统/亮色/暗色) + 头部独立按钮一键循环,都经 applyThemeMode 同一存储 -->
508
+ <div class="hmenu">
509
+ <button class="icon-btn" id="btn-header-menu" title="更多操作:更新、主题、重启、反馈"></button>
510
+ <div class="hmenu-pop" id="hmenu-pop">
511
+ <div class="hmenu-group">
512
+ <div class="hmenu-title" data-i18n="外观">外观</div>
513
+ <div class="mode-seg" id="theme-seg">
514
+ <button class="seg-btn" data-theme-mode="auto" title="跟随系统亮色 / 暗色设置" data-i18n="跟随系统">跟随系统</button>
515
+ <button class="seg-btn" data-theme-mode="light" title="始终使用亮色" data-i18n="亮色">亮色</button>
516
+ <button class="seg-btn" data-theme-mode="dark" title="始终使用暗色" data-i18n="暗色">暗色</button>
517
+ </div>
518
+ </div>
519
+ <div class="hmenu-group">
520
+ <div class="hmenu-title" data-i18n="语言">语言</div>
521
+ <div class="mode-seg lang-seg" id="locale-seg">
522
+ <button class="seg-btn" data-locale="zh-CN" title="简体中文">简</button>
523
+ <button class="seg-btn" data-locale="zh-TW" title="繁體中文">繁</button>
524
+ <button class="seg-btn" data-locale="en" title="English">EN</button>
525
+ <button class="seg-btn" data-locale="ja" title="日本語">日</button>
526
+ <button class="seg-btn" data-locale="ko" title="한국어">한</button>
527
+ </div>
528
+ </div>
529
+ <div class="hmenu-group">
530
+ <div class="hmenu-title" data-i18n="工作区">工作区</div>
531
+ <button class="hmenu-item" id="hm-update">检查更新</button>
532
+ <button class="hmenu-item" id="changelog-brief-btn">更新日志</button>
533
+ </div>
534
+ <div class="hmenu-group">
535
+ <div class="hmenu-title" data-i18n="关于">关于</div>
536
+ <a class="hmenu-item" id="hm-home" href="https://github.com/techysy/CreditDaddy" target="_blank" rel="noopener" data-i18n="项目主页">项目主页</a>
537
+ <button class="hmenu-item" id="feedback-btn" data-i18n="建议反馈">建议反馈</button>
538
+ <button class="hmenu-item" id="donate-btn" data-i18n="捐赠支持">捐赠支持</button>
539
+ <button class="hmenu-item danger" id="hm-restart" data-i18n="重启 CreditDaddy">重启 CreditDaddy</button>
540
+ </div>
541
+ <div class="hmenu-ver"><span id="hmenu-version">CreditDaddy</span><a id="hmenu-channel" href="https://github.com/techysy/CreditDaddy/releases/latest" target="_blank" rel="noopener" hidden>Releases</a></div>
542
+ </div>
543
+ </div>
370
544
  </div>
371
545
  </header>
372
546
 
373
547
  <nav class="nav" id="nav">
374
- <button data-view="dash" class="active">仪表盘</button>
548
+ <button data-view="dash" class="active" data-i18n="仪表盘">仪表盘</button>
375
549
  <button data-view="qoder">Qoder<span class="cnt" id="cnt-qoder">0</span></button>
376
550
  <button data-view="workbuddy">WorkBuddy<span class="cnt" id="cnt-workbuddy">0</span></button>
377
551
  <button data-view="zcode">ZCode<span class="cnt" id="cnt-zcode">0</span></button>
378
552
  <button data-view="mirasim">mirasim<span class="cnt" id="cnt-mirasim">0</span></button>
379
- <button data-view="catpaw">妙手<span class="cnt" id="cnt-catpaw">0</span></button>
553
+ <button data-view="catpaw"><span data-i18n="妙手">妙手</span><span class="cnt" id="cnt-catpaw">0</span></button>
380
554
  <button data-view="trae">Trae<span class="cnt" id="cnt-trae">0</span></button>
381
555
  <button data-view="minimax">MiniMax<span class="cnt" id="cnt-minimax">0</span></button>
382
556
  <button data-view="tenrouter">10Router<span class="cnt" id="cnt-tenrouter">–</span></button>
@@ -386,7 +560,7 @@
386
560
  <section class="view active" id="view-dash">
387
561
  <div class="panel dash-panel">
388
562
  <div class="toolbar">
389
- <div class="summary"><span class="dash-title">总览</span></div>
563
+ <div class="summary"><span class="dash-title" data-i18n="总览">总览</span></div>
390
564
  <div class="actions">
391
565
  <button class="btn primary" id="btn-checkin-all"></button>
392
566
  <button class="btn" id="btn-add-dash"></button>
@@ -398,8 +572,8 @@
398
572
  </div>
399
573
  <div class="prods" id="prods"></div>
400
574
  <div class="dash-cols">
401
- <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>
402
- <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>
575
+ <section class="pcard" data-fold="recent"><div class="pcard-title fold-head"><span data-i18n="最近记录">最近记录</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>
576
+ <section class="pcard" data-fold="attention"><div class="pcard-title fold-head"><span data-i18n="需要处理">需要处理</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>
403
577
  </div>
404
578
  </section>
405
579
 
@@ -408,23 +582,22 @@
408
582
  <div class="panel">
409
583
  <div class="toolbar">
410
584
  <div class="summary">
411
- <div class="stat"><span>账号</span><strong id="s-count">0</strong></div>
585
+ <div class="stat"><span data-i18n="账号">账号</span><strong id="s-count">0</strong></div>
412
586
  <div class="divider"></div>
413
587
  <div id="s-done-wrap" style="display:contents">
414
- <div class="stat"><span>今日已领</span><strong id="s-done">0</strong></div>
588
+ <div class="stat"><span data-i18n="今日已领">今日已领</span><strong id="s-done">0</strong></div>
415
589
  <div class="divider"></div>
416
590
  </div>
417
- <div class="stat"><span id="s-credits-label">剩余积分</span><strong id="s-credits">-</strong></div>
591
+ <div class="stat"><span id="s-credits-label" data-i18n="剩余积分">剩余积分</span><strong id="s-credits">-</strong></div>
418
592
  <div class="divider" id="region-div"></div>
419
593
  <div class="seg-ctl" id="region">
420
- <button data-r="" class="active">全部</button><button data-r="intl">国际版</button><button data-r="cn">国内版</button>
594
+ <button data-r="" class="active">全部</button><button data-r="intl" data-i18n="国际版">国际版</button><button data-r="cn" data-i18n="国内版">国内版</button>
421
595
  </div>
422
596
  </div>
423
597
  <div class="actions">
424
598
  <button class="btn primary" id="btn-checkin"></button>
425
599
  <span id="z-actions" style="display:none">
426
600
  <button class="btn" id="zt-ac"></button>
427
- <button class="btn" id="zt-net"></button>
428
601
  <button class="btn" id="zt-gw"></button>
429
602
  <button class="btn" id="zt-open" style="display:none"></button>
430
603
  </span>
@@ -442,62 +615,65 @@
442
615
  </div>
443
616
  </div>
444
617
  <div class="notice" id="notice" style="display:none"></div>
445
- <div class="list" id="list"><div class="empty">加载中…</div></div>
618
+ <div class="list" id="list"><div class="empty" data-i18n="加载中…">加载中…</div></div>
446
619
  </div>
447
620
  </section>
448
621
 
449
622
  <!-- 10Router:其他供应商额度 + 用量同步 -->
450
623
  <section class="view" id="view-10r">
451
- <div class="panel" id="tr-panel"><div class="empty">加载中…</div></div>
624
+ <div class="panel" id="tr-panel"><div class="empty" data-i18n="加载中…">加载中…</div></div>
452
625
  </section>
453
626
 
454
627
  <!-- 日志卡:运行日志 + 各网关调用日志,卡内标签切换(默认跟随当前页,可手动切) -->
455
628
  <section class="pcard collapsed" id="log-card">
456
629
  <div class="pcard-title">
457
630
  <div class="seg-ctl" id="log-tabs">
458
- <button data-log-tab="run" class="active">运行日志</button>
459
- <button data-log-tab="zcode">ZCode 网关</button>
460
- <button data-log-tab="minimax">MiniMax 网关</button>
461
- <button data-log-tab="trae">Trae 网关</button>
631
+ <button data-log-tab="run" class="active" data-i18n="运行日志">运行日志</button>
632
+ <button data-log-tab="zcode" data-i18n="ZCode 网关">ZCode 网关</button>
633
+ <button data-log-tab="minimax" data-i18n="MiniMax 网关">MiniMax 网关</button>
634
+ <button data-log-tab="trae" data-i18n="Trae 网关">Trae 网关</button>
462
635
  </div>
463
- <div class="log-filter" id="log-filter">
464
- <button data-log-level="all" class="active">全部</button>
465
- <button data-log-level="warn">只看提醒</button>
636
+ <div style="display:flex;align-items:center;gap:8px">
637
+ <div class="log-filter" id="log-filter">
638
+ <button data-log-level="all" class="active">全部</button>
639
+ <button data-log-level="warn" data-i18n="只看提醒">只看提醒</button>
640
+ </div>
641
+ <button class="btn" id="btn-logs-history" data-i18n="加载历史">加载历史</button>
642
+ <button class="btn" id="btn-logs" data-i18n="展开">展开</button>
466
643
  </div>
467
- <button class="btn" id="btn-logs">展开</button>
468
644
  </div>
469
- <div class="logs" id="logs">(暂无)</div>
645
+ <div class="logs" id="logs" data-i18n="(暂无)">(暂无)</div>
470
646
  </section>
471
647
  </div>
472
648
 
473
649
  <!-- 添加账号 -->
474
650
  <div class="mask" id="m-add">
475
651
  <div class="modal">
476
- <div class="modal-title"><span>添加账号</span><button class="close" data-close>✕</button></div>
652
+ <div class="modal-title"><span data-i18n="添加账号">添加账号</span><button class="close" data-close>✕</button></div>
477
653
  <div class="tabs">
478
- <button class="tab active" data-tab="local">本机导入</button>
479
- <button class="tab" data-tab="device">浏览器登录</button>
480
- <button class="tab" data-tab="token">粘贴 Token</button>
654
+ <button class="tab active" data-tab="local" data-i18n="本机导入">本机导入</button>
655
+ <button class="tab" data-tab="device" data-i18n="浏览器登录">浏览器登录</button>
656
+ <button class="tab" data-tab="token" data-i18n="粘贴 Token">粘贴 Token</button>
481
657
  </div>
482
658
 
483
659
  <div class="pane active" data-pane="local">
484
660
  <div class="hint" id="local-hint"></div>
485
- <div class="row" style="margin-top:10px"><button class="btn primary" id="btn-scan">重新扫描</button><span class="hint" id="scan-status"></span></div>
486
- <div class="box" id="scan-result"><div class="hint">点击「重新扫描」</div></div>
661
+ <div class="row" style="margin-top:10px"><button class="btn primary" id="btn-scan" data-i18n="重新扫描">重新扫描</button><span class="hint" id="scan-status"></span></div>
662
+ <div class="box" id="scan-result"><div class="hint" data-i18n="点击「重新扫描」">点击「重新扫描」</div></div>
487
663
  </div>
488
664
 
489
665
  <div class="pane" data-pane="device">
490
666
  <div class="hint" id="dev-hint" style="margin-bottom:10px"></div>
491
667
  <div class="field">
492
- <label>登录到</label>
668
+ <label data-i18n="登录到">登录到</label>
493
669
  <select class="input" id="dev-provider">
494
670
  <option value="qoder">Qoder 国际版</option><option value="qoder-cn">Qoder 国内版</option>
495
- <option value="workbuddy">WorkBuddy / CodeBuddy 国内版</option><option value="workbuddy-intl">WorkBuddy / CodeBuddy 国际版</option>
496
- <option value="zcode-bigmodel">ZCode · BigModel(智谱)</option><option value="zcode-zai">ZCode · Z.ai(国际)</option>
671
+ <option value="workbuddy" data-i18n="WorkBuddy / CodeBuddy 国内版">WorkBuddy / CodeBuddy 国内版</option><option value="workbuddy-intl" data-i18n="WorkBuddy / CodeBuddy 国际版">WorkBuddy / CodeBuddy 国际版</option>
672
+ <option value="zcode-bigmodel" data-i18n="ZCode · BigModel(智谱)">ZCode · BigModel(智谱)</option><option value="zcode-zai" data-i18n="ZCode · Z.ai(国际)">ZCode · Z.ai(国际)</option>
497
673
  <option value="minimax">MiniMax Code</option>
498
674
  </select>
499
675
  </div>
500
- <div class="row"><button class="btn primary" id="btn-device">生成授权链接</button><button class="btn" id="dev-cancel" style="display:none">取消</button></div>
676
+ <div class="row"><button class="btn primary" id="btn-device" data-i18n="生成授权链接">生成授权链接</button><button class="btn" id="dev-cancel" style="display:none">取消</button></div>
501
677
  <div class="box" id="device-box" style="display:none">
502
678
  <div class="hint" id="dev-status"></div>
503
679
  <div class="hint" style="margin-top:6px"><a id="dev-link" href="#" target="_blank" rel="noopener"></a></div>
@@ -506,12 +682,12 @@
506
682
 
507
683
  <div class="pane" data-pane="token">
508
684
  <div class="field">
509
- <label>版本</label>
510
- <select class="input" id="f-provider"><option value="qoder">Qoder 国际版</option><option value="qoder-cn">Qoder 国内版</option><option value="workbuddy">WorkBuddy 国内版</option><option value="workbuddy-intl">WorkBuddy 国际版</option><option value="minimax">MiniMax</option></select>
685
+ <label data-i18n="版本">版本</label>
686
+ <select class="input" id="f-provider"><option value="qoder">Qoder 国际版</option><option value="qoder-cn">Qoder 国内版</option><option value="workbuddy" data-i18n="WorkBuddy 国内版">WorkBuddy 国内版</option><option value="workbuddy-intl" data-i18n="WorkBuddy 国际版">WorkBuddy 国际版</option><option value="minimax">MiniMax</option></select>
511
687
  </div>
512
- <div class="field"><label>备注名(可选)</label><input class="input" id="f-name" placeholder="留空则自动读取昵称"></div>
688
+ <div class="field"><label data-i18n="备注名(可选)">备注名(可选)</label><input class="input" id="f-name" placeholder="留空则自动读取昵称"></div>
513
689
  <div class="field"><label>Token</label><input class="input mono" id="f-token" placeholder="dt-… / jt-… 或 PAT(pt-…,自动兑换)"></div>
514
- <div class="modal-actions"><button class="btn primary" id="btn-add-token">添加</button></div>
690
+ <div class="modal-actions"><button class="btn primary" id="btn-add-token" data-i18n="添加">添加</button></div>
515
691
  </div>
516
692
  </div>
517
693
  </div>
@@ -519,47 +695,28 @@
519
695
  <!-- 导出 -->
520
696
  <div class="mask" id="m-export">
521
697
  <div class="modal sm">
522
- <div class="modal-title"><span>导出账号</span><button class="close" data-close>✕</button></div>
698
+ <div class="modal-title"><span data-i18n="导出账号">导出账号</span><button class="close" data-close>✕</button></div>
523
699
  <div class="field">
524
- <label>范围</label>
525
- <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><option value="p:minimax">MiniMax</option></select>
700
+ <label data-i18n="范围">范围</label>
701
+ <select class="input" id="ex-provider"><option value="" data-i18n="全部账号">全部账号</option><option value="p:qoder" data-i18n="Qoder(全部)">Qoder(全部)</option><option value="qoder" data-i18n="仅 Qoder 国际版">仅 Qoder 国际版</option><option value="qoder-cn" data-i18n="仅 Qoder 国内版">仅 Qoder 国内版</option><option value="p:workbuddy" data-i18n="WorkBuddy(全部)">WorkBuddy(全部)</option><option value="workbuddy" data-i18n="仅 WorkBuddy 国内版">仅 WorkBuddy 国内版</option><option value="workbuddy-intl" data-i18n="仅 WorkBuddy 国际版">仅 WorkBuddy 国际版</option><option value="zcode">ZCode</option><option value="p:mirasim">mirasim</option><option value="p:catpaw" data-i18n="妙手">妙手</option><option value="p:trae">Trae</option><option value="p:minimax">MiniMax</option></select>
526
702
  </div>
527
703
  <div class="field">
528
- <label>加密口令(必填)</label>
704
+ <label data-i18n="加密口令(必填)">加密口令(必填)</label>
529
705
  <input class="input" id="ex-password" type="password" placeholder="至少 4 位" autocomplete="new-password">
530
706
  </div>
531
- <div class="hint">导出文件一律加密(10router 迁移格式,scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。<b>口令不会被保存</b>,请牢记——遗失后文件无法解开、无法导入。</div>
707
+ <div class="hint">导出文件一律加密(10router 迁移格式,scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。<b data-i18n="口令不会被保存">口令不会被保存</b>,请牢记——遗失后文件无法解开、无法导入。</div>
532
708
  <div class="notice" id="ex-session-note" style="display:none"></div>
533
- <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-do-export">导出</button></div>
709
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-do-export" data-i18n="导出">导出</button></div>
534
710
  </div>
535
711
  </div>
536
712
 
537
713
  <!-- 口令输入 -->
538
714
  <div class="mask" id="m-pass">
539
715
  <div class="modal sm">
540
- <div class="modal-title"><span>输入口令</span><button class="close" data-close>✕</button></div>
541
- <div class="hint" id="pass-hint">该文件已加密,请输入导出时设置的口令。</div>
716
+ <div class="modal-title"><span data-i18n="输入口令">输入口令</span><button class="close" data-close>✕</button></div>
717
+ <div class="hint" id="pass-hint" data-i18n="该文件已加密,请输入导出时设置的口令。">该文件已加密,请输入导出时设置的口令。</div>
542
718
  <div class="field" style="margin-top:10px"><input class="input" id="pass-input" type="password"></div>
543
- <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-pass-ok">导入</button></div>
544
- </div>
545
- </div>
546
-
547
- <!-- 面板设置(右上角齿轮) -->
548
- <div class="mask" id="m-settings">
549
- <div class="modal">
550
- <div class="modal-title"><span>面板设置</span><button class="close" data-close>✕</button></div>
551
- <div class="comp-menu" id="comp-menu"></div>
552
- <div class="field" id="upd-entry" style="display:none;margin-top:12px">
553
- <label>版本与更新</label>
554
- <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>
555
- </div>
556
- <div class="field" style="margin-top:12px;padding-top:10px;border-top:1px dashed var(--border-subtle)">
557
- <label>服务维护</label>
558
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
559
- <button class="btn" id="btn-restart-app">重启 CreditDaddy</button>
560
- </div>
561
- </div>
562
- <div class="modal-actions"><button class="btn" data-close>关闭</button></div>
719
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-pass-ok" data-i18n="导入">导入</button></div>
563
720
  </div>
564
721
  </div>
565
722
 
@@ -572,24 +729,33 @@
572
729
  </div>
573
730
  </div>
574
731
 
732
+ <!-- 更新日志(简要版):解析随包 CHANGELOG.md,主题行 + 板块标题 -->
733
+ <div class="mask" id="m-changelog">
734
+ <div class="modal sm">
735
+ <div class="modal-title"><span>更新日志</span><button class="close" data-close>✕</button></div>
736
+ <div id="changelog-body" style="max-height:56vh;overflow-y:auto"></div>
737
+ <div class="modal-actions"><button class="btn" id="btn-changelog-full" data-i18n="GitHub 完整日志">GitHub 完整日志</button><button class="btn primary" data-close>关闭</button></div>
738
+ </div>
739
+ </div>
740
+
575
741
  <!-- 10Router 设置 -->
576
742
  <div class="mask" id="m-10r">
577
743
  <div class="modal">
578
- <div class="modal-title"><span>10Router 连接设置</span><button class="close" data-close>✕</button></div>
579
- <div class="hint">填 10Router 的访问地址和仪表盘里创建的<b>虚拟 key(sk-…)</b>。key 只保存在 CreditDaddy 守护进程的数据目录(0600),面板里只显示脱敏值。</div>
580
- <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>
581
- <div class="field"><label>虚拟 key</label><input class="input mono" id="tr-key" placeholder="sk-…" autocomplete="off"></div>
582
- <div class="field"><label>10Router 面板密码(可选)</label><input class="input mono" id="tr-admin-pw" type="password" placeholder="配置后「同步账号」走 OAuth 通道(带 refreshToken 自动续期);留空保持不变" autocomplete="new-password"></div>
583
- <div class="field"><label>账号同步</label>
584
- <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>
744
+ <div class="modal-title"><span data-i18n="10Router 连接设置">10Router 连接设置</span><button class="close" data-close>✕</button></div>
745
+ <div class="hint">填 10Router 的访问地址和仪表盘里创建的<b data-i18n="虚拟 key(sk-…)">虚拟 key(sk-…)</b>。key 只保存在 CreditDaddy 守护进程的数据目录(0600),面板里只显示脱敏值。</div>
746
+ <div class="field" style="margin-top:12px"><label data-i18n="10Router 地址">10Router 地址</label><input class="input mono" id="tr-endpoint" placeholder="http://127.0.0.1:20128(站点根地址,不要带 /v1)"></div>
747
+ <div class="field"><label data-i18n="虚拟 key">虚拟 key</label><input class="input mono" id="tr-key" placeholder="sk-…" autocomplete="off"></div>
748
+ <div class="field"><label data-i18n="10Router 面板密码(可选)">10Router 面板密码(可选)</label><input class="input mono" id="tr-admin-pw" type="password" placeholder="配置后「同步账号」走 OAuth 通道(带 refreshToken 自动续期);留空保持不变" autocomplete="new-password"></div>
749
+ <div class="field"><label data-i18n="账号同步">账号同步</label>
750
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="btn" id="tr-sync-accounts" data-i18n="同步账号到 10R">同步账号到 10R</button><span class="hint" data-i18n="把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接;zcode 待反代就绪后支持">把本机 Qoder / WorkBuddy 账号推为 10Router 上游连接;zcode 待反代就绪后支持</span></div>
585
751
  </div>
586
- <div class="field"><label>显示</label>
752
+ <div class="field"><label data-i18n="显示">显示</label>
587
753
  <div><label class="check"><input type="checkbox" id="tr-hide-empty"> 「全部」视图隐藏没有额度 / 余额为 0 的供应商汇总卡</label></div>
588
754
  </div>
589
755
  <div class="hint" id="tr-test-msg"></div>
590
756
  <div class="modal-actions">
591
- <button class="btn" id="tr-disconnect" style="margin-right:auto">断开</button>
592
- <button class="btn" id="tr-test">测试连接</button>
757
+ <button class="btn" id="tr-disconnect" style="margin-right:auto" data-i18n="断开">断开</button>
758
+ <button class="btn" id="tr-test" data-i18n="测试连接">测试连接</button>
593
759
  <button class="btn primary" id="tr-save">保存</button>
594
760
  </div>
595
761
  </div>
@@ -598,20 +764,20 @@
598
764
  <!-- 10Router 用量同步 -->
599
765
  <div class="mask" id="m-trsync">
600
766
  <div class="modal sm">
601
- <div class="modal-title"><span>用量同步(同 10router-sync 插件)</span><button class="close" data-close>✕</button></div>
767
+ <div class="modal-title"><span data-i18n="用量同步(同 10router-sync 插件)">用量同步(同 10router-sync 插件)</span><button class="close" data-close>✕</button></div>
602
768
  <div class="field"><label class="check"><input type="checkbox" id="tr-auto"> 每小时自动同步本机用量到 10Router</label></div>
603
- <div class="field"><label>同步来源</label><div id="tr-sources"></div></div>
604
- <div class="hint">ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。「10Router 本机」只把实例原生产生的行标记为网关同步。10Router 按行签名去重,重复同步不会产生重复数据。</div>
769
+ <div class="field"><label data-i18n="同步来源">同步来源</label><div id="tr-sources"></div></div>
770
+ <div class="hint" data-i18n="ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。「10Router 本机」只把实例原生产生的行标记为网关同步。10Router 按行签名去重,重复同步不会产生重复数据。">ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。「10Router 本机」只把实例原生产生的行标记为网关同步。10Router 按行签名去重,重复同步不会产生重复数据。</div>
605
771
  <div class="hint" id="tr-sync-status" style="margin-top:8px"></div>
606
- <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>
772
+ <div class="modal-actions"><button class="btn" style="margin-right:auto" id="tr-sync-now" data-i18n="立即同步一次">立即同步一次</button><button class="btn" data-close>取消</button><button class="btn primary" id="tr-sync-save">保存</button></div>
607
773
  </div>
608
774
  </div>
609
775
 
610
776
  <!-- ZCode 活动领取 -->
611
777
  <div class="mask" id="m-claim">
612
778
  <div class="modal">
613
- <div class="modal-title"><span>活动领取</span><button class="close" data-close>✕</button></div>
614
- <div class="hint">从 ZCode 官方活动接口领取(billing/preview → billing/claim)。领取时通常静默通过人机验证;触发风控时会弹出验证码,按提示完成即可。</div>
779
+ <div class="modal-title"><span data-i18n="活动领取">活动领取</span><button class="close" data-close>✕</button></div>
780
+ <div class="hint" data-i18n="从 ZCode 官方活动接口领取(billing/preview → billing/claim)。领取时通常静默通过人机验证;触发风控时会弹出验证码,按提示完成即可。">从 ZCode 官方活动接口领取(billing/preview → billing/claim)。领取时通常静默通过人机验证;触发风控时会弹出验证码,按提示完成即可。</div>
615
781
  <div id="claim-body" style="margin-top:10px"></div>
616
782
  </div>
617
783
  </div>
@@ -622,49 +788,31 @@
622
788
  <div class="modal-title"><span id="dlg-title"></span><button class="close" data-close>✕</button></div>
623
789
  <div class="hint" id="dlg-msg" style="white-space:pre-wrap"></div>
624
790
  <div class="field" id="dlg-field" style="margin-top:10px"><input class="input" id="dlg-input"></div>
625
- <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="dlg-ok">确定</button></div>
791
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="dlg-ok" data-i18n="确定">确定</button></div>
626
792
  </div>
627
793
  </div>
628
794
 
629
795
  <!-- ZCode 自动领取计划 -->
630
796
  <div class="mask" id="m-zac">
631
797
  <div class="modal sm">
632
- <div class="modal-title"><span>ZCode 自动领取计划</span><button class="close" data-close>✕</button></div>
798
+ <div class="modal-title"><span data-i18n="ZCode 自动领取计划">ZCode 自动领取计划</span><button class="close" data-close>✕</button></div>
633
799
  <div class="field">
634
- <label>轮询周期(分钟,1–720)</label>
800
+ <label data-i18n="轮询周期(分钟,1–720)">轮询周期(分钟,1–720)</label>
635
801
  <input class="input" id="zac-interval" type="number" min="1" max="720" step="1">
636
802
  </div>
637
803
  <div class="field">
638
- <label>单次运行时长(分钟,0 = 一直运行直到手动关闭)</label>
804
+ <label data-i18n="单次运行时长(分钟,0 = 一直运行直到手动关闭)">单次运行时长(分钟,0 = 一直运行直到手动关闭)</label>
639
805
  <input class="input" id="zac-window" type="number" min="0" max="1440" step="5">
640
806
  </div>
641
- <div class="hint">开启后按此周期检查活动资格并自动领取(首次检查在开启 2 分钟后开始)。活动包由服务端在每日 0 点左右刷新放出——若运行时段没有覆盖 0 点,会错过当次领取,建议把运行时长设为 0,或让时段跨过 0 点。需要验证码时无法自动通过,卡片上会提示手动领取。</div>
642
- <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>
643
- </div>
644
- </div>
645
-
646
- <!-- ZCode 网络出口 -->
647
- <div class="mask" id="m-znet">
648
- <div class="modal sm">
649
- <div class="modal-title"><span>ZCode 网络出口</span><button class="close" data-close>✕</button></div>
650
- <div class="field">
651
- <label>上游接口访问顺序(失败 / 被拦时自动回退另一条)</label>
652
- <label class="check"><input type="radio" name="znet-order" id="znet-direct"> 直连优先(代理兜底)</label>
653
- <label class="check"><input type="radio" name="znet-order" id="znet-proxy"> 代理优先(直连兜底)</label>
654
- </div>
655
- <div class="field">
656
- <label>出口代理(http://host:port,支持口令 http://user:pass@host:port)</label>
657
- <input class="input" id="znet-url" placeholder="http://192.168.31.101:7890">
658
- </div>
659
- <div class="hint">留空 = 清除已保存的代理,退回 HTTPS_PROXY 环境变量;取消 = 不改动。当前代理只显示脱敏后的地址。</div>
660
- <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="znet-ok">保存</button></div>
807
+ <div class="hint" data-i18n="开启后按此周期检查活动资格并自动领取(首次检查在开启 2 分钟后开始)。活动包由服务端在每日 0 点左右刷新放出——若运行时段没有覆盖 0 点,会错过当次领取,建议把运行时长设为 0,或让时段跨过 0 点。需要验证码时无法自动通过,卡片上会提示手动领取。">开启后按此周期检查活动资格并自动领取(首次检查在开启 2 分钟后开始)。活动包由服务端在每日 0 点左右刷新放出——若运行时段没有覆盖 0 点,会错过当次领取,建议把运行时长设为 0,或让时段跨过 0 点。需要验证码时无法自动通过,卡片上会提示手动领取。</div>
808
+ <div class="modal-actions"><button class="btn" id="zac-off" style="display:none" data-i18n="停用">停用</button><button class="btn" data-close>取消</button><button class="btn primary" id="zac-ok" data-i18n="保存并启用">保存并启用</button></div>
661
809
  </div>
662
810
  </div>
663
811
 
664
812
  <!-- ZCode 体验包接口 -->
665
813
  <div class="mask" id="m-zgw">
666
814
  <div class="modal sm">
667
- <div class="modal-title"><span>ZCode 体验包接口</span><button class="close" data-close>✕</button></div>
815
+ <div class="modal-title"><span data-i18n="ZCode 体验包接口">ZCode 体验包接口</span><button class="close" data-close>✕</button></div>
668
816
  <div class="hint" id="zgw-url" style="margin-bottom:10px"></div>
669
817
  <div class="field">
670
818
  <label class="check"><input type="checkbox" id="zgw-on"> 启用体验包接口(/gateway/zcode/v1/messages)</label>
@@ -682,7 +830,7 @@
682
830
  <!-- MiniMax 本地接口 -->
683
831
  <div class="mask" id="m-mgw">
684
832
  <div class="modal sm">
685
- <div class="modal-title"><span>MiniMax 本地接口</span><button class="close" data-close>✕</button></div>
833
+ <div class="modal-title"><span data-i18n="MiniMax 本地接口">MiniMax 本地接口</span><button class="close" data-close>✕</button></div>
686
834
  <div class="hint" id="mgw-url" style="margin-bottom:10px"></div>
687
835
  <div class="field">
688
836
  <label class="check"><input type="checkbox" id="mgw-on"> 启用本地接口(/gateway/minimax/v1/messages)</label>
@@ -700,7 +848,7 @@
700
848
  <!-- Trae 本地接口 -->
701
849
  <div class="mask" id="m-tgw">
702
850
  <div class="modal sm">
703
- <div class="modal-title"><span>Trae 本地接口</span><button class="close" data-close>✕</button></div>
851
+ <div class="modal-title"><span data-i18n="Trae 本地接口">Trae 本地接口</span><button class="close" data-close>✕</button></div>
704
852
  <div class="hint" id="tgw-url" style="margin-bottom:10px"></div>
705
853
  <div class="field">
706
854
  <label class="check"><input type="checkbox" id="tgw-on"> 启用本地接口(/gateway/trae/v1/messages)</label>
@@ -710,7 +858,7 @@
710
858
  <label>IP 白名单(每行一个,支持 192.168.31.* 通配)</label>
711
859
  <textarea class="input" id="tgw-allow" rows="4" placeholder="192.168.31.101"></textarea>
712
860
  </div>
713
- <div class="hint">开启后,Trae SOLO 积分与模型能力(Doubao-Seed / GLM-5.1 / Kimi-K2.6 / DeepSeek-V4 等 12 款模型)变成本机的一个原生 Anthropic Messages 补全接口——在 10Router 或 Claude Code 等工具中建一个 anthropic-compatible 节点指向本机 <code>/gateway/trae/v1/messages</code> 即可调用。支持多账号自动加权轮转、429 限流自动冷却 5 分钟、流式增量与完整思考过程(thinking_delta)。白名单内的机器<b>免密直连</b>;本机回环(127.0.0.1)始终放行。</div>
861
+ <div class="hint">开启后,Trae SOLO 积分与模型能力(官方目录持续更新,网关透传任意合法模型 ID——如 GLM-5.3 / DeepSeek-V4.1-Flash / MiMo-V2.6 / Kimi-K3)变成本机的一个原生 Anthropic Messages 补全接口——在 10Router 或 Claude Code 等工具中建一个 anthropic-compatible 节点指向本机 <code>/gateway/trae/v1/messages</code> 即可调用。支持多账号自动加权轮转、429 限流自动冷却 5 分钟、流式增量与完整思考过程(thinking_delta)。白名单内的机器<b>免密直连</b>;本机回环(127.0.0.1)始终放行。</div>
714
862
  <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="tgw-ok">保存</button></div>
715
863
  </div>
716
864
  </div>
@@ -721,6 +869,33 @@
721
869
  <script>
722
870
  'use strict';
723
871
 
872
+ let I18N_DICT = {};
873
+ function T(text, vars) {
874
+ const s = I18N_DICT[text] ?? text;
875
+ if (!vars) return s;
876
+ return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
877
+ }
878
+
879
+ // 语言:顶栏菜单的五档分段与面板设置弹窗的下拉共用一个真相源。
880
+ // 切换必须先落盘(settings.locale 由守护进程读取,日志也按它实时重渲染),再刷新页面。
881
+ async function setLocale(locale) {
882
+ const busy = $('locale-busy');
883
+ if (busy) busy.hidden = false;
884
+ try {
885
+ await api('/api/settings', { method: 'PUT', json: { locale } });
886
+ location.reload();
887
+ } catch (e) { if (busy) busy.hidden = true; /* 失败时保留原选项 */ }
888
+ }
889
+ function renderLocale(cur) {
890
+ if (cur) state.locale = cur;
891
+ const l = state.locale || 'zh-CN';
892
+ const seg = $('locale-seg');
893
+ if (seg) for (const btn of seg.querySelectorAll('[data-locale]')) btn.classList.toggle('on', btn.dataset.locale === l);
894
+ const sel = $('set-locale');
895
+ if (sel && sel.value !== l) sel.value = l;
896
+ }
897
+
898
+
724
899
  // ── 图标(Lucide 风格,currentColor) ──
725
900
  const svg = (d) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
726
901
  const I = {
@@ -737,14 +912,24 @@ const I = {
737
912
  credit: svg('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>'),
738
913
  gift: svg('<rect x="3" y="8" width="18" height="4"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5C11 3 12 8 12 8s1-5 4.5-5a2.5 2.5 0 0 1 0 5"/>'),
739
914
  tune: svg('<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/>'),
915
+ // 齿轮:面板设置(比「推子」更通用地表达“设置”)
916
+ settings: svg('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'),
740
917
  chevUp: svg('<path d="M18 15l-6-6-6 6"/>'),
918
+ grip: svg('<circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/>'),
741
919
  chevDown: svg('<path d="M6 9l6 6 6-6"/>'),
742
920
  chevRight: svg('<path d="M9 18l6-6-6-6"/>'),
743
921
  check: svg('<path d="M20 6L9 17l-5-5"/>'),
922
+ grid: svg('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>'),
923
+ book: svg('<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>'),
924
+ power: svg('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>'),
925
+ monitor: svg('<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>'),
926
+ clock: svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5l3 2"/>'),
927
+ msg: svg('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'),
928
+ heart: svg('<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>'),
744
929
  };
745
930
 
746
931
  const $ = (id) => document.getElementById(id);
747
- const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '', logTab: 'run', logLevel: 'all' };
932
+ const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '', logTab: 'run', logLevel: 'all', locale: 'zh-CN' };
748
933
  const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax', 'tenrouter'];
749
934
  // 10Router 集成:config 来自 /api/tenrouter,quotas 来自 /api/tenrouter/quotas
750
935
  const tr = { config: null, quotas: null, error: null, loading: false, syncing: false };
@@ -757,7 +942,7 @@ const PRODUCTS = [
757
942
  { id: 'trae', label: 'Trae' },
758
943
  { id: 'minimax', label: 'MiniMax' },
759
944
  ];
760
- const prodLabel = (id) => (PRODUCTS.find((x) => x.id === id) || { label: id }).label;
945
+ const prodLabel = (id) => T((PRODUCTS.find((x) => x.id === id) || { label: id }).label);
761
946
 
762
947
  // ── 全局组件开关:不用某个产品 / 10Router 时整块收起(偏好存浏览器本地) ──
763
948
  const COMPONENTS = [
@@ -906,8 +1091,8 @@ const provBadges = (p, regionOnly, flavor) => {
906
1091
  : flavor === 'zai' ? '<span class="badge intl">Z.ai</span>'
907
1092
  : '<span class="badge cn">BigModel</span>')
908
1093
  : '';
909
- return (regionOnly ? '' : '<span class="badge prod ' + v.prod + '">' + esc(v.prodLabel) + '</span>')
910
- + (v.region ? '<span class="badge ' + v.cls + '">' + v.region + '</span>' : '')
1094
+ return (regionOnly ? '' : '<span class="badge prod ' + v.prod + '">' + esc(T(v.prodLabel)) + '</span>')
1095
+ + (v.region ? '<span class="badge ' + v.cls + '">' + T(v.region) + '</span>' : '')
911
1096
  + zBadge;
912
1097
  };
913
1098
 
@@ -935,7 +1120,7 @@ let toastTimer = null;
935
1120
  const armed = {};
936
1121
  function toast(msg, ok) {
937
1122
  const t = $('toast');
938
- t.textContent = msg;
1123
+ t.textContent = T(msg);
939
1124
  t.classList.toggle('err', ok === false);
940
1125
  t.classList.add('show');
941
1126
  clearTimeout(toastTimer);
@@ -958,11 +1143,11 @@ let keyPrompt = null;
958
1143
  /** 弹一次密码输入框(并发的请求共用同一个);返回 true = 已输入 */
959
1144
  function promptKey(invalid) {
960
1145
  if (!keyPrompt) {
961
- keyPrompt = uiPrompt('输入面板访问密码', '', {
962
- type: 'password', okText: '进入',
963
- message: (invalid ? '密码不正确,请重新输入。\n' : '')
964
- + '面板访问密码在右上角「设置」里管理。\n'
965
- + 'fnOS:安装向导设置的密码(忘了可在 fnOS「应用设置」里重设)。\n命令行部署:环境变量 CREDITDADDY_PASSWORD 的值。',
1146
+ keyPrompt = uiPrompt(T('输入面板访问密码'), '', {
1147
+ type: 'password', okText: T('进入'),
1148
+ message: (invalid ? T('密码不正确,请重新输入。\n') : '')
1149
+ + T('面板访问密码在右上角「设置」里管理。\n')
1150
+ + T('fnOS:安装向导设置的密码(忘了可在 fnOS「应用设置」里重设)。\n命令行部署:环境变量 CREDITDADDY_PASSWORD 的值。'),
966
1151
  }).then((k) => { keyPrompt = null; if (k === null || !k.trim()) return false; setKey(k.trim()); return true; });
967
1152
  }
968
1153
  return keyPrompt;
@@ -996,14 +1181,85 @@ async function loadSettings() {
996
1181
  try {
997
1182
  const d = await api('/api/settings');
998
1183
  state.sec = { enabled: Boolean(d.panelKeyEnabled), loaded: true };
1184
+ state.wbActivity = d.workbuddyActivity !== false; // WB 国际版活跃请求总开关(默认开)
999
1185
  if (!d.panelKeyEnabled) setKey(''); // 关闭后清掉本地缓存的旧密钥
1186
+ if (d.locale) renderLocale(d.locale);
1000
1187
  } catch (e) { /* 401(密码开启但未输入)等状态由 promptKey 流程处理 */ }
1001
1188
  }
1002
1189
 
1190
+
1191
+ async function applyDictFromServer() {
1192
+ try {
1193
+ const { dict, locale } = await api('/api/i18n');
1194
+ I18N_DICT = dict || {};
1195
+ renderLocale(locale); // 语言分段的高亮态以服务端为准(比 /api/settings 更早可用)
1196
+ document.querySelectorAll('[data-i18n]').forEach((el) => {
1197
+ const src = el.getAttribute('data-i18n');
1198
+ if (dict[src]) el.textContent = dict[src];
1199
+ });
1200
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1201
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
1202
+ const key = n.nodeValue.trim();
1203
+ if (key && dict[key] && !n.parentElement?.closest('script,style')) n.nodeValue = n.nodeValue.replace(key, dict[key]);
1204
+ }
1205
+ document.querySelectorAll('[title],[placeholder]').forEach((el) => {
1206
+ for (const attr of ['title', 'placeholder']) {
1207
+ const v = el.getAttribute(attr);
1208
+ if (v && dict[v]) el.setAttribute(attr, dict[v]);
1209
+ }
1210
+ });
1211
+ } catch (e) { /* 字典不可用时保留中文原文 */ }
1212
+ }
1213
+
1214
+ document.getElementById('set-locale')?.addEventListener('change', (ev) => setLocale(ev.target.value));
1215
+
1216
+ // ── 通用设置:全局自动签到(开关 + 频率,落盘立刻重调度)、日志默认显示方式(本机偏好)──
1217
+ const wbActOn = () => state.wbActivity !== false;
1218
+ $('cfg-wb-activity').addEventListener('change', async (e) => {
1219
+ try {
1220
+ await api('/api/settings', { method: 'PUT', json: { workbuddyActivity: e.target.checked } });
1221
+ await loadSettings();
1222
+ render(); renderStatus();
1223
+ toast(T('已保存'), true);
1224
+ } catch (err) { toast(err.message, false); }
1225
+ });
1226
+ function fillAutoCheckin() {
1227
+ const sc = state.status && state.status.scheduler;
1228
+ if (!sc) return;
1229
+ $('cfg-auto-checkin').checked = sc.enabled !== false;
1230
+ $('cfg-checkin-interval').value = String(sc.intervalMin || 120);
1231
+ }
1232
+ function logsDisplayDefault() {
1233
+ try { return localStorage.getItem('qd_logs_expanded') !== '0' ? 'expanded' : 'collapsed'; } catch { return 'expanded'; }
1234
+ }
1235
+ function applyLogsDisplay(mode) {
1236
+ const collapsed = mode !== 'expanded';
1237
+ $('log-card').classList.toggle('collapsed', collapsed);
1238
+ $('btn-logs').textContent = collapsed ? T('展开') : T('收起');
1239
+ try { localStorage.setItem('qd_logs_expanded', collapsed ? '0' : '1'); } catch {}
1240
+ }
1241
+ $('cfg-auto-checkin').addEventListener('change', async (e) => {
1242
+ try {
1243
+ await api('/api/settings', { method: 'PUT', json: { autoCheckin: e.target.checked } });
1244
+ await loadStatus();
1245
+ toast(T('已保存'), true);
1246
+ } catch (err) { toast(err.message, false); fillAutoCheckin(); }
1247
+ });
1248
+ $('cfg-checkin-interval').addEventListener('change', async (e) => {
1249
+ try {
1250
+ await api('/api/settings', { method: 'PUT', json: { checkinIntervalMin: Number(e.target.value) } });
1251
+ await loadStatus();
1252
+ toast(T('已保存'), true);
1253
+ } catch (err) { toast(err.message, false); fillAutoCheckin(); }
1254
+ });
1255
+ $('cfg-logs-display').addEventListener('change', (e) => applyLogsDisplay(e.target.value));
1256
+ applyLogsDisplay(logsDisplayDefault());
1257
+
1003
1258
  // ── 主题 ──
1259
+ // 应用的是「解析后的」亮/暗;用户选的三态(跟随系统 / 亮色 / 暗色)另存于 qd_theme,
1260
+ // 由 renderThemeMode() 反映到菜单里的分段控件(主题只有那一个入口)。
1004
1261
  function applyTheme(t) {
1005
1262
  document.documentElement.dataset.theme = t;
1006
- $('btn-theme').innerHTML = t === 'dark' ? I.sun : I.moon;
1007
1263
  }
1008
1264
  function initTheme() {
1009
1265
  let t = null;
@@ -1022,10 +1278,120 @@ function initTheme() {
1022
1278
  if (mq.addEventListener) mq.addEventListener('change', onSysTheme);
1023
1279
  else if (mq.addListener) mq.addListener(onSysTheme); // 旧 Safari
1024
1280
  }
1025
- $('btn-theme').onclick = () => {
1026
- const n = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
1027
- applyTheme(n);
1028
- try { localStorage.setItem('qd_theme', n); } catch (e) {}
1281
+ // 右上角菜单按钮:打开聚合操作弹窗(openModal/遮罩点击/ESC/✕ 由通用弹窗机制处理)
1282
+ $('btn-header-menu').innerHTML = I.grid;
1283
+ const hmenuPop = $('hmenu-pop');
1284
+ const closeHmenu = () => hmenuPop.classList.remove('show');
1285
+ $('btn-header-menu').addEventListener('click', (e) => {
1286
+ e.stopPropagation();
1287
+ closeModal('m-settings'); // 设置弹层与聚合菜单同处一处,开一个收一个
1288
+ const willOpen = !hmenuPop.classList.contains('show');
1289
+ if (willOpen) renderThemeMode(); // 打开时同步一次当前主题态(系统可能已变过)
1290
+ hmenuPop.classList.toggle('show', willOpen);
1291
+ });
1292
+ // 点菜单外任意处 / ESC 收起下拉
1293
+ document.addEventListener('click', (e) => { if (!e.target.closest('.hmenu')) closeHmenu(); });
1294
+ document.addEventListener('click', (e) => {
1295
+ const st = $('m-settings');
1296
+ // target 已脱离 DOM = 点击者所在区域刚被重渲染(如组件显隐切换重建 comp-menu),不算「点外面」
1297
+ if (st && st.classList.contains('show') && e.target.isConnected && !e.target.closest('#m-settings') && !e.target.closest('.head-setpop')) closeModal('m-settings');
1298
+ });
1299
+ window.addEventListener('scroll', closeHmenu, true);
1300
+ // 菜单项图标:与头部按钮同款 Lucide 描边(静态文字前插图标)
1301
+ const withIcon = (id, icon) => { const b = $(id); if (b) b.innerHTML = icon + b.textContent; };
1302
+ withIcon('hm-update', I.refresh);
1303
+ withIcon('changelog-brief-btn', I.book);
1304
+ withIcon('hm-home', I.github); // 由头部独立图标收进「关于」分区
1305
+ withIcon('hm-restart', I.power);
1306
+ withIcon('feedback-btn', I.msg);
1307
+ withIcon('donate-btn', I.heart);
1308
+ // 检查更新:桌面版走应用内更新;纯浏览器(无 updater 桥)也保留入口——点开给「去 Releases 手动下载」的承接。
1309
+ $('hm-update').onclick = () => { closeHmenu(); openUpdateModal(true); };
1310
+ $('hm-restart').onclick = async () => {
1311
+ closeHmenu();
1312
+ const ok = await uiConfirm(T('确定要重启 CreditDaddy 服务吗?正在进行的任务可能会中断。'), T('重启服务'), T('重启'));
1313
+ if (ok) restartCreditDaddy();
1314
+ };
1315
+ $('changelog-brief-btn').onclick = () => { closeHmenu(); openChangelogModal(); };
1316
+ // 主题:菜单里的三态分段控件(跟随系统 / 亮色 / 暗色),当前态高亮。
1317
+ // 「跟随系统」= 清掉手动选择,matchMedia 实时跟跑;手动选过亮/暗则固定不再跟系统
1318
+ // (与 initTheme 里 onSysTheme 判定同一存储键 qd_theme)。菜单保持展开,连点可连续比较观感。
1319
+ const THEME_MODES = [
1320
+ ['auto', T('跟随系统'), I.monitor],
1321
+ ['light', T('亮色'), I.sun],
1322
+ ['dark', T('暗色'), I.moon],
1323
+ ];
1324
+ function currentThemeMode() {
1325
+ try { const t = localStorage.getItem('qd_theme'); return t === 'light' || t === 'dark' ? t : 'auto'; } catch (e) { return 'auto'; }
1326
+ }
1327
+ function applyThemeMode(mode) {
1328
+ try {
1329
+ if (mode === 'auto') localStorage.removeItem('qd_theme');
1330
+ else localStorage.setItem('qd_theme', mode);
1331
+ } catch (e) {}
1332
+ const sysDark = window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches;
1333
+ applyTheme(mode === 'auto' ? (sysDark ? 'dark' : 'light') : mode);
1334
+ renderThemeMode();
1335
+ }
1336
+ // 分段控件:注入图标 + 绑定一次点击;renderThemeMode 只切换 .on,不重建节点(保住焦点)
1337
+ // 与菜单项一样,点击不收起菜单——切主题本来就该能连着试。
1338
+ function renderThemeMode() {
1339
+ const seg = $('theme-seg');
1340
+ if (seg) {
1341
+ const cur = currentThemeMode();
1342
+ for (const btn of seg.querySelectorAll('[data-theme-mode]')) {
1343
+ const mode = btn.dataset.themeMode;
1344
+ if (!btn.dataset.ready) {
1345
+ const def = THEME_MODES.find((m) => m[0] === mode);
1346
+ btn.innerHTML = (def ? def[2] : '') + btn.textContent.trim();
1347
+ btn.dataset.ready = '1';
1348
+ }
1349
+ btn.classList.toggle('on', mode === cur);
1350
+ }
1351
+ }
1352
+ renderThemeButton(); // 所有改主题的通路(头部按钮 / 菜单分段 / 系统主题变化兜底)统一同步图标
1353
+ }
1354
+ // 头部独立主题按钮:只做亮↔暗一键反转(每次点击必有视觉反馈)。
1355
+ // 「跟随系统」不进循环——跟随系统与系统主题一致时,点那一档观感=没反应(用户反馈);
1356
+ // 需要跟随系统走菜单里的三态分段控件。
1357
+ const themeBtn = $('btn-theme');
1358
+ function resolvedTheme() { return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; }
1359
+ function renderThemeButton() {
1360
+ if (!themeBtn) return;
1361
+ const cur = resolvedTheme();
1362
+ themeBtn.innerHTML = cur === 'dark' ? I.moon : I.sun;
1363
+ themeBtn.title = T('主题:{mode}(点击切换)', { mode: T(cur === 'dark' ? '暗色' : '亮色') });
1364
+ }
1365
+ themeBtn.addEventListener('click', () => applyThemeMode(resolvedTheme() === 'dark' ? 'light' : 'dark'));
1366
+ // 字典到达后需要重渲染主题按钮(标题/图标):暴露出脚本级入口供 boot 调
1367
+ window.__syncTheme = renderThemeMode;
1368
+ renderThemeMode();
1369
+ $('theme-seg').addEventListener('click', (e) => {
1370
+ const btn = e.target.closest('[data-theme-mode]');
1371
+ if (btn) applyThemeMode(btn.dataset.themeMode);
1372
+ });
1373
+ renderLocale();
1374
+ $('locale-seg').addEventListener('click', (e) => {
1375
+ const btn = e.target.closest('[data-locale]');
1376
+ if (btn && btn.dataset.locale !== state.locale) setLocale(btn.dataset.locale);
1377
+ });
1378
+
1379
+ // 建议反馈:直达 GitHub issue 模板选择页(🐞 Bug 反馈 / ✨ 功能建议 / 🔒 安全问题,
1380
+ // 表单已含版本 / 安装渠道 / 平台字段,与 10Router 仓库同一套结构)
1381
+ $('feedback-btn').onclick = () => {
1382
+ closeHmenu();
1383
+ const url = 'https://github.com/techysy/CreditDaddy/issues/new/choose';
1384
+ if (window.creditdaddy && typeof window.creditdaddy.shellOpen === 'function')
1385
+ window.creditdaddy.shellOpen(url);
1386
+ else window.open(url, '_blank');
1387
+ };
1388
+
1389
+ // 捐赠(桌面版走 IPC 桥;网页直接开浏览器)
1390
+ $('donate-btn').onclick = () => {
1391
+ closeHmenu();
1392
+ if (window.creditdaddy && typeof window.creditdaddy.shellOpen === 'function')
1393
+ window.creditdaddy.shellOpen('https://github.com/sponsors/techysy');
1394
+ else window.open('https://github.com/sponsors/techysy', '_blank');
1029
1395
  };
1030
1396
  }
1031
1397
 
@@ -1039,17 +1405,17 @@ function statusOf(a) {
1039
1405
  const done = state.status && state.status.todayDone && state.status.todayDone[a.id] === today;
1040
1406
  const r = a.lastResult;
1041
1407
  const fresh = r && r.at && state.status && (Date.now() - new Date(r.at).getTime() < 26 * 3600e3);
1042
- if (r && fresh && r.status === 'limited' && done) return { cls: 'limited', text: '本机已领', msg: r.message };
1408
+ if (r && fresh && r.status === 'limited' && done) return { cls: 'limited', text: T('本机已领'), msg: r.message };
1043
1409
  if (done) {
1044
- if (intl) return { cls: 'ok', text: '今日活跃', msg: r && r.message ? r.message : '' };
1410
+ if (intl) return { cls: 'ok', text: T('今日活跃'), msg: r && r.message ? r.message : '' };
1045
1411
  const amt = r && r.status === 'checked-in' && fresh && r.amount ? ' +' + fmtNum(r.amount) : '';
1046
- const streak = r && !intl && r.streakDays ? ' · 连续 ' + r.streakDays + ' 天' : '';
1047
- return { cls: 'ok', text: '今日已领' + amt + streak, msg: r && r.message && !['checked-in', 'already'].includes(r.status) ? r.message : '' };
1412
+ const streak = r && !intl && r.streakDays ? T(' · 连续 ') + r.streakDays + T(' 天') : '';
1413
+ return { cls: 'ok', text: T('今日已领') + amt + streak, msg: r && r.message && !['checked-in', 'already'].includes(r.status) ? r.message : '' };
1048
1414
  }
1049
- const failText = intl ? '活跃失败' : '领取失败';
1415
+ const failText = intl ? T('活跃失败') : T('领取失败');
1050
1416
  if (r && fresh && r.status === 'failed') return { cls: 'fail', text: failText, msg: r.message, err: true };
1051
- if (r && fresh && r.status === 'no-activity') return { cls: 'pending', text: intl ? '待活跃' : '待领取', msg: r.message };
1052
- return { cls: 'pending', text: intl ? '未活跃' : '未领取', msg: '' };
1417
+ if (r && fresh && r.status === 'no-activity') return { cls: 'pending', text: intl ? T('待活跃') : T('待领取'), msg: r.message };
1418
+ return { cls: 'pending', text: intl ? T('未活跃') : T('未领取'), msg: '' };
1053
1419
  }
1054
1420
 
1055
1421
  function expiryCell(a) {
@@ -1058,11 +1424,11 @@ function expiryCell(a) {
1058
1424
  if (q && q.planExpiresAt) {
1059
1425
  const msPlan = new Date(q.planExpiresAt).getTime() - Date.now();
1060
1426
  const planName = q.plan ? esc(q.plan) + ' ' : '';
1061
- if (msPlan <= 0) return { label: planName + '已到期', warn: true, title: '套餐已到期' };
1427
+ if (msPlan <= 0) return { label: planName + T('已到期'), warn: true, title: T('套餐已到期') };
1062
1428
  return {
1063
1429
  label: planName + fmtTime(q.planExpiresAt).slice(0, 10),
1064
1430
  warn: msPlan < 3 * 86400e3,
1065
- title: '套餐有效期至 ' + fmtTime(q.planExpiresAt),
1431
+ title: T('套餐有效期至 ') + fmtTime(q.planExpiresAt),
1066
1432
  isPlan: true,
1067
1433
  };
1068
1434
  }
@@ -1070,17 +1436,17 @@ function expiryCell(a) {
1070
1436
  // 具有 refreshToken 的账号会自动在后台续期,短期 exp 属于正常轮换,不应显示刺眼红字误导用户
1071
1437
  if (a.hasRefreshToken) {
1072
1438
  return {
1073
- label: '凭据有效 · 自动续期',
1439
+ label: T('凭据有效 · 自动续期'),
1074
1440
  warn: false,
1075
- title: a.expiresAt ? '本次登录凭据有效期至 ' + fmtTime(a.expiresAt) + '(过期将自动通过 refreshToken 刷新续期,无需手动干预)' : '支持自动刷新续期',
1441
+ title: a.expiresAt ? T('本次登录凭据有效期至 ') + fmtTime(a.expiresAt) + T('(过期将自动通过 refreshToken 刷新续期,无需手动干预)') : T('支持自动刷新续期'),
1076
1442
  isAuto: true,
1077
1443
  };
1078
1444
  }
1079
1445
 
1080
- if (!a.expiresAt) return { label: a.isPat ? 'PAT 长期有效' : '—', warn: false };
1446
+ if (!a.expiresAt) return { label: a.isPat ? T('PAT 长期有效') : '—', warn: false };
1081
1447
  const ms = new Date(a.expiresAt).getTime() - Date.now();
1082
- if (ms <= 0) return { label: '已过期', warn: true, title: '登录凭据已过期' };
1083
- return { label: fmtTime(a.expiresAt), warn: ms < 48 * 3600e3, title: '有效期至 ' + fmtTime(a.expiresAt) };
1448
+ if (ms <= 0) return { label: T('已过期'), warn: true, title: T('登录凭据已过期') };
1449
+ return { label: fmtTime(a.expiresAt), warn: ms < 48 * 3600e3, title: T('有效期至 ') + fmtTime(a.expiresAt) };
1084
1450
  }
1085
1451
 
1086
1452
  // 通用相对重置标签:「3 天 后重置」「45 分钟 后到期」;一小时内标红,悬停看绝对时间
@@ -1089,11 +1455,11 @@ function relReset(q) {
1089
1455
  if (!at) return null;
1090
1456
  const ms = new Date(at).getTime() - Date.now();
1091
1457
  if (!Number.isFinite(ms)) return null;
1092
- if (ms <= 0) return { text: '即将' + (q.recurring === false ? '到期' : '重置'), soon: true };
1458
+ if (ms <= 0) return { text: T('即将') + (q.recurring === false ? T('到期') : T('重置')), soon: true };
1093
1459
  const m = Math.ceil(ms / 60000);
1094
1460
  const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
1095
- const dur = d > 0 ? d + ' 天' + (h ? h + ' 小时' : '') : h > 0 ? h + ' 小时' + (mm ? mm + ' 分' : '') : Math.max(1, mm) + ' 分钟';
1096
- return { text: dur + ' 后' + (q.recurring === false ? '到期' : '重置'), soon: ms < 3600e3 };
1461
+ const dur = d > 0 ? d + T(' 天') + (h ? h + T(' 小时') : '') : h > 0 ? h + T(' 小时') + (mm ? mm + T(' 分') : '') : Math.max(1, mm) + T(' 分钟');
1462
+ return { text: dur + T(' 后') + (q.recurring === false ? T('到期') : T('重置')), soon: ms < 3600e3 };
1097
1463
  }
1098
1464
  function relResetHtml(q) {
1099
1465
  const r = relReset(q);
@@ -1113,38 +1479,38 @@ function trLine(name, valHtml, title) {
1113
1479
 
1114
1480
  function creditHtml(a) {
1115
1481
  const q = state.quotas[a.id];
1116
- if (!q) return '<div class="credit-line"><span class="dim">读取积分中…</span></div><div class="bar"></div>';
1117
- 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>';
1118
- if (q.empty) return '<div class="credit-line"><span class="dim">无有效套餐(仅免费额度)</span></div><div class="bar"></div>';
1482
+ if (!q) return T('<div class="credit-line"><span class="dim">读取积分中…</span></div><div class="bar"></div>');
1483
+ if (q.error) return '<div class="credit-line"><span class="dim" title="' + esc(T(q.error)) + '">' + T('积分读取失败:') + esc(q.error.slice(0, 40)) + '</span></div><div class="bar"></div>';
1484
+ if (q.empty) return T('<div class="credit-line"><span class="dim">无有效套餐(仅免费额度)</span></div><div class="bar"></div>');
1119
1485
 
1120
1486
  // 妙手:Credits 钱包余额 + 当前版本(体验版 / 专业版),无「总量」口径,不画水位条
1121
1487
  if (a.product === 'catpaw') {
1122
1488
  const planBadge = q.plan ? '<span class="badge">' + esc(q.plan) + '</span>' : '';
1123
- const expHtml = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + ' 到期</span>'
1124
- : q.nextRefreshAt ? '<span class="dim">' + fmtTime(q.nextRefreshAt).slice(0, 10) + ' 下次刷新</span>' : '';
1125
- return '<div class="credit-line"><span class="credit"><span class="lbl">可用额度</span><b>' + fmtNum(q.remaining) + '</b>Credits</span>'
1489
+ const expHtml = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + T(' 到期</span>')
1490
+ : q.nextRefreshAt ? '<span class="dim">' + fmtTime(q.nextRefreshAt).slice(0, 10) + T(' 下次刷新</span>') : '';
1491
+ return T('<div class="credit-line"><span class="credit"><span class="lbl">可用额度</span><b>') + fmtNum(q.remaining) + '</b>Credits</span>'
1126
1492
  + '<span class="credit dim">' + planBadge + (planBadge && expHtml ? ' · ' : '') + expHtml + '</span></div>';
1127
1493
  }
1128
1494
 
1129
1495
  // 多窗口配额体系(mirasim 等):每个窗口平等等权,独立一行带分散对齐的名称、数值与三档细水位条
1130
1496
  if (a.product === 'mirasim' && q.parts && q.parts.length) {
1131
1497
  const isExhausted = q.exceeded || (q.remaining <= 0);
1132
- const planBadge = q.plan ? '<span class="badge">' + esc(q.plan) + ' 套餐</span>' : '';
1133
- const expLabel = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + ' 到期</span>' : '';
1498
+ const planBadge = q.plan ? '<span class="badge">' + esc(T(q.plan)) + T(' 套餐</span>') : '';
1499
+ const expLabel = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + T(' 到期</span>') : '';
1134
1500
  // 状态采用温和的徽章,避免整行大红字制造恐慌
1135
1501
  const topStatus = isExhausted
1136
- ? '<span class="pill pending">' + esc(q.bottleneckLabel || '额度已用尽') + '</span>'
1137
- : '<span class="lbl">可用额度</span><b>' + fmtNum(q.remaining) + '%</b>';
1502
+ ? '<span class="pill pending">' + esc(T(q.bottleneckLabel || '') || T('额度已用尽')) + '</span>'
1503
+ : T('<span class="lbl">可用额度</span><b>') + fmtNum(q.remaining) + '%</b>';
1138
1504
 
1139
- const fmtUnits = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + '万' : fmtNum(Math.round(n)));
1505
+ const fmtUnits = (n) => (n >= 10000 ? (n / 10000).toFixed(2) + T('万') : fmtNum(Math.round(n)));
1140
1506
  const rows = q.parts.map((p) => {
1141
1507
  const pct = p.remainingPercentage ?? (p.total > 0 ? Math.max(0, Math.min(100, (p.remaining / p.total) * 100)) : 0);
1142
1508
  const isZero = pct <= 0;
1143
1509
  const numTxt = p.total > 0 ? fmtUnits(p.remaining) + ' / ' + fmtUnits(p.total) : '';
1144
1510
  // 数值保持中性温和排版,不用鲜红大字恐吓用户
1145
1511
  const pctTxt = isZero
1146
- ? '<span class="dim">剩 0%</span>'
1147
- : '<span>剩 ' + fmtNum(pct) + '%</span>';
1512
+ ? T('<span class="dim">剩 0%</span>')
1513
+ : T('<span>剩 ') + fmtNum(pct) + '%</span>';
1148
1514
  const valHtml = pctTxt + (numTxt ? ' <small class="dim">(' + numTxt + ')</small>' : '') + ' ' + relResetHtml(p);
1149
1515
  return trLine(p.name, valHtml) + trMeter(pct);
1150
1516
  }).join('');
@@ -1154,16 +1520,16 @@ function creditHtml(a) {
1154
1520
  + '<div class="mirasim-windows">' + rows + '</div>';
1155
1521
  }
1156
1522
 
1157
- const unit = q.unit ? ' ' + q.unit : '';
1523
+ const unit = q.unit ? ' ' + T(q.unit) : '';
1158
1524
  if (q.unit) {
1159
1525
  const items = q.parts.map((x) => {
1160
- const base = esc(x.name) + ' ' + fmtNum(x.remaining) + '/' + fmtNum(x.total) + (x.unit ? ' ' + esc(x.unit) : '');
1161
- if (x.pending && x.startsAt) return base + '(' + fmtTime(x.startsAt).slice(5, 16) + ' 生效)';
1162
- return base + (x.expiresAt ? '(' + fmtTime(x.expiresAt).slice(5, 16) + (x.recurring ? ' 重置)' : ' 到期)') : '');
1526
+ const base = esc(T(x.name)) + ' ' + fmtNum(x.remaining) + '/' + fmtNum(x.total) + (x.unit ? ' ' + esc(T(x.unit)) : '');
1527
+ if (x.pending && x.startsAt) return base + T('(') + fmtTime(x.startsAt).slice(5, 16) + T(' 生效)');
1528
+ return base + (x.expiresAt ? T('(') + fmtTime(x.expiresAt).slice(5, 16) + (x.recurring ? T(' 重置)') : T(' 到期)')) : '');
1163
1529
  }).join(' · ');
1164
1530
  const pct = q.total > 0 ? Math.min(1, q.remaining / q.total) : 0;
1165
1531
  // 满额(100%)不再渲染灰色「已使用」尾段——它只占 0 宽也会造成几像素的灰边
1166
- return '<div class="credit-line"><span class="credit"><span class="lbl">剩余</span><b>' + fmtNum(q.remaining) + '</b>' + esc(unit) + '</span>'
1532
+ return T('<div class="credit-line"><span class="credit"><span class="lbl">剩余</span><b>') + fmtNum(q.remaining) + '</b>' + esc(unit) + '</span>'
1167
1533
  + '<span class="credit dim" title="' + items + '">' + (q.plan ? esc(q.plan) + ' · ' : '') + items + '</span></div>'
1168
1534
  + '<div class="bar"><span class="seg" style="flex:' + pct.toFixed(4) + ' 1 0"></span>'
1169
1535
  + (pct < 0.9999 ? '<span class="seg used" style="flex:' + (1 - pct).toFixed(4) + ' 1 0"></span>' : '')
@@ -1174,14 +1540,14 @@ function creditHtml(a) {
1174
1540
  if (total > 0) {
1175
1541
  q.parts.filter((x) => x.remaining > 0).forEach((x, i) => {
1176
1542
  bar += '<span class="seg" style="flex:' + (x.remaining / total).toFixed(4) + ' 1 0;--a:' + (x.recurring ? 1 : Math.max(.45, .85 - i * .05)) + '" title="'
1177
- + esc(x.name) + ':剩余 ' + fmtNum(x.remaining) + ' / ' + fmtNum(x.total) + (x.expiresAt ? (x.recurring ? ',' + fmtTime(x.expiresAt).slice(0, 10) + ' 重置' : ',' + fmtTime(x.expiresAt).slice(0, 10) + ' 到期') : '') + '"></span>';
1543
+ + esc(T(x.name)) + T(':剩余 ') + fmtNum(x.remaining) + ' / ' + fmtNum(x.total) + (x.expiresAt ? (x.recurring ? T(',') + fmtTime(x.expiresAt).slice(0, 10) + T(' 重置') : T(',') + fmtTime(x.expiresAt).slice(0, 10) + T(' 到期')) : '') + '"></span>';
1178
1544
  });
1179
- if (q.used > 0) bar += '<span class="seg used" style="flex:' + (q.used / total).toFixed(4) + ' 1 0" title="已使用 ' + fmtNum(q.used) + '"></span>';
1545
+ if (q.used > 0) bar += '<span class="seg used" style="flex:' + (q.used / total).toFixed(4) + ' 1 0" title="' + T('已使用 ') + fmtNum(q.used) + '"></span>';
1180
1546
  }
1181
1547
  const soon = q.parts.filter((x) => x.remaining > 0 && !x.recurring && x.expiresAt).sort((x, y) => x.expiresAt.localeCompare(y.expiresAt))[0];
1182
1548
  return '<div class="credit-line">'
1183
- + '<span class="credit"><span class="lbl">剩余</span>' + I.credit + '<b>' + fmtNum(q.remaining) + '</b></span>'
1184
- + '<span class="credit dim">' + (soon ? fmtNum(soon.remaining) + ' 于 ' + fmtTime(soon.expiresAt).slice(5, 10) + ' 到期 · ' : '') + '已用 ' + fmtNum(q.used) + ' / 共 ' + fmtNum(total) + (q.exceeded ? ' · <span style="color:var(--err)">已超额</span>' : '') + '</span>'
1549
+ + T('<span class="credit"><span class="lbl">剩余</span>') + I.credit + '<b>' + fmtNum(q.remaining) + '</b></span>'
1550
+ + '<span class="credit dim">' + (soon ? fmtNum(soon.remaining) + T(' 于 ') + fmtTime(soon.expiresAt).slice(5, 10) + T(' 到期 · ') : '') + T('已用 ') + fmtNum(q.used) + T(' / 共 ') + fmtNum(total) + (q.exceeded ? T(' · <span style="color:var(--err)">已超额</span>') : '') + '</span>'
1185
1551
  + '</div><div class="bar">' + bar + '</div>';
1186
1552
  }
1187
1553
 
@@ -1193,31 +1559,31 @@ function cardHtml(a) {
1193
1559
  const canCheckin = CHECKIN_PRODUCTS.has(a.product);
1194
1560
  return '<div class="card" draggable="true" data-id="' + esc(a.id) + '">'
1195
1561
  + '<div class="row1"><div class="name-group">'
1196
- + '<span class="name" data-act="rename" title="点击修改备注名">' + esc(a.name || '(未命名)') + '</span>'
1562
+ + T('<span class="name" data-act="rename" title="点击修改备注名">') + esc(a.name || T('(未命名)')) + '</span>'
1197
1563
  + provBadges(a.provider, true, a.flavor)
1198
- + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] || '') + ' 客户端当前登录的账号">客户端当前</span>' : '')
1199
- + (a.product === 'zcode' && isGatewayCurrent(a) ? '<span class="pill gw" title="体验包接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>' : '')
1200
- + (a.product === 'minimax' && isMiniMaxGatewayCurrent(a) ? '<span class="pill gw" title="本地接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>' : '')
1201
- + (a.product === 'trae' && isTraeGatewayCurrent(a) ? '<span class="pill gw" title="本地接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>' : '')
1564
+ + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] ? T(CLIENT_LABEL[a.product]) : '') + T(' 客户端当前登录的账号">客户端当前</span>') : '')
1565
+ + (a.product === 'zcode' && isGatewayCurrent(a) ? T('<span class="pill gw" title="体验包接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>') : '')
1566
+ + (a.product === 'minimax' && isMiniMaxGatewayCurrent(a) ? T('<span class="pill gw" title="本地接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>') : '')
1567
+ + (a.product === 'trae' && isTraeGatewayCurrent(a) ? T('<span class="pill gw" title="本地接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>') : '')
1202
1568
  + (a.isPat ? '<span class="badge">PAT</span>' : '')
1203
1569
  + qoderWebBadge(a)
1204
- + (a.verified === false ? '<span class="pill fail">token 无效</span>' : '')
1570
+ + (a.verified === false ? T('<span class="pill fail">token 无效</span>') : '')
1205
1571
  + (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>' : '')
1206
1572
  + '</div><div class="ops">'
1207
- + (a.product === 'zcode' ? '<button class="icon-btn" data-act="claim" title="活动领取(billing/preview → claim,可能需过一次验证码)">' + I.gift + '</button>' : '')
1208
- + (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>' : '')
1209
- + (a.canSwitch && !isCurrent && st.cls !== 'fail' ? '<button class="icon-btn" data-act="switch" title="切换 ' + (CLIENT_LABEL[a.product] || prodLabel(a.product)) + ' 客户端到此账号">' + I.swap + '</button>' : '')
1210
- + '<button class="icon-btn" data-act="quota" title="刷新积分">' + I.refresh + '</button>'
1211
- + '<button class="icon-btn del" data-act="del" title="删除(点两次确认)">' + I.trash + '</button>'
1573
+ + (a.product === 'zcode' ? T('<button class="icon-btn" data-act="claim" title="活动领取(billing/preview → claim,可能需过一次验证码)">') + I.gift + '</button>' : '')
1574
+ + (canCheckin && (a.provider !== 'workbuddy-intl' || wbActOn()) ? '<button class="icon-btn' + (state.busy[a.id] ? ' busy' : '') + '" data-act="checkin" title="' + (a.provider === 'workbuddy-intl' ? T('发送活跃请求') : T('立即领取')) + '">' + (state.busy[a.id] ? I.refresh : I.play) + '</button>' : '')
1575
+ + (a.canSwitch && !isCurrent && st.cls !== 'fail' ? T('<button class="icon-btn" data-act="switch" title="切换 ') + (CLIENT_LABEL[a.product] ? T(CLIENT_LABEL[a.product]) : prodLabel(a.product)) + T(' 客户端到此账号">') + I.swap + '</button>' : '')
1576
+ + T('<button class="icon-btn" data-act="quota" title="刷新积分">') + I.refresh + '</button>'
1577
+ + T('<button class="icon-btn del" data-act="del" title="删除(点两次确认)">') + I.trash + '</button>'
1212
1578
  + '</div></div>'
1213
1579
  + '<div class="meta">'
1214
- + '<div class="mi"><span class="lbl">账号</span><span class="val" title="' + esc(a.email || a.phone || a.uid || '') + '">' + esc(a.phone && !a.email ? ident : mask(ident)) + '</span></div>'
1215
- + '<div class="mi right"><span class="lbl">' + (ex.isPlan ? '套餐到期' : ex.isAuto ? '凭据状态' : '有效期至') + '</span>'
1580
+ + '<div class="mi"><span class="lbl">' + T('账号') + '</span><span class="val" title="' + esc(a.email || a.phone || a.uid || '') + '">' + esc(a.phone && !a.email ? ident : mask(ident)) + '</span></div>'
1581
+ + '<div class="mi right"><span class="lbl">' + (ex.isPlan ? T('套餐到期') : ex.isAuto ? T('凭据状态') : T('有效期至')) + '</span>'
1216
1582
  + '<span class="val' + (ex.warn ? ' warn' : '') + '" title="' + esc(ex.title || '') + '">' + esc(ex.label) + '</span></div>'
1217
1583
  + '</div>'
1218
1584
  + creditHtml(a)
1219
1585
  + (st.msg && !(state.quotas[a.id] && state.quotas[a.id].error && state.quotas[a.id].error.includes(st.msg.slice(-24)))
1220
- ? '<div class="msg' + (st.err ? ' err' : '') + '" title="' + esc(st.msg) + '">' + esc(st.msg) + '</div>' : '')
1586
+ ? '<div class="msg' + (st.err ? ' err' : '') + '" title="' + esc(T(st.msg)) + '">' + esc(T(st.msg)) + '</div>' : '')
1221
1587
  + '</div>';
1222
1588
  }
1223
1589
 
@@ -1298,7 +1664,7 @@ const EMPTY_TIP = {
1298
1664
  mirasim: '还没有 mirasim 账号。<br>点「添加账号」可读取本机 mirasim 客户端当前登录的账号(本地解密,token 不出本机)。',
1299
1665
  catpaw: '还没有妙手账号。<br>点「添加账号」可读取本机妙手(美团 CatPaw)客户端当前登录的账号(本地解密凭据,token 不出本机)。',
1300
1666
  trae: '还没有 Trae 账号。<br>点「添加账号 → 本机导入」可读取本机 Trae 客户端当前登录的账号(本地解密 storage.json 凭据,token 不出本机)。多个账号需先在 Trae 里分别登录并逐个导入。',
1301
- minimax: '还没有 MiniMax 账号。<br>点「添加账号 → 本机导入」可读取本机 MiniMax Code 客户端当前登录的账号,或粘贴 Bearer Token 添加。',
1667
+ minimax: '还没有 MiniMax 账号。<br>点「添加账号 → 本机导入」可读取本机 MiniMax Code 客户端当前登录的账号,或用浏览器登录 / 粘贴 Bearer Token 添加。',
1302
1668
  };
1303
1669
 
1304
1670
  function renderProd() {
@@ -1306,7 +1672,7 @@ function renderProd() {
1306
1672
  const all = ofProduct(prod);
1307
1673
  const acc = all.filter((a) => !state.region || provOf(a.provider).cls === state.region);
1308
1674
  const list = $('list');
1309
- if (!acc.length) list.innerHTML = '<div class="empty">' + (all.length ? '该地区下没有账号。' : EMPTY_TIP[prod]) + '</div>';
1675
+ if (!acc.length) list.innerHTML = '<div class="empty">' + (all.length ? T('该地区下没有账号。') : T(EMPTY_TIP[prod])) + '</div>';
1310
1676
  else list.innerHTML = sortedAccounts(prod, acc).map(cardHtml).join('');
1311
1677
  const canCheckin = CHECKIN_PRODUCTS.has(prod);
1312
1678
  $('s-count').textContent = acc.length;
@@ -1314,16 +1680,16 @@ function renderProd() {
1314
1680
  if (prod === 'workbuddy') {
1315
1681
  // 国内版 = 每日签到送积分;国际版 = 保持活跃(活跃不等于必有奖励)
1316
1682
  const cnAcc = acc.filter((a) => a.provider === 'workbuddy');
1317
- const intlAcc = acc.filter((a) => a.provider === 'workbuddy-intl');
1318
- $('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>' : '')
1319
- + (intlAcc.length ? '<div class="stat"><span title="国际版定期发送活跃请求保持资格;活跃不等于必有奖励">活跃(国际)</span><strong>' + intlAcc.filter(isDone).length + ' / ' + intlAcc.length + '</strong></div><div class="divider"></div>' : '');
1683
+ const intlAcc = acc.filter((a) => a.provider === 'workbuddy-intl' && wbActOn());
1684
+ $('s-done-wrap').innerHTML = (cnAcc.length ? T('<div class="stat"><span title="国内版每日签到送积分">领取(国内)</span><strong>') + cnAcc.filter(isDone).length + ' / ' + cnAcc.length + '</strong></div><div class="divider"></div>' : '')
1685
+ + (intlAcc.length ? T('<div class="stat"><span title="国际版定期发送活跃请求保持资格;活跃不等于必有奖励">活跃(国际)</span><strong>') + intlAcc.filter(isDone).length + ' / ' + intlAcc.length + '</strong></div><div class="divider"></div>' : '');
1320
1686
  } else {
1321
1687
  // 从 WorkBuddy 页切回来时 s-done 可能被 innerHTML 换没了,先重建结构再写值
1322
- if (!$('s-done')) $('s-done-wrap').innerHTML = '<div class="stat"><span>今日已领</span><strong id="s-done"></strong></div><div class="divider"></div>';
1688
+ if (!$('s-done')) $('s-done-wrap').innerHTML = T('<div class="stat"><span>今日已领</span><strong id="s-done"></strong></div><div class="divider"></div>');
1323
1689
  $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
1324
1690
  }
1325
1691
  const c = creditSum(acc);
1326
- $('s-credits-label').textContent = CREDIT_VIEWS.has(prod) ? '有效套餐' : '剩余积分';
1692
+ $('s-credits-label').textContent = CREDIT_VIEWS.has(prod) ? T('有效套餐') : T('剩余积分');
1327
1693
  $('s-credits').textContent = CREDIT_VIEWS.has(prod)
1328
1694
  ? acc.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length + ' / ' + acc.length
1329
1695
  : c.loading ? '…' : (c.known ? fmtNum(c.sum) : '-');
@@ -1333,8 +1699,8 @@ function renderProd() {
1333
1699
  document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
1334
1700
  $('btn-checkin').style.display = canCheckin ? '' : 'none';
1335
1701
  if (!$('btn-checkin').disabled) {
1336
- const hasIntl = acc.some((a) => a.provider === 'workbuddy-intl');
1337
- $('btn-checkin').innerHTML = I.play + (prod === 'workbuddy' && hasIntl ? '领取 / 活跃全部' : '领取全部 ' + prodLabel(prod));
1702
+ const hasIntl = acc.some((a) => a.provider === 'workbuddy-intl') && wbActOn();
1703
+ $('btn-checkin').innerHTML = I.play + (prod === 'workbuddy' && hasIntl ? T('领取 / 活跃全部') : T('领取全部 ') + prodLabel(prod));
1338
1704
  }
1339
1705
  // 顶栏产品专属入口:ZCode 设置组、Trae 打开客户端,带状态灯(运行中绿点呼吸)
1340
1706
  const dot = (on, pulse) => '<span class="dot' + (on ? ' on' : '') + (on && pulse ? ' pulse' : '') + '"></span>';
@@ -1342,44 +1708,42 @@ function renderProd() {
1342
1708
  if (prod === 'zcode') {
1343
1709
  const acOn = state.zAutoClaim === true;
1344
1710
  const gwOn = state.zGateway === true;
1345
- $('zt-ac').innerHTML = dot(acOn) + '自动领取';
1346
- $('zt-ac').title = acOn ? '自动领取运行中——点我调整轮询周期 / 运行时长' : '自动领取已关闭——点我设置轮询周期与运行时长并开启';
1347
- $('zt-net').innerHTML = '出口:<b style="font-weight:700;color:var(--text)">' + (state.zProxyFirst === true ? '代理优先' : '直连优先') + '</b>';
1348
- $('zt-net').title = '网络出口:直连 / 代理顺序与代理地址';
1349
- $('zt-gw').innerHTML = dot(gwOn) + '接口' + (gwOn && state.zGatewayLan === true ? '·局域网' : '');
1350
- $('zt-gw').title = '体验包接口:开关、局域网开放与 IP 白名单';
1711
+ $('zt-ac').innerHTML = dot(acOn) + T('自动领取');
1712
+ $('zt-ac').title = acOn ? T('自动领取运行中——点我调整轮询周期 / 运行时长') : T('自动领取已关闭——点我设置轮询周期与运行时长并开启');
1713
+ $('zt-gw').innerHTML = dot(gwOn) + T('接口') + (gwOn && state.zGatewayLan === true ? T('·局域网') : '');
1714
+ $('zt-gw').title = T('体验包接口:开关、局域网开放与 IP 白名单');
1351
1715
  const zc = (state.status && state.status.zcodeClient) || {};
1352
1716
  const canOpen = zc.installed && window.creditdaddy && typeof window.creditdaddy.openZcodeClient === 'function';
1353
1717
  $('zt-open').style.display = canOpen ? '' : 'none';
1354
1718
  if (canOpen) {
1355
- $('zt-open').innerHTML = dot(zc.running === true, true) + '打开客户端';
1356
- $('zt-open').title = zc.running ? 'ZCode 客户端运行中,点我唤到前台' : 'ZCode 客户端未运行,点我启动';
1719
+ $('zt-open').innerHTML = dot(zc.running === true, true) + T('打开客户端');
1720
+ $('zt-open').title = zc.running ? T('ZCode 客户端运行中,点我唤到前台') : T('ZCode 客户端未运行,点我启动');
1357
1721
  }
1358
1722
  }
1359
1723
  $('t-actions').style.display = prod === 'trae' ? 'contents' : 'none';
1360
1724
  if (prod === 'trae') {
1361
1725
  const gwOn = state.tGateway === true;
1362
- $('tt-gw').innerHTML = dot(gwOn) + '接口' + (gwOn && state.tGatewayLan === true ? '·局域网' : '');
1363
- $('tt-gw').title = 'Trae 本地接口:开关、局域网开放与 IP 白名单';
1726
+ $('tt-gw').innerHTML = dot(gwOn) + T('接口') + (gwOn && state.tGatewayLan === true ? T('·局域网') : '');
1727
+ $('tt-gw').title = T('Trae 本地接口:开关、局域网开放与 IP 白名单');
1364
1728
  const tc = (state.status && state.status.traeClient) || {};
1365
1729
  const canOpen = tc.installed && window.creditdaddy && typeof window.creditdaddy.openTraeClient === 'function';
1366
1730
  $('tt-open').style.display = canOpen ? '' : 'none';
1367
1731
  if (canOpen) {
1368
- $('tt-open').innerHTML = dot(tc.running === true, true) + '打开客户端';
1369
- $('tt-open').title = tc.running ? 'Trae 客户端运行中,点我唤到前台' : 'Trae 客户端未运行,点我启动';
1732
+ $('tt-open').innerHTML = dot(tc.running === true, true) + T('打开客户端');
1733
+ $('tt-open').title = tc.running ? T('Trae 客户端运行中,点我唤到前台') : T('Trae 客户端未运行,点我启动');
1370
1734
  }
1371
1735
  }
1372
1736
  $('m-actions').style.display = prod === 'minimax' ? 'contents' : 'none';
1373
1737
  if (prod === 'minimax') {
1374
1738
  const gwOn = state.mGateway === true;
1375
- $('mt-gw').innerHTML = dot(gwOn) + '接口' + (gwOn && state.mGatewayLan === true ? '·局域网' : '');
1376
- $('mt-gw').title = 'MiniMax 本地接口:开关、局域网开放与 IP 白名单';
1739
+ $('mt-gw').innerHTML = dot(gwOn) + T('接口') + (gwOn && state.mGatewayLan === true ? T('·局域网') : '');
1740
+ $('mt-gw').title = T('MiniMax 本地接口:开关、局域网开放与 IP 白名单');
1377
1741
  const mc = (state.status && state.status.minimaxClient) || {};
1378
1742
  const canOpen = mc.installed && window.creditdaddy && typeof window.creditdaddy.openMiniMaxClient === 'function';
1379
1743
  $('mt-open').style.display = canOpen ? '' : 'none';
1380
1744
  if (canOpen) {
1381
- $('mt-open').innerHTML = dot(mc.running === true, true) + '打开客户端';
1382
- $('mt-open').title = mc.running ? 'MiniMax Code 客户端运行中,点我唤到前台' : 'MiniMax Code 客户端未运行,点我启动';
1745
+ $('mt-open').innerHTML = dot(mc.running === true, true) + T('打开客户端');
1746
+ $('mt-open').title = mc.running ? T('MiniMax Code 客户端运行中,点我唤到前台') : T('MiniMax Code 客户端未运行,点我启动');
1383
1747
  }
1384
1748
  }
1385
1749
  }
@@ -1400,29 +1764,29 @@ function renderDash() {
1400
1764
  const byProd = orderedVisibleComps().filter((id) => id !== 'tenrouter').map((id) => prodLabel(id) + ' ' + ofProduct(id).length).join(' · ');
1401
1765
  const creditByProd = ['qoder', 'workbuddy', 'minimax'].filter(compVisible).map((id) => { const c = creditSum(ofProduct(id)); return prodLabel(id) + ' ' + (c.known ? fmtNum(c.sum) : '-'); }).join(' · ');
1402
1766
  // 今日进度拆「国内领取 / 国际活跃」:国际版 WorkBuddy 是保持活跃(活跃不等于必有奖励)
1403
- const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl');
1767
+ const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl' && wbActOn());
1404
1768
  const domAcc = signable.filter((a) => a.provider !== 'workbuddy-intl');
1405
1769
  const doneDom = domAcc.filter(isDone).length;
1406
1770
  const doneIntl = intlAcc.filter(isDone).length;
1407
1771
  $('kpis').innerHTML =
1408
- kpi('账号', state.accounts.length, byProd)
1409
- + kpi('今日领取' + (intlAcc.length ? ' / 活跃' : ''), intlAcc.length
1410
- ? doneDom + ' <small>/ ' + domAcc.length + '</small><small class="kpi-side"> · 活跃 ' + doneIntl + ' / ' + intlAcc.length + '</small>'
1772
+ kpi(T('账号'), state.accounts.length, byProd)
1773
+ + kpi(T('今日领取') + (intlAcc.length ? T(' / 活跃') : ''), intlAcc.length
1774
+ ? doneDom + ' <small>/ ' + domAcc.length + '</small><small class="kpi-side">' + T(' · 活跃 {n} / {m}', { n: doneIntl, m: intlAcc.length }) + '</small>'
1411
1775
  : done + ' <small>/ ' + signable.length + '</small>',
1412
- !signable.length ? '暂无可领取的账号'
1413
- : (doneDom === domAcc.length && doneIntl === intlAcc.length ? (intlAcc.length ? '今天全部完成(国际为活跃请求,不保证奖励)' : '今天全部签完')
1414
- : '待办 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : '')),
1415
- '领取 = 国内版每日签到;活跃 = 国际版定期活跃请求(保持资格,不保证奖励)')
1416
- + kpi('剩余积分', credit.loading ? '…' : (credit.known ? fmtNum(credit.sum) : '-'), credit.loading ? '正在读取各账号额度…' : creditByProd)
1417
- + kpi('下次自动领取', sch.ticking ? '领取中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
1418
- sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : '每 ~2 小时一轮');
1776
+ !signable.length ? T('暂无可领取的账号')
1777
+ : (doneDom === domAcc.length && doneIntl === intlAcc.length ? (intlAcc.length ? T('今天全部完成(国际为活跃请求,不保证奖励)') : T('今天全部签完'))
1778
+ : T('待办 ') + (signable.length - done) + (failed ? T(' · 失败 ') + failed : '')),
1779
+ T('领取 = 国内版每日签到;活跃 = 国际版定期活跃请求(保持资格,不保证奖励)'))
1780
+ + kpi(T('剩余积分'), credit.loading ? '…' : (credit.known ? fmtNum(credit.sum) : '-'), credit.loading ? T('正在读取各账号额度…') : creditByProd)
1781
+ + kpi(T('下次自动领取'), sch.ticking ? T('领取中…') : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
1782
+ sch.lastTick ? T('上一轮 ') + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : T('每 ~2 小时一轮'));
1419
1783
 
1420
1784
  // 仪表盘产品卡按组件设置里的顺序渲染(与标签栏同步),10Router 汇总卡按其顺序插入
1421
1785
  const prodCardHtml = (p) => {
1422
1786
  const list = ofProduct(p.id);
1423
1787
  let rows = '';
1424
1788
  if (!list.length) {
1425
- rows = '<span class="lbl">账号</span><span class="val dim">还没有,点击进入添加</span>';
1789
+ rows = '<span class="lbl">' + T('账号') + '</span><span class="val dim">' + T('还没有,点击进入添加') + '</span>';
1426
1790
  } else if (CHECKIN_PRODUCTS.has(p.id)) {
1427
1791
  const d = list.filter(isDone).length;
1428
1792
  const c = creditSum(list);
@@ -1431,57 +1795,57 @@ function renderDash() {
1431
1795
  const cnList = list.filter((a) => a.provider === 'workbuddy');
1432
1796
  const intlList = list.filter((a) => a.provider === 'workbuddy-intl');
1433
1797
  const parts = [];
1434
- if (cnList.length) parts.push(cnList.filter(isDone).length + ' / ' + cnList.length + '(国内)');
1435
- if (intlList.length) parts.push(intlList.filter(isDone).length + ' / ' + intlList.length + '(国际 · 不保证奖励)');
1436
- rows += '<span class="lbl">领取 / 活跃</span><span class="val" title="' + esc(parts.join(',') + (intlList.length ? '(国际为活跃请求,不保证奖励)' : '')) + '">'
1437
- + (cnList.length ? cnList.filter(isDone).length + ' / ' + cnList.length + ' 国内' : '')
1798
+ if (cnList.length) parts.push(cnList.filter(isDone).length + ' / ' + cnList.length + T('(国内)'));
1799
+ if (intlList.length) parts.push(intlList.filter(isDone).length + ' / ' + intlList.length + T('(国际 · 不保证奖励)'));
1800
+ rows += T('<span class="lbl">领取 / 活跃</span><span class="val" title="') + esc(parts.join(T(',')) + (intlList.length ? T('(国际为活跃请求,不保证奖励)') : '')) + '">'
1801
+ + (cnList.length ? cnList.filter(isDone).length + ' / ' + cnList.length + T(' 国内') : '')
1438
1802
  + (cnList.length && intlList.length ? ' · ' : '')
1439
- + (intlList.length ? intlList.filter(isDone).length + ' / ' + intlList.length + ' 国际' : '')
1803
+ + (intlList.length ? intlList.filter(isDone).length + ' / ' + intlList.length + T(' 国际') : '')
1440
1804
  + '</span>';
1441
1805
  } else if (p.id === 'minimax') {
1442
1806
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1443
- rows = '<span class="lbl">今日领取</span><span class="val">' + d + ' / ' + list.length + '</span>'
1807
+ rows = T('<span class="lbl">今日领取</span><span class="val">') + d + ' / ' + list.length + '</span>'
1444
1808
  + '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
1445
- + '<span class="lbl">剩余积分</span><span class="val">' + (c.known ? fmtNum(c.sum) : '-') + '</span>';
1809
+ + T('<span class="lbl">剩余积分</span><span class="val">') + (c.known ? fmtNum(c.sum) : '-') + '</span>';
1446
1810
  } else {
1447
- rows = '<span class="lbl">今日领取</span><span class="val">' + d + ' / ' + list.length + '</span>';
1811
+ rows = T('<span class="lbl">今日领取</span><span class="val">') + d + ' / ' + list.length + '</span>';
1448
1812
  }
1449
1813
  if (p.id !== 'minimax') {
1450
1814
  rows += '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
1451
- + '<span class="lbl">剩余积分</span><span class="val">' + (c.known ? fmtNum(c.sum) : '-') + '</span>';
1815
+ + T('<span class="lbl">剩余积分</span><span class="val">') + (c.known ? fmtNum(c.sum) : '-') + '</span>';
1452
1816
  }
1453
1817
  } else if (p.id === 'mirasim') {
1454
1818
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1455
- rows = '<span class="lbl">额度体系</span><span class="val dim">5h / 7d 滚动窗口</span>'
1456
- + '<span class="lbl">有效套餐</span><span class="val">' + planned + ' / ' + list.length + '</span>';
1819
+ rows = T('<span class="lbl">额度体系</span><span class="val dim">5h / 7d 滚动窗口</span>')
1820
+ + T('<span class="lbl">有效套餐</span><span class="val">') + planned + ' / ' + list.length + '</span>';
1457
1821
  } else if (p.id === 'catpaw') {
1458
1822
  const withPlan = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && q.plan; });
1459
1823
  const credits = withPlan.reduce((s, a) => s + (Number(state.quotas[a.id].remaining) || 0), 0);
1460
- rows = '<span class="lbl">额度体系</span><span class="val dim">Credits 余额 + 套餐版本</span>'
1461
- + (withPlan.length ? '<span class="lbl">可用额度</span><span class="val">' + fmtNum(credits) + ' Credits(' + esc(withPlan.map((a) => state.quotas[a.id].plan).join(' / ')) + ')</span>' : '');
1824
+ rows = T('<span class="lbl">额度体系</span><span class="val dim">Credits 余额 + 套餐版本</span>')
1825
+ + (withPlan.length ? T('<span class="lbl">可用额度</span><span class="val">') + fmtNum(credits) + T(' Credits(') + esc(withPlan.map((a) => state.quotas[a.id].plan).join(' / ')) + T(')</span>') : '');
1462
1826
  } else {
1463
1827
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1464
1828
  // ZCode 没有每日签到:不展示签到语义的行
1465
- rows = '<span class="lbl">活动领取</span><span class="val dim">手动领取,或在标签页开自动轮询</span>'
1466
- + '<span class="lbl">有效套餐</span><span class="val">' + planned + ' / ' + list.length + '</span>';
1829
+ rows = T('<span class="lbl">活动领取</span><span class="val dim">手动领取,或在标签页开自动轮询</span>')
1830
+ + T('<span class="lbl">有效套餐</span><span class="val">') + planned + ' / ' + list.length + '</span>';
1467
1831
  }
1468
1832
  if (p.id === 'qoder' && list.some((a) => a.provider === 'qoder')) {
1469
- rows += '<span class="lbl">国际版风控身份</span><span class="val' + (s.riskIdentity ? '' : ' warn') + '">' + (s.riskIdentity ? (s.riskSource === 'cli' ? '已就绪(设备身份组件)' : '已就绪') : umidOffer(s) ? '需安装设备身份组件' : '未检测到客户端') + '</span>';
1833
+ rows += T('<span class="lbl">国际版风控身份</span><span class="val') + (s.riskIdentity ? '' : ' warn') + '">' + (s.riskIdentity ? (s.riskSource === 'cli' ? T('已就绪(设备身份组件)') : T('已就绪')) : umidOffer(s) ? T('需安装设备身份组件') : T('未检测到客户端')) + '</span>';
1470
1834
  }
1471
1835
  if (CLIENT_LABEL[p.id]) {
1472
1836
  const cur = list.find((a) => a.product === p.id && isClientCurrent(a));
1473
1837
  const zid = p.id === 'zcode' ? s.zcodeCurrentIdentity : null;
1474
1838
  const live = cur ? esc(cur.name || cur.email || cur.uid)
1475
- : zid && (zid.email || zid.uid || zid.username) ? '未导入的账号(' + esc(zid.email || zid.username || zid.uid) + ')'
1476
- : p.id === 'mirasim' && s.mirasimCurrentUid ? '未导入的账号'
1477
- : p.id === 'catpaw' && s.catpawClient && s.catpawClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1478
- : p.id === 'trae' && s.traeClient && s.traeClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1479
- : p.id === 'minimax' && s.minimaxClient && s.minimaxClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1480
- : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? '未导入的账号' : '—');
1481
- rows += '<span class="lbl">客户端当前</span><span class="val">' + live + '</span>';
1839
+ : zid && (zid.email || zid.uid || zid.username) ? T('未导入的账号(') + esc(zid.email || zid.username || zid.uid) + T(')')
1840
+ : p.id === 'mirasim' && s.mirasimCurrentUid ? T('未导入的账号')
1841
+ : p.id === 'catpaw' && s.catpawClient && s.catpawClient.signedIn && !list.some((a) => isClientCurrent(a)) ? T('未导入的账号')
1842
+ : p.id === 'trae' && s.traeClient && s.traeClient.signedIn && !list.some((a) => isClientCurrent(a)) ? T('未导入的账号')
1843
+ : p.id === 'minimax' && s.minimaxClient && s.minimaxClient.signedIn && !list.some((a) => isClientCurrent(a)) ? T('未导入的账号')
1844
+ : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? T('未导入的账号') : '—');
1845
+ rows += T('<span class="lbl">客户端当前</span><span class="val">') + live + '</span>';
1482
1846
  }
1483
1847
  return '<div class="prodcard" data-view="' + p.id + '"><div class="prod-head"><span class="prod-name"><span class="dot ' + p.id + '"></span>' + p.label
1484
- + '<span class="badge">' + list.length + ' 个账号</span></span><span class="prod-go">查看' + I.chevRight + '</span></div><div class="prod-rows">' + rows + '</div></div>';
1848
+ + '<span class="badge">' + list.length + T(' 个账号</span></span><span class="prod-go">查看') + I.chevRight + '</span></div><div class="prod-rows">' + rows + '</div></div>';
1485
1849
  };
1486
1850
  $('prods').innerHTML = orderedVisibleComps().map((id) => {
1487
1851
  if (id === 'tenrouter') return trDashCard();
@@ -1492,101 +1856,112 @@ function renderDash() {
1492
1856
  // 需要处理:token 失效 / 即将过期、领取失败、国际版缺风控身份、10Router 额度告急 / 查询失败
1493
1857
  const att = [];
1494
1858
  if (!s.riskIdentity && state.accounts.some((a) => a.provider === 'qoder')) {
1495
- att.push({ view: 'qoder', name: 'Qoder 国际版', badges: '', pill: 'limited', text: umidOffer(s) ? '安装设备身份组件后才能领取' : '未检测到 Qoder 客户端,国际版无法领取' });
1859
+ att.push({ view: 'qoder', name: T('Qoder 国际版'), badges: '', pill: 'limited', text: umidOffer(s) ? T('安装设备身份组件后才能领取') : T('未检测到 Qoder 客户端,国际版无法领取') });
1496
1860
  }
1497
1861
  for (const a of state.accounts) {
1498
1862
  const ex = expiryCell(a);
1499
1863
  const st = statusOf(a);
1500
1864
  const base = { view: a.product, name: a.name || a.email || a.id, badges: provBadges(a.provider) };
1501
- if (a.verified === false) att.push(Object.assign({ pill: 'fail', text: 'token 无效' }, base));
1502
- else if (ex.label === '已过期') att.push(Object.assign({ pill: 'fail', text: 'token 已过期' }, base));
1503
- else if (ex.warn) att.push(Object.assign({ pill: 'limited', text: ex.label.slice(5, 10) + ' 过期' }, base));
1504
- if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: a.provider === 'workbuddy-intl' ? '活跃失败' : '领取失败', title: st.msg }, base));
1865
+ if (a.verified === false) att.push(Object.assign({ pill: 'fail', text: T('token 无效') }, base));
1866
+ else if (ex.label === '已过期') att.push(Object.assign({ pill: 'fail', text: T('token 已过期') }, base));
1867
+ else if (ex.warn) att.push(Object.assign({ pill: 'limited', text: ex.label.slice(5, 10) + T(' 过期') }, base));
1868
+ if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: a.provider === 'workbuddy-intl' ? T('活跃失败') : T('领取失败'), title: st.msg }, base));
1505
1869
  }
1506
1870
  for (const c of (tr.quotas && tr.quotas.connections) || []) {
1507
1871
  if (!compVisible('tenrouter')) break;
1508
1872
  const base = { view: 'tenrouter', name: c.name || c.email || c.providerName, badges: '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>' };
1509
- if (c.error) att.push(Object.assign({ pill: 'fail', text: '额度查询失败', title: c.error }, base));
1510
- else if (c.isActive && quotaLow(c)) att.push(Object.assign({ pill: 'limited', text: '额度告急' }, base));
1873
+ if (c.error) att.push(Object.assign({ pill: 'fail', text: T('额度查询失败'), title: c.error }, base));
1874
+ else if (c.isActive && quotaLow(c)) att.push(Object.assign({ pill: 'limited', text: T('额度告急') }, base));
1511
1875
  }
1512
1876
  // 隐藏掉的组件不进「需要处理」(如收起 10Router 后不再展示其额度告急)
1513
1877
  const attVisible = att.filter((x) => compVisible(x.view));
1514
- $('att-count').textContent = attVisible.length ? attVisible.length + ' 项' : '';
1878
+ $('att-count').textContent = attVisible.length ? attVisible.length + T(' 项') : '';
1515
1879
  $('attention').innerHTML = attVisible.length
1516
1880
  ? attVisible.slice(0, 8).map((x) => '<div class="item" data-view="' + x.view + '" title="' + esc(x.title || '') + '"><span class="item-main"><span class="item-name">' + esc(x.name) + '</span>' + x.badges + '</span>'
1517
1881
  + '<span class="item-side"><span class="pill ' + x.pill + '">' + esc(x.text) + '</span></span></div>').join('')
1518
- + (attVisible.length > 8 ? '<div class="muted-empty">还有 ' + (attVisible.length - 8) + ' 项</div>' : '')
1519
- : '<div class="muted-empty">' + (state.accounts.length ? '<span class="ok-glyph">' + I.check + '</span>一切正常' : '还没有账号') + '</div>';
1882
+ + (attVisible.length > 8 ? T('<div class="muted-empty">还有 ') + (attVisible.length - 8) + T(' 项</div>') : '')
1883
+ : '<div class="muted-empty">' + (state.accounts.length ? '<span class="ok-glyph">' + I.check + T('</span>一切正常') : T('还没有账号')) + '</div>';
1520
1884
 
1521
1885
  // 最近领取记录
1522
- const RES = { 'checked-in': ['ok', '领取成功'], already: ['ok', '已领过'], limited: ['limited', '本机已领'], 'no-activity': ['pending', '无可领活动'], failed: ['fail', '失败'] };
1886
+ const RES = { 'checked-in': ['ok', T('领取成功')], already: ['ok', T('已领过')], limited: ['limited', T('本机已领')], 'no-activity': ['pending', T('无可领活动')], failed: ['fail', T('失败')] };
1523
1887
  const recent = state.accounts.filter((a) => a.lastResult && a.lastResult.at && compVisible(a.product))
1524
1888
  .sort((x, y) => String(y.lastResult.at).localeCompare(String(x.lastResult.at))).slice(0, 8);
1525
- $('last-tick').textContent = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) : '';
1889
+ $('last-tick').textContent = sch.lastTick ? T('上一轮 ') + fmtTime(sch.lastTick.at).slice(5) : '';
1526
1890
  $('recent').innerHTML = recent.length
1527
1891
  ? recent.map((a) => {
1528
1892
  const r = a.lastResult;
1529
1893
  let [cls, txt] = RES[r.status] || ['pending', r.status];
1530
1894
  // 国际版 WorkBuddy 是保持活跃(不保证奖励),不叫「领取」
1531
- if (a.provider === 'workbuddy-intl' && r.status === 'checked-in') txt = '活跃达成';
1895
+ if (a.provider === 'workbuddy-intl' && r.status === 'checked-in') txt = T('活跃达成');
1532
1896
  const label = r.status === 'checked-in' && r.amount ? txt + ' +' + fmtNum(r.amount) : txt;
1533
1897
  return '<div class="item" data-view="' + a.product + '" title="' + esc(r.message || '') + '"><span class="item-main"><span class="item-name">' + esc(a.name || a.email || a.id) + '</span>' + provBadges(a.provider) + '</span>'
1534
1898
  + '<span class="item-side"><span class="pill ' + cls + '">' + esc(label) + '</span><span class="item-time">' + fmtTime(r.at).slice(5) + '</span></span></div>';
1535
1899
  }).join('')
1536
- : '<div class="muted-empty">还没有领取记录</div>';
1900
+ : '<div class="muted-empty">' + T('还没有领取记录') + '</div>';
1537
1901
  }
1538
1902
 
1539
1903
  function renderStatus() {
1540
1904
  const s = state.status;
1541
1905
  if (!s) return;
1542
- $('ver').textContent = 'v' + s.version + ' · 数据目录 ' + s.dataDir;
1906
+ $('ver').textContent = 'v' + s.version + T(' · 数据目录 ') + s.dataDir;
1543
1907
  document.title = 'CreditDaddy v' + s.version;
1544
- if (s.homepage) $('btn-home').href = s.homepage;
1908
+ if (s.homepage) $('hm-home').href = s.homepage;
1909
+ // 菜单脚注版本:面板版本 + 数据目录(与品牌区同一个口径,换行折行由 CSS 收敛)
1910
+ $('hmenu-version').textContent = 'CreditDaddy v' + s.version;
1911
+ $('hmenu-version').title = s.dataDir ? T('数据目录 ') + s.dataDir : '';
1912
+ // 状态胶囊:领取中显示强调色文字,空闲显示「下次自动领取 HH:MM」(时间用 <b> 提权重)
1545
1913
  const sch = s.scheduler || {};
1546
- $('sched').textContent = sch.ticking ? '正在领取…'
1547
- : sch.nextTickAt ? '下次自动领取 ' + fmtTime(sch.nextTickAt).slice(11) : '';
1548
- $('sched').title = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at) + ':' + sch.lastTick.summary : '';
1914
+ const chip = $('sched');
1915
+ chip.classList.toggle('busy', Boolean(sch.ticking));
1916
+ if (sch.ticking) {
1917
+ chip.innerHTML = I.refresh + T('正在领取…');
1918
+ } else if (sch.nextTickAt) {
1919
+ chip.innerHTML = I.clock + T('下次自动领取 <b class="num">') + fmtTime(sch.nextTickAt).slice(11) + '</b>';
1920
+ } else {
1921
+ chip.innerHTML = '';
1922
+ }
1923
+ chip.title = sch.lastTick ? T('上一轮 ') + fmtTime(sch.lastTick.at) + T(':') + sch.lastTick.summary : '';
1549
1924
  // 产品相关提示只在对应标签页出现
1550
1925
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
1551
1926
  const n = $('notice');
1552
1927
  n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || state.view === 'catpaw' || state.view === 'trae' || state.view === 'minimax' || (hasIntl && Boolean(s.riskIdentity)));
1553
1928
  if (state.view === 'minimax') {
1554
1929
  n.style.display = '';
1555
- n.innerHTML = '<div class="z-row"><span class="z-label">每日签到</span><span class="z-text">每日签到领 800~2000 积分 + 400 连签加成。支持自动检测本机 MiniMax Code 凭据与 Bearer Token 导入,自带标准 Anthropic 本地网关。</span></div>';
1930
+ n.innerHTML = T('<div class="z-row"><span class="z-label">每日签到</span><span class="z-text">每日签到领 800~2000 积分 + 400 连签加成。支持自动检测本机 MiniMax Code 凭据与 Bearer Token 导入,自带标准 Anthropic 本地网关。</span></div>');
1556
1931
  } else if (state.view === 'trae') {
1557
1932
  n.style.display = '';
1558
- n.innerHTML = '<div class="z-row"><span class="z-label">每日签到</span><span class="z-text">每日签到领约 150 通用积分(7 天有效,多资源包自动归并)。凭据由本机 Trae 客户端解密导入,支持多账号快照一键切换。</span></div>';
1933
+ n.innerHTML = T('<div class="z-row"><span class="z-label">每日签到</span><span class="z-text">每日签到领约 150 通用积分(7 天有效,多资源包自动归并)。凭据由本机 Trae 客户端解密导入,支持多账号快照一键切换。</span></div>');
1559
1934
  } else if (state.view === 'catpaw') {
1560
1935
  n.style.display = '';
1561
- n.innerHTML = '妙手(美团 CatPaw)按套餐发放 Credits(体验版 / 专业版…),没有每日签到。凭据由妙手客户端登录管理:读取额度用本机解密出的登录 token 直连桌面网关,token 失效时到妙手客户端重新登录后再「本机导入」即可。'
1562
- + '多账号切换:写入本机凭据后重启妙手客户端即生效(切换会自动保存当前登录,不丢号)。'
1936
+ n.innerHTML = T('妙手(美团 CatPaw)按套餐发放 Credits(体验版 / 专业版…),没有每日签到。凭据由妙手客户端登录管理:读取额度用本机解密出的登录 token 直连桌面网关,token 失效时到妙手客户端重新登录后再「本机导入」即可。')
1937
+ + T('多账号切换:写入本机凭据后重启妙手客户端即生效(切换会自动保存当前登录,不丢号)。')
1563
1938
  + catpawClientChip(s);
1564
1939
  } else if (state.view === 'mirasim') {
1565
1940
  n.style.display = '';
1566
- n.innerHTML = 'mirasim(原生 AI 编程开发环境)额度按「平台额度」(5h 滚动窗口、7d 全局上限与分模型上限)分配。Token 约 1 小时过期,读取额度时会自动用 refreshToken 静默续期并同步回客户端,无需手动处理。'
1567
- + '切换账号需要至少两个已导入的账号(当前登录 + 目标账号):先在「添加账号 → 本机导入」把各账号分别导入,切换时当前登录会自动保存、目标账号写入客户端,桌面版切完自动重启 mirasim。'
1941
+ n.innerHTML = T('mirasim(原生 AI 编程开发环境)额度按「平台额度」(5h 滚动窗口、7d 全局上限与分模型上限)分配。Token 约 1 小时过期,读取额度时会自动用 refreshToken 静默续期并同步回客户端,无需手动处理。')
1942
+ + T('切换账号需要至少两个已导入的账号(当前登录 + 目标账号):先在「添加账号 → 本机导入」把各账号分别导入,切换时当前登录会自动保存、目标账号写入客户端,桌面版切完自动重启 mirasim。')
1568
1943
  + mirasimClientChip(s);
1569
1944
  } else if (state.view === 'zcode') {
1570
1945
  n.style.display = '';
1571
1946
  const ac = state.zAutoClaim === true;
1572
1947
  const ziv = state.zClaimIntervalMin || 2;
1573
1948
  const zwin = state.zClaimWindowMin === 0 ? 0 : (state.zClaimWindowMin || 60);
1574
- n.innerHTML = '<div class="z-row"><span class="z-label">活动领取</span><span class="z-text">活动包积分,每日 0 点左右刷新(无每日签到)。自动领取'
1575
- + (ac ? '每 ' + ziv + ' 分钟检查资格' + (zwin > 0 ? ',本次运行 ' + zwin + ' 分钟' : ',持续运行') : '未开启(右上角设置轮询计划)')
1576
- + ';礼物图标 = 手动临时领。</span></div>';
1949
+ n.innerHTML = T('<div class="z-row"><span class="z-label">活动领取</span><span class="z-text">活动包积分,每日 0 点左右刷新(无每日签到)。自动领取')
1950
+ + (ac ? T('每 ') + ziv + T(' 分钟检查资格') + (zwin > 0 ? T(',本次运行 ') + zwin + T(' 分钟') : T(',持续运行')) : T('未开启(右上角设置轮询计划)'))
1951
+ + T(';礼物图标 = 手动临时领。</span></div>');
1577
1952
  } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
1578
1953
  n.style.display = '';
1579
1954
  const busy = s.umid.installing || umidBusy;
1580
- n.innerHTML = 'Qoder 国际版只向带设备风控身份的请求下发「每天领 100 Credits」。这台 Linux / NAS 上没有 Qoder 客户端,'
1581
- + '可以安装「设备身份组件」:从 npm 官方包 @qoder-ai/qodercli 提取其内置的 UMID 程序(约 30MB 下载,校验 npm 完整性),装好后国际版即可领取(本机每天限领一个账号)。'
1582
- + ' <button class="btn primary" id="btn-umid" style="margin-left:6px"' + (busy ? ' disabled' : '') + '>' + (busy ? '安装中…' : '安装设备身份组件') + '</button>';
1955
+ n.innerHTML = T('Qoder 国际版只向带设备风控身份的请求下发「每天领 100 Credits」。这台 Linux / NAS 上没有 Qoder 客户端,')
1956
+ + T('可以安装「设备身份组件」:从 npm 官方包 @qoder-ai/qodercli 提取其内置的 UMID 程序(约 30MB 下载,校验 npm 完整性),装好后国际版即可领取(本机每天限领一个账号)。')
1957
+ + ' <button class="btn primary" id="btn-umid" style="margin-left:6px"' + (busy ? ' disabled' : '') + '>' + (busy ? T('安装中…') : T('安装设备身份组件')) + '</button>';
1583
1958
  } else if (hasIntl && !s.riskIdentity) {
1584
1959
  n.style.display = '';
1585
- n.textContent = 'Qoder 国际版领取需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。';
1960
+ n.textContent = T('Qoder 国际版领取需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。');
1586
1961
  } else if (hasIntl) {
1587
1962
  n.style.display = '';
1588
- n.textContent = '提示:Qoder 国际版每台设备每天只能有一个账号领取每日 100 Credits(服务端按设备限领),其余国际版账号会显示「本机已领」。'
1589
- + (s.riskSource === 'cli' && s.umid && s.umid.installed ? '(风控身份来自设备身份组件 qodercli ' + s.umid.installed.version + ')' : '');
1963
+ n.textContent = T('提示:Qoder 国际版每台设备每天只能有一个账号领取每日 100 Credits(服务端按设备限领),其余国际版账号会显示「本机已领」。')
1964
+ + (s.riskSource === 'cli' && s.umid && s.umid.installed ? T('(风控身份来自设备身份组件 qodercli ') + s.umid.installed.version + T(')') : '');
1590
1965
  } else {
1591
1966
  n.style.display = 'none';
1592
1967
  }
@@ -1595,20 +1970,20 @@ function renderStatus() {
1595
1970
  // mirasim 客户端状态芯片 + 「打开客户端」按钮
1596
1971
  function mirasimClientChip(s) {
1597
1972
  const c = s.mirasimClient || {};
1598
- if (!c.installed) return ' <span class="dim">客户端:未安装</span>';
1973
+ if (!c.installed) return T(' <span class="dim">客户端:未安装</span>');
1599
1974
  const canOpen = window.creditdaddy && typeof window.creditdaddy.openMirasimClient === 'function';
1600
- return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + '</span>'
1601
- + (canOpen ? ' <button class="btn" id="btn-miraopen" style="margin-left:4px" title="打开本机 Mirasim 客户端">打开客户端</button>' : '');
1975
+ return T(' <span class="dim">客户端:') + (c.running ? T('运行中') : T('未运行')) + '</span>'
1976
+ + (canOpen ? T(' <button class="btn" id="btn-miraopen" style="margin-left:4px" title="打开本机 Mirasim 客户端">打开客户端</button>') : '');
1602
1977
  }
1603
1978
 
1604
1979
 
1605
1980
  // 妙手客户端状态芯片 + 「打开客户端」按钮
1606
1981
  function catpawClientChip(s) {
1607
1982
  const c = s.catpawClient || {};
1608
- if (!c.installed) return ' <span class="dim">客户端:未安装(本机导入 / 切换需要 Windows 妙手客户端)</span>';
1983
+ if (!c.installed) return T(' <span class="dim">客户端:未安装(本机导入 / 切换需要 Windows 妙手客户端)</span>');
1609
1984
  const canOpen = window.creditdaddy && typeof window.creditdaddy.openCatpawClient === 'function';
1610
- return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + (c.signedIn ? '' : ' · 未登录') + '</span>'
1611
- + (canOpen ? ' <button class="btn" id="btn-cpopen" style="margin-left:4px" title="打开本机妙手客户端">打开客户端</button>' : '');
1985
+ return T(' <span class="dim">客户端:') + (c.running ? T('运行中') : T('未运行')) + (c.signedIn ? '' : T(' · 未登录')) + '</span>'
1986
+ + (canOpen ? T(' <button class="btn" id="btn-cpopen" style="margin-left:4px" title="打开本机妙手客户端">打开客户端</button>') : '');
1612
1987
  }
1613
1988
 
1614
1989
  // Qoder 设备身份组件(Linux / fnOS)
@@ -1618,7 +1993,7 @@ async function installUmid() {
1618
1993
  umidBusy = true; renderStatus();
1619
1994
  try {
1620
1995
  const r = await api('/api/qoder/umid/install', { method: 'POST' });
1621
- toast('设备身份组件已安装(qodercli ' + r.installed.version + '),Qoder 国际版可以领取了');
1996
+ toast(T('设备身份组件已安装(qodercli ') + r.installed.version + T('),Qoder 国际版可以领取了'));
1622
1997
  } catch (e) { toast(e.message, false); }
1623
1998
  umidBusy = false;
1624
1999
  await loadStatus(); render(); loadLogs();
@@ -1629,40 +2004,32 @@ document.addEventListener('click', async (e) => {
1629
2004
  if (e.target.closest('#zt-open')) {
1630
2005
  try {
1631
2006
  const r = await window.creditdaddy.openZcodeClient();
1632
- if (r && r.ok) toast('已启动 ZCode 客户端');
1633
- else toast((r && r.error) || '无法打开 ZCode 客户端', false);
2007
+ if (r && r.ok) toast(T('已启动 ZCode 客户端'));
2008
+ else toast((r && r.error) || T('无法打开 ZCode 客户端'), false);
1634
2009
  } catch (err) { toast(String(err && err.message || err), false); }
1635
2010
  return;
1636
2011
  }
1637
2012
  if (e.target.closest('#btn-miraopen')) {
1638
2013
  try {
1639
2014
  const r = await window.creditdaddy.openMirasimClient();
1640
- if (r && r.ok) toast('已启动 Mirasim 客户端');
1641
- else toast((r && r.error) || '无法打开 Mirasim 客户端', false);
2015
+ if (r && r.ok) toast(T('已启动 Mirasim 客户端'));
2016
+ else toast((r && r.error) || T('无法打开 Mirasim 客户端'), false);
1642
2017
  } catch (err) { toast(String(err && err.message || err), false); }
1643
2018
  return;
1644
2019
  }
1645
2020
  if (e.target.closest('#btn-cpopen')) {
1646
2021
  try {
1647
2022
  const r = await window.creditdaddy.openCatpawClient();
1648
- if (r && r.ok) toast('已启动妙手客户端');
1649
- else toast((r && r.error) || '无法打开妙手客户端', false);
2023
+ if (r && r.ok) toast(T('已启动妙手客户端'));
2024
+ else toast((r && r.error) || T('无法打开妙手客户端'), false);
1650
2025
  } catch (err) { toast(String(err && err.message || err), false); }
1651
2026
  return;
1652
2027
  }
1653
2028
  if (e.target.closest('#tt-open')) {
1654
2029
  try {
1655
2030
  const r = await window.creditdaddy.openTraeClient();
1656
- if (r && r.ok) toast('已启动 Trae 客户端');
1657
- else toast((r && r.error) || '无法打开 Trae 客户端', false);
1658
- } catch (err) { toast(String(err && err.message || err), false); }
1659
- return;
1660
- }
1661
- if (e.target.closest('#mt-open')) {
1662
- try {
1663
- const r = await window.creditdaddy.openMiniMaxClient();
1664
- if (r && r.ok) toast('已启动 MiniMax Code 客户端');
1665
- else toast((r && r.error) || '无法打开 MiniMax Code 客户端', false);
2031
+ if (r && r.ok) toast(T('已启动 Trae 客户端'));
2032
+ else toast((r && r.error) || T('无法打开 Trae 客户端'), false);
1666
2033
  } catch (err) { toast(String(err && err.message || err), false); }
1667
2034
  return;
1668
2035
  }
@@ -1673,14 +2040,22 @@ document.addEventListener('click', async (e) => {
1673
2040
  const allow = Array.isArray(state.tGatewayAllow) ? state.tGatewayAllow : [];
1674
2041
  $('tgw-allow').value = (allow.length ? allow : (state.tGatewaySuggest ? [state.tGatewaySuggest] : [])).join('\n');
1675
2042
  const st = state.tGatewayStats;
1676
- $('tgw-url').innerHTML = '接口地址 <code>POST http://127.0.0.1:' + location.port + '/gateway/trae/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)'
2043
+ $('tgw-url').innerHTML = T('接口地址 <code>POST http://127.0.0.1:') + location.port + T('/gateway/trae/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)')
1677
2044
  + (st && st.lastCallAt
1678
- ? '<br>最近调用 ' + esc(String(st.lastCallAt).slice(11, 19)) + ' · 共 ' + st.calls + ' 次' + (st.lastAccount ? ' · 最后用号 ' + esc(st.lastAccount) : '')
1679
- : '<br>尚未有调用');
2045
+ ? T('<br>最近调用 ') + esc(String(st.lastCallAt).slice(11, 19)) + T(' · 共 ') + st.calls + T(' 次') + (st.lastAccount ? T(' · 最后用号 ') + esc(st.lastAccount) : '')
2046
+ : T('<br>尚未有调用'));
1680
2047
  syncTgwLanFields();
1681
2048
  openModal('m-tgw');
1682
2049
  return;
1683
2050
  }
2051
+ if (e.target.closest('#mt-open')) {
2052
+ try {
2053
+ const r = await window.creditdaddy.openMiniMaxClient();
2054
+ if (r && r.ok) toast(T('已启动 MiniMax Code 客户端'));
2055
+ else toast((r && r.error) || T('无法打开 MiniMax Code 客户端'), false);
2056
+ } catch (err) { toast(String(err && err.message || err), false); }
2057
+ return;
2058
+ }
1684
2059
  if (e.target.closest('#mt-gw')) {
1685
2060
  // MiniMax 本地接口弹窗:开关 + 局域网 + 白名单设置
1686
2061
  $('mgw-on').checked = state.mGateway === true;
@@ -1688,10 +2063,10 @@ document.addEventListener('click', async (e) => {
1688
2063
  const allow = Array.isArray(state.mGatewayAllow) ? state.mGatewayAllow : [];
1689
2064
  $('mgw-allow').value = (allow.length ? allow : (state.mGatewaySuggest ? [state.mGatewaySuggest] : [])).join('\n');
1690
2065
  const st = state.mGatewayStats;
1691
- $('mgw-url').innerHTML = '接口地址 <code>POST http://127.0.0.1:' + location.port + '/gateway/minimax/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)'
2066
+ $('mgw-url').innerHTML = T('接口地址 <code>POST http://127.0.0.1:') + location.port + T('/gateway/minimax/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)')
1692
2067
  + (st && st.lastCallAt
1693
- ? '<br>最近调用 ' + esc(String(st.lastCallAt).slice(11, 19)) + ' · 共 ' + st.calls + ' 次' + (st.lastAccount ? ' · 最后用号 ' + esc(st.lastAccount) : '')
1694
- : '<br>尚未有调用');
2068
+ ? T('<br>最近调用 ') + esc(String(st.lastCallAt).slice(11, 19)) + T(' · 共 ') + st.calls + T(' 次') + (st.lastAccount ? T(' · 最后用号 ') + esc(st.lastAccount) : '')
2069
+ : T('<br>尚未有调用'));
1695
2070
  syncMgwLanFields();
1696
2071
  openModal('m-mgw');
1697
2072
  return;
@@ -1701,7 +2076,7 @@ document.addEventListener('click', async (e) => {
1701
2076
  $('zac-interval').value = state.zClaimIntervalMin || 2;
1702
2077
  $('zac-window').value = state.zClaimWindowMin === 0 ? 0 : (state.zClaimWindowMin || 60);
1703
2078
  $('zac-off').style.display = state.zAutoClaim === true ? '' : 'none';
1704
- $('zac-ok').textContent = state.zAutoClaim === true ? '保存' : '保存并启用';
2079
+ $('zac-ok').textContent = state.zAutoClaim === true ? T('保存') : T('保存并启用');
1705
2080
  openModal('m-zac');
1706
2081
  return;
1707
2082
  }
@@ -1712,22 +2087,14 @@ document.addEventListener('click', async (e) => {
1712
2087
  const allow = Array.isArray(state.zGatewayAllow) ? state.zGatewayAllow : [];
1713
2088
  $('zgw-allow').value = (allow.length ? allow : (state.zGatewaySuggest ? [state.zGatewaySuggest] : [])).join('\n');
1714
2089
  const st = state.zGatewayStats;
1715
- $('zgw-url').innerHTML = '接口地址 <code>POST http://127.0.0.1:' + location.port + '/gateway/zcode/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)'
2090
+ $('zgw-url').innerHTML = T('接口地址 <code>POST http://127.0.0.1:') + location.port + T('/gateway/zcode/v1/messages</code>(局域网开放时把 127.0.0.1 换成本机 IP)')
1716
2091
  + (st && st.lastCallAt
1717
- ? '<br>最近调用 ' + esc(String(st.lastCallAt).slice(11, 19)) + ' · 共 ' + st.calls + ' 次' + (st.lastAccount ? ' · 最后用号 ' + esc(st.lastAccount) : '')
1718
- : '<br>尚未有调用');
2092
+ ? T('<br>最近调用 ') + esc(String(st.lastCallAt).slice(11, 19)) + T(' · 共 ') + st.calls + T(' 次') + (st.lastAccount ? T(' · 最后用号 ') + esc(st.lastAccount) : '')
2093
+ : T('<br>尚未有调用'));
1719
2094
  syncZgwLanFields();
1720
2095
  openModal('m-zgw');
1721
2096
  return;
1722
2097
  }
1723
- if (e.target.closest('#zt-net')) {
1724
- // 出口弹窗:顺序 + 代理地址一起保存;代理只回填到 placeholder(脱敏),留空即清除
1725
- (state.zProxyFirst === true ? $('znet-proxy') : $('znet-direct')).checked = true;
1726
- $('znet-url').value = '';
1727
- $('znet-url').placeholder = state.zProxyUrl || (state.zHasEnvProxy ? '跟随环境变量 HTTPS_PROXY' : 'http://192.168.31.101:7890');
1728
- openModal('m-znet');
1729
- return;
1730
- }
1731
2098
  });
1732
2099
  async function loadZNet() {
1733
2100
  try {
@@ -1809,11 +2176,11 @@ function quotaLow(c) {
1809
2176
 
1810
2177
  function quotaValue(q) {
1811
2178
  const pct = quotaPct(q);
1812
- if (q.percentScale || (!(q.total > 0) && pct !== null)) return { big: fmtNum(pct) + '%', small: '剩余', pct };
1813
- if (q.total > 0) return { big: fmtNum(quotaRemaining(q)), small: '/ ' + fmtNum(q.total) + (q.unit ? ' ' + q.unit : ''), pct };
1814
- return { big: fmtNum(q.used || 0), small: '已用', pct };
2179
+ if (q.percentScale || (!(q.total > 0) && pct !== null)) return { big: fmtNum(pct) + '%', small: T('剩余'), pct };
2180
+ if (q.total > 0) return { big: fmtNum(quotaRemaining(q)), small: '/ ' + fmtNum(q.total) + (q.unit ? ' ' + T(q.unit) : ''), pct };
2181
+ return { big: fmtNum(q.used || 0), small: T('已用'), pct };
1815
2182
  }
1816
- const resetLabel = (q) => (q.resetAt ? fmtTime(q.resetAt).slice(5) + (q.recurring === false ? ' 到期' : ' 重置') : '');
2183
+ const resetLabel = (q) => (q.resetAt ? fmtTime(q.resetAt).slice(5) + (q.recurring === false ? T(' 到期') : T(' 重置')) : '');
1817
2184
 
1818
2185
  // 明细整行:名称 + 数值/相对重置 + 水位条(CLIProxyAPI quotaRow 版式)
1819
2186
  function trRow(q) {
@@ -1822,11 +2189,12 @@ function trRow(q) {
1822
2189
 
1823
2190
  function lineVal(q) {
1824
2191
  const pct = quotaPct(q);
1825
- // 百分比口径(限时限额额度):一律显示 %,节约空间;积分类保持实际数字
1826
- const pctScale = q.percentScale || Number.isFinite(q.remainingPercentage);
1827
- const txt = (pctScale || !(q.total > 0))
1828
- ? '剩 ' + (pct !== null ? fmtNum(pct) + '%' : fmtNum(quotaRemaining(q)))
1829
- : '剩 ' + fmtNum(quotaRemaining(q)) + ' / ' + fmtNum(q.total || 0) + (q.unit ? ' ' + q.unit : '');
2192
+ // 数值优先:有 total 就显示「剩 x / y」——remainingPercentage 是上游四舍五入的
2193
+ // 粗略值(3.99/4 → 100%),只在没有绝对量时才退回百分比口径;
2194
+ // percentScale(Antigravity 这类限时窗口平等关系)保持百分比。
2195
+ const txt = (q.percentScale || !(q.total > 0))
2196
+ ? T('剩 ') + (pct !== null ? fmtNum(pct) + '%' : fmtNum(quotaRemaining(q)))
2197
+ : T('剩 ') + fmtNum(quotaRemaining(q)) + ' / ' + fmtNum(q.total || 0) + (q.unit ? ' ' + T(q.unit) : '');
1830
2198
  return '<span>' + esc(txt) + '</span> ' + relResetHtml(q);
1831
2199
  }
1832
2200
 
@@ -1835,7 +2203,7 @@ function lineVal(q) {
1835
2203
  function trPrimaryHtml(q) {
1836
2204
  if (q.percentScale) return trRow(q);
1837
2205
  const v = quotaValue(q);
1838
- return '<div class="tr-primary"><div class="tr-line tr-primary-line"><span class="tr-q-name" title="' + esc(q.name) + '">' + esc(q.name) + '</span>'
2206
+ return '<div class="tr-primary"><div class="tr-line tr-primary-line"><span class="tr-q-name" title="' + esc(T(q.name)) + '">' + esc(T(q.name)) + '</span>'
1839
2207
  + '<span class="tr-q-val"><b>' + esc(v.big) + '</b> <small class="dim">' + esc(v.small) + '</small> ' + relResetHtml(q) + '</span></div>'
1840
2208
  + (v.pct !== null ? trMeter(v.pct) : '')
1841
2209
  + '</div>';
@@ -1844,15 +2212,15 @@ function trPrimaryHtml(q) {
1844
2212
  function trQuotaHtml(q, { dimWhenEmpty = false } = {}) {
1845
2213
  const v = quotaValue(q);
1846
2214
  const used = v.pct !== null && v.pct <= 0;
1847
- const val = used && dimWhenEmpty ? '已用完' : (q.percentScale || !(q.total > 0) ? '剩余 ' + v.big : '剩余 ' + v.big + ' ' + v.small);
1848
- return '<div class="tr-q' + (used && dimWhenEmpty ? ' spent' : '') + '"><div class="tr-q-line"><span class="tr-q-name" title="' + esc(q.name) + '">' + esc(q.name) + '</span>'
2215
+ const val = used && dimWhenEmpty ? T('已用完') : (q.percentScale || !(q.total > 0) ? T('剩余 ') + v.big : T('剩余 ') + v.big + ' ' + v.small);
2216
+ return '<div class="tr-q' + (used && dimWhenEmpty ? ' spent' : '') + '"><div class="tr-q-line"><span class="tr-q-name" title="' + esc(T(q.name)) + '">' + esc(T(q.name)) + '</span>'
1849
2217
  + '<span class="tr-q-val">' + esc(val) + relResetHtml(q) + '</span></div>'
1850
2218
  + (!(used && dimWhenEmpty) ? trMeter(v.pct) : '')
1851
2219
  + '</div>';
1852
2220
  }
1853
2221
 
1854
2222
  // 积分包按名称系列聚合("Bonus Pack 18/19/20" → "Bonus Pack ×3"),去掉尾部序号作键
1855
- const packKey = (name) => String(name || '').replace(/[\s#(]*\d+\s*[)]?\s*$/, '').trim() || '积分包';
2223
+ const packKey = (name) => String(name || '').replace(/[\s#(]*\d+\s*[)]?\s*$/, '').trim() || T('积分包');
1856
2224
  function trPacksHtml(c, packs) {
1857
2225
  if (!packs.length) return '';
1858
2226
  const alive = packs.filter((q) => quotaRemaining(q) > 0);
@@ -1871,13 +2239,13 @@ function trPacksHtml(c, packs) {
1871
2239
  const gs = [...groups.values()].sort((a, b) => String(a.resetAt || '9999').localeCompare(String(b.resetAt || '9999')));
1872
2240
  const aliveSum = alive.reduce((n, q) => n + quotaRemaining(q), 0);
1873
2241
  const open = Boolean(state.trOpen && state.trOpen[c.id]);
1874
- const head = '<div class="tr-packs-head">积分包 <b>' + alive.length + '</b> 个可用'
1875
- + (alive.length ? ',共剩 ' + fmtNum(aliveSum) : '')
1876
- + (spent.length ? ' · ' + spent.length + ' 个已用完' : '') + '</div>';
2242
+ const head = T('<div class="tr-packs-head">积分包 <b>') + alive.length + T('</b> 个可用')
2243
+ + (alive.length ? T(',共剩 ') + fmtNum(aliveSum) : '')
2244
+ + (spent.length ? ' · ' + spent.length + T(' 个已用完') : '') + '</div>';
1877
2245
  const lines = gs.map((g) => trLine(
1878
2246
  g.name + (g.count > 1 ? ' ×' + g.count : ''),
1879
- esc('剩 ' + fmtNum(g.rem) + (g.tot > 0 ? ' / ' + fmtNum(g.tot) : ''))
1880
- + (g.resetAt ? '<span class="tr-q-when"> · 最早 ' + esc(fmtTime(g.resetAt).slice(5, 10)) + ' 到期</span>' : ''),
2247
+ esc(T('剩 ') + fmtNum(g.rem) + (g.tot > 0 ? ' / ' + fmtNum(g.tot) : ''))
2248
+ + (g.resetAt ? T('<span class="tr-q-when"> · 最早 ') + esc(fmtTime(g.resetAt).slice(5, 10)) + T(' 到期</span>') : ''),
1881
2249
  )).join('');
1882
2250
  const detail = open
1883
2251
  ? [...alive.sort((a, b) => String(a.resetAt || '9999').localeCompare(String(b.resetAt || '9999'))), ...spent]
@@ -1885,7 +2253,7 @@ function trPacksHtml(c, packs) {
1885
2253
  : '';
1886
2254
  const collapsible = alive.length > gs.length || spent.length > 0;
1887
2255
  return '<div class="tr-packs">' + head + (open ? detail : lines)
1888
- + (collapsible ? '<button class="tr-more" data-tr="toggle" data-id="' + esc(c.id) + '">' + (open ? '收起' : '逐包明细') + '</button>' : '')
2256
+ + (collapsible ? '<button class="tr-more" data-tr="toggle" data-id="' + esc(c.id) + '">' + (open ? T('收起') : T('逐包明细')) + '</button>' : '')
1889
2257
  + '</div>';
1890
2258
  }
1891
2259
 
@@ -1896,11 +2264,11 @@ function trCardHtml(c) {
1896
2264
  const showPlan = c.plan && !norm(c.plan).includes(norm(c.providerName)) && !norm(c.providerName).includes(norm(c.plan));
1897
2265
  let body;
1898
2266
  if (c.error) {
1899
- body = '<div class="msg err" title="' + esc(c.error) + '">查询失败:' + esc(c.error.slice(0, 80)) + '</div>';
2267
+ body = '<div class="msg err" title="' + esc(T(c.error)) + '">' + T('查询失败:') + esc(c.error.slice(0, 80)) + '</div>';
1900
2268
  } else {
1901
2269
  const { primary, windows, packs } = splitQuotas(c);
1902
2270
  if (!(primary || windows.length || packs.length)) {
1903
- body = '<div class="msg">' + esc(/no separate quota|billed at standard/i.test(c.message || '') ? '按量计费,无额度可显示' : (c.message || '该供应商未返回额度数据')) + '</div>';
2271
+ body = '<div class="msg">' + esc(/no separate quota|billed at standard/i.test(c.message || '') ? T('按量计费,无额度可显示') : (c.message || T('该供应商未返回额度数据'))) + '</div>';
1904
2272
  } else {
1905
2273
  // 与汇总卡同款默认视图:主额度 + 每个周 / 月窗口额度一条(带进度条);积分包走聚合行,「逐包明细」展开单包
1906
2274
  body = (primary ? trPrimaryHtml(primary) : '')
@@ -1910,12 +2278,12 @@ function trCardHtml(c) {
1910
2278
  }
1911
2279
  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">'
1912
2280
  + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1913
- + (TR_REGION[c.provider] ? '<span class="badge ' + TR_REGION[c.provider][1] + '">' + TR_REGION[c.provider][0] + '</span>' : '')
2281
+ + (TR_REGION[c.provider] ? '<span class="badge ' + TR_REGION[c.provider][1] + '">' + T(TR_REGION[c.provider][0]) + '</span>' : '')
1914
2282
  + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
1915
2283
  + (showPlan ? '<span class="badge">' + esc(c.plan) + '</span>' : '')
1916
- + (c.isActive ? '' : '<span class="pill pending">已停用</span>')
1917
- + (c.limitReached ? '<span class="pill fail">已达上限</span>' : '')
1918
- + (c.isActive && !c.error && quotaLow(c) ? '<span class="pill limited">告急</span>' : '')
2284
+ + (c.isActive ? '' : T('<span class="pill pending">已停用</span>'))
2285
+ + (c.limitReached ? T('<span class="pill fail">已达上限</span>') : '')
2286
+ + (c.isActive && !c.error && quotaLow(c) ? T('<span class="pill limited">告急</span>') : '')
1919
2287
  + '</div></div>' + body + '</div>';
1920
2288
  }
1921
2289
 
@@ -1927,7 +2295,7 @@ const trSorted = (list) => list.slice().sort((a, b) => (b.isActive - a.isActive)
1927
2295
  function trGroups(list) {
1928
2296
  const map = new Map();
1929
2297
  for (const c of trSorted(list)) {
1930
- const k = c.providerName || c.provider || '其他';
2298
+ const k = c.providerName || c.provider || T('其他');
1931
2299
  if (!map.has(k)) map.set(k, []);
1932
2300
  map.get(k).push(c);
1933
2301
  }
@@ -1970,7 +2338,9 @@ function trVendorCardHtml(g) {
1970
2338
  if (!q || isAgg(q) || isDetail(q)) continue;
1971
2339
  if (!rowMap.has(q.name)) rowMap.set(q.name, { name: q.name, total: 0, rem: 0, anyTotal: false, pctScale: false, resetAt: null, pcts: [], unit: '' });
1972
2340
  const w = rowMap.get(q.name);
1973
- const isPctRow = q.percentScale || Number.isFinite(q.remainingPercentage);
2341
+ // 有绝对量(total>0)就不算百分比行:remainingPercentage 是上游四舍五入值,
2342
+ // 拿它当口径会把 3.99/4 显示成「剩 100%」,与 10Router 自己的 3.99/4 对不齐
2343
+ const isPctRow = q.percentScale || !(q.total > 0);
1974
2344
  if (isPctRow) w.pctScale = true;
1975
2345
  const pct = quotaPct(q);
1976
2346
  if (pct !== null) w.pcts.push(pct);
@@ -1995,14 +2365,14 @@ function trVendorCardHtml(g) {
1995
2365
  const alivePacks = packs.filter((q) => quotaRemaining(q) > 0);
1996
2366
  const packRem = alivePacks.reduce((n, q) => n + quotaRemaining(q), 0);
1997
2367
  const briefs = [];
1998
- if (alivePacks.length) briefs.push('积分包×' + alivePacks.length + ' 剩 ' + fmtNum(packRem));
1999
- else if (packs.length) briefs.push('积分包已用完');
2368
+ if (alivePacks.length) briefs.push(T('积分包×') + alivePacks.length + T(' 剩 ') + fmtNum(packRem));
2369
+ else if (packs.length) briefs.push(T('积分包已用完'));
2000
2370
  const hasQuotas = Boolean(primary || others.length || packs.length);
2001
2371
  let quotaHtml;
2002
2372
  if (errs === items.length) {
2003
- quotaHtml = '<div class="msg err">全部连接查询失败</div>';
2373
+ quotaHtml = T('<div class="msg err">全部连接查询失败</div>');
2004
2374
  } else if (!hasQuotas) {
2005
- quotaHtml = '<div class="msg">该供应商没有可汇总的额度</div>';
2375
+ quotaHtml = T('<div class="msg">该供应商没有可汇总的额度</div>');
2006
2376
  } else {
2007
2377
  // 「全部」视角不显示「最早到期」:每行自带相对重置时间,足够判断
2008
2378
  quotaHtml = (primary ? trPrimaryHtml(primary) : '')
@@ -2013,30 +2383,30 @@ function trVendorCardHtml(g) {
2013
2383
  }
2014
2384
  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">'
2015
2385
  + '<span class="badge prod tenrouter">' + esc(g.name) + '</span>'
2016
- + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + TR_REGION[prov][0] + '</span>' : '')
2017
- + '<span class="badge">' + items.length + ' 个账号</span>'
2018
- + (lows ? '<span class="pill limited">告急 ×' + lows + '</span>' : '')
2019
- + (errs ? '<span class="pill fail" title="部分连接查询失败">失败 ×' + errs + '</span>' : '')
2386
+ + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + T(TR_REGION[prov][0]) + '</span>' : '')
2387
+ + '<span class="badge">' + items.length + T(' 个账号</span>')
2388
+ + (lows ? T('<span class="pill limited">告急 ×') + lows + '</span>' : '')
2389
+ + (errs ? T('<span class="pill fail" title="部分连接查询失败">失败 ×') + errs + '</span>' : '')
2020
2390
  + '</div></div>' + quotaHtml + '</div>';
2021
2391
  }
2022
2392
 
2023
2393
  function trSetupHtml() {
2024
- return '<div class="setup"><h3>接入 10Router</h3>'
2025
- + '<div class="hint">填 10Router 的地址和一个<b>虚拟 key</b>(10Router 仪表盘 → API Keys 创建的 sk-…),就能在这里:</div>'
2026
- + '<div class="hint" style="margin:6px 0 12px">· 查看 10Router 里其他供应商(Claude / Codex / Kiro / GLM …)的额度卡片(需要 10Router 1.2.1+)<br>'
2027
- + '· 把本机 ZCode / OpenCode / mirasim / 小米 MiMo / 10Router 的用量同步进 10Router 的用量统计(同 10router-sync 插件,自动每小时一次)<br>'
2028
- + '· 「10Router 本机」= 本机 10Router 实例自己记的账(如 zcode-free 经它中转的行)同步到聚合端;地址指向本机时自动跳过,避免自己导自己</div>'
2029
- + '<button class="btn primary" data-tr="settings">' + I.plus + '配置 10Router</button></div>';
2394
+ return T('<div class="setup"><h3>接入 10Router</h3>')
2395
+ + T('<div class="hint">填 10Router 的地址和一个<b>虚拟 key</b>(10Router 仪表盘 → API Keys 创建的 sk-…),就能在这里:</div>')
2396
+ + T('<div class="hint" style="margin:6px 0 12px">· 查看 10Router 里其他供应商(Claude / Codex / Kiro / GLM …)的额度卡片(需要 10Router 1.2.1+)<br>')
2397
+ + T('· 把本机 ZCode / OpenCode / mirasim / 小米 MiMo / 10Router 的用量同步进 10Router 的用量统计(同 10router-sync 插件,自动每小时一次)<br>')
2398
+ + T('· 「10Router 本机」= 本机 10Router 实例自己记的账(如 zcode-free 经它中转的行)同步到聚合端;地址指向本机时自动跳过,避免自己导自己</div>')
2399
+ + '<button class="btn primary" data-tr="settings">' + I.plus + T('配置 10Router</button></div>');
2030
2400
  }
2031
2401
 
2032
2402
  function renderTenrouter() {
2033
2403
  const box = $('tr-panel');
2034
2404
  const c = tr.config;
2035
- if (!c) { box.innerHTML = '<div class="empty">加载中…</div>'; return; }
2405
+ if (!c) { box.innerHTML = '<div class="empty">' + T('加载中…') + '</div>'; return; }
2036
2406
  const gwLine = state.zGateway === true
2037
- ? '<div class="gw-line"><span class="dot on pulse"></span> ZCode 体验包接口运行中'
2038
- + (state.zGatewayStats && state.zGatewayStats.lastCallAt ? ' · 最近调用 ' + esc(String(state.zGatewayStats.lastCallAt).slice(11, 19)) + ' · ' + state.zGatewayStats.calls + ' 次 · 最后用号 ' + esc(state.zGatewayStats.lastAccount || '-') : ' · 尚未有调用')
2039
- + ' · 模型 <code>zcode-free/glm-5.3-flash</code></div>'
2407
+ ? T('<div class="gw-line"><span class="dot on pulse"></span> ZCode 体验包接口运行中')
2408
+ + (state.zGatewayStats && state.zGatewayStats.lastCallAt ? T(' · 最近调用 ') + esc(String(state.zGatewayStats.lastCallAt).slice(11, 19)) + ' · ' + state.zGatewayStats.calls + T(' 次 · 最后用号 ') + esc(state.zGatewayStats.lastAccount || '-') : T(' · 尚未有调用'))
2409
+ + T(' · 模型 <code>zcode-free/glm-5.3-flash</code></div>')
2040
2410
  : '';
2041
2411
  if (!c.configured) { box.innerHTML = gwLine + trSetupHtml(); return; }
2042
2412
  const list = (tr.quotas && tr.quotas.connections) || [];
@@ -2063,36 +2433,36 @@ function renderTenrouter() {
2063
2433
  const activeVendor = vendor && shown.some((g) => g.name === vendor) ? vendor : '';
2064
2434
  if (groups.length) {
2065
2435
  tabStrip = '<div class="tr-tabs">'
2066
- + '<button class="btn' + (!activeVendor ? ' primary' : '') + '" data-tr="vendor" data-v="">全部</button>'
2436
+ + '<button class="btn' + (!activeVendor ? ' primary' : '') + T('" data-tr="vendor" data-v="">全部</button>')
2067
2437
  + shown.map((g) => '<button class="btn' + (activeVendor === g.name ? ' primary' : '') + '" data-tr="vendor" data-v="' + esc(g.name) + '">' + esc(g.name) + ' ' + g.items.length + '</button>').join('')
2068
2438
  + '</div>';
2069
2439
  }
2070
- if (tr.error) grid = '<div class="empty">' + esc(tr.error) + '</div>';
2071
- else if (!tr.quotas) grid = '<div class="empty">正在读取 10Router 供应商额度…</div>';
2072
- else if (!list.length) grid = '<div class="empty">10Router 里还没有可查额度的供应商连接(OAuth 或支持额度查询的 API Key)。</div>';
2440
+ if (tr.error) grid = '<div class="empty">' + esc(T(tr.error)) + '</div>';
2441
+ else if (!tr.quotas) grid = T('<div class="empty">正在读取 10Router 供应商额度…</div>');
2442
+ else if (!list.length) grid = T('<div class="empty">10Router 里还没有可查额度的供应商连接(OAuth 或支持额度查询的 API Key)。</div>');
2073
2443
  else if (activeVendor) {
2074
2444
  const g = groups.find((x) => x.name === activeVendor) || { items: [] };
2075
2445
  grid = '<div class="tr-vcards">' + sortedConnections(activeVendor, g.items).map(trCardHtml).join('') + '</div>';
2076
2446
  } else {
2077
2447
  grid = shown.length
2078
2448
  ? '<div class="tr-vcards">' + sortedVendorGroups(shown).map(trVendorCardHtml).join('') + '</div>'
2079
- : '<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>';
2449
+ : T('<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>');
2080
2450
  }
2081
2451
  const h = tr.health;
2082
- const healthStrong = !h ? '…' : h.ok ? '正常' : '异常';
2452
+ const healthStrong = !h ? '…' : h.ok ? T('正常') : T('异常');
2083
2453
  const healthDetail = !h ? '' : h.ok
2084
- ? (h.driver ? '存储驱动 ' + h.driver : '') + (h.lastDriverError ? '(驱动降级:' + h.lastDriverError + ')' : '')
2454
+ ? (h.driver ? T('存储驱动 ') + h.driver : '') + (h.lastDriverError ? T('(驱动降级:') + h.lastDriverError + T(')') : '')
2085
2455
  : (h.error || '');
2086
2456
  box.innerHTML = '<div class="toolbar"><div class="summary">'
2087
- + '<div class="stat" title="' + esc(healthDetail) + '"><span>10Router 健康</span><strong' + (h && !h.ok ? ' style="color:var(--err)"' : (h && h.lastDriverError ? ' style="color:var(--warn,#b45309)"' : '')) + '>' + healthStrong + '</strong></div><div class="divider"></div>'
2088
- + '<div class="stat"><span>供应商连接</span><strong>' + (tr.quotas ? list.length : '-') + '</strong></div><div class="divider"></div>'
2089
- + '<div class="stat"><span>额度告急</span><strong>' + (tr.quotas ? low : '-') + '</strong></div><div class="divider"></div>'
2090
- + '<div class="stat"><span>查询失败</span><strong>' + (tr.quotas ? failed : '-') + '</strong></div>'
2457
+ + '<div class="stat" title="' + esc(healthDetail) + T('"><span>10Router 健康</span><strong') + (h && !h.ok ? ' style="color:var(--err)"' : (h && h.lastDriverError ? ' style="color:var(--warn,#b45309)"' : '')) + '>' + healthStrong + '</strong></div><div class="divider"></div>'
2458
+ + T('<div class="stat"><span>供应商连接</span><strong>') + (tr.quotas ? list.length : '-') + '</strong></div><div class="divider"></div>'
2459
+ + T('<div class="stat"><span>额度告急</span><strong>') + (tr.quotas ? low : '-') + '</strong></div><div class="divider"></div>'
2460
+ + T('<div class="stat"><span>查询失败</span><strong>') + (tr.quotas ? failed : '-') + '</strong></div>'
2091
2461
  + '</div><div class="actions">'
2092
- + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + (tr.loading ? '刷新中…' : '刷新额度') + '</button>'
2093
- + '<button class="btn" data-tr="sync" title="用量同步:自动开关、同步来源与手动同步(点我打开)"' + (tr.syncing || c.syncing ? ' disabled' : '') + '>'
2094
- + '<span class="dot' + (c.sync.enabled ? ' on' : '') + '"></span>' + (tr.syncing || c.syncing ? '同步中…' : '用量同步') + '</button>'
2095
- + '<button class="btn" data-tr="settings">设置</button>'
2462
+ + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + (tr.loading ? T('刷新中…') : T('刷新额度')) + '</button>'
2463
+ + T('<button class="btn" data-tr="sync" title="用量同步:自动开关、同步来源与手动同步(点我打开)"') + (tr.syncing || c.syncing ? ' disabled' : '') + '>'
2464
+ + '<span class="dot' + (c.sync.enabled ? ' on' : '') + '"></span>' + (tr.syncing || c.syncing ? T('同步中…') : T('用量同步')) + '</button>'
2465
+ + T('<button class="btn" data-tr="settings">设置</button>')
2096
2466
  + '</div></div>'
2097
2467
  + tabStrip
2098
2468
  + '<div class="tr-grid">' + grid + '</div>';
@@ -2104,11 +2474,11 @@ function trDashCard() {
2104
2474
  const list = (tr.quotas && tr.quotas.connections) || [];
2105
2475
  const low = list.filter((x) => x.isActive && !x.error && quotaLow(x)).length;
2106
2476
  const ls = c.lastSync;
2107
- const rows = '<span class="lbl">供应商连接</span><span class="val">' + (tr.quotas ? list.length : tr.error ? '读取失败' : '…') + '</span>'
2108
- + '<span class="lbl">额度告急</span><span class="val' + (low ? ' warn' : '') + '">' + (tr.quotas ? low : '-') + '</span>'
2109
- + '<span class="lbl">用量同步</span><span class="val">' + (ls ? esc(fmtTime(ls.at).slice(5)) : c.sync.enabled ? '等待首次同步' : '手动') + '</span>';
2477
+ const rows = T('<span class="lbl">供应商连接</span><span class="val">') + (tr.quotas ? list.length : tr.error ? T('读取失败') : '…') + '</span>'
2478
+ + T('<span class="lbl">额度告急</span><span class="val') + (low ? ' warn' : '') + '">' + (tr.quotas ? low : '-') + '</span>'
2479
+ + T('<span class="lbl">用量同步</span><span class="val">') + (ls ? esc(fmtTime(ls.at).slice(5)) : c.sync.enabled ? T('等待首次同步') : T('手动')) + '</span>';
2110
2480
  return '<div class="prodcard" data-view="tenrouter"><div class="prod-head"><span class="prod-name"><span class="dot tenrouter"></span>10Router</span>'
2111
- + '<span class="prod-go">查看' + I.chevRight + '</span></div><div class="prod-rows">' + rows + '</div></div>';
2481
+ + T('<span class="prod-go">查看') + I.chevRight + '</span></div><div class="prod-rows">' + rows + '</div></div>';
2112
2482
  }
2113
2483
 
2114
2484
  async function loadTrConfig() {
@@ -2140,9 +2510,9 @@ function openTrSettings() {
2140
2510
  const c = tr.config || { sync: { enabled: true, sources: [] }, sources: [] };
2141
2511
  $('tr-endpoint').value = c.endpoint || '';
2142
2512
  $('tr-key').value = '';
2143
- $('tr-key').placeholder = c.keyMasked ? '已保存 ' + c.keyMasked + '(留空保持不变)' : 'sk-…';
2513
+ $('tr-key').placeholder = c.keyMasked ? T('已保存 ') + c.keyMasked + T('(留空保持不变)') : 'sk-…';
2144
2514
  $('tr-admin-pw').value = '';
2145
- $('tr-admin-pw').placeholder = c.hasAdminPassword ? '已保存(留空保持不变)' : '可选:10Router 面板密码';
2515
+ $('tr-admin-pw').placeholder = c.hasAdminPassword ? T('已保存(留空保持不变)') : T('可选:10Router 面板密码');
2146
2516
  $('tr-hide-empty').checked = state.trHideEmpty === true;
2147
2517
  $('tr-disconnect').style.display = c.configured ? '' : 'none';
2148
2518
  $('tr-test-msg').textContent = '';
@@ -2155,16 +2525,16 @@ function openTrSync() {
2155
2525
  $('tr-auto').checked = c.configured ? c.sync.enabled : true;
2156
2526
  $('tr-sources').innerHTML = (c.sources || []).map((x) => '<label class="check"><input type="checkbox" data-src="' + x.id + '"'
2157
2527
  + ((c.configured ? c.sync.sources.includes(x.id) : true) ? ' checked' : '') + (x.supported ? '' : ' disabled') + '> ' + esc(x.label)
2158
- + '<span class="dim">' + (x.found ? '' : '(本机未检测到)') + (x.supported ? '' : '(需要 Node 22.5+)') + '</span></label>').join('');
2528
+ + '<span class="dim">' + (x.found ? '' : T('(本机未检测到)')) + (x.supported ? '' : T('(需要 Node 22.5+)')) + '</span></label>').join('');
2159
2529
  const ls = c.lastSync;
2160
- $('tr-sync-status').textContent = ls ? '上次同步 ' + fmtTime(ls.at).slice(5) + ':' + ls.summary : '还没有同步过';
2530
+ $('tr-sync-status').textContent = ls ? T('上次同步 ') + fmtTime(ls.at).slice(5) + T(':') + ls.summary : T('还没有同步过');
2161
2531
  $('tr-sync-now').disabled = tr.syncing || c.syncing === true;
2162
2532
  openModal('m-trsync');
2163
2533
  }
2164
2534
  async function trSyncSave(close) {
2165
2535
  try {
2166
2536
  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) } });
2167
- if (close) { closeModal('m-trsync'); toast('用量同步设置已保存'); render(); }
2537
+ if (close) { closeModal('m-trsync'); toast(T('用量同步设置已保存')); render(); }
2168
2538
  } catch (e) { toast(e.message, false); throw e; }
2169
2539
  }
2170
2540
  function trForm() {
@@ -2178,36 +2548,36 @@ function trForm() {
2178
2548
  async function trTest() {
2179
2549
  const f = trForm();
2180
2550
  $('tr-test-msg').style.color = '';
2181
- $('tr-test-msg').textContent = '测试中…';
2551
+ $('tr-test-msg').textContent = T('测试中…');
2182
2552
  try {
2183
2553
  const r = await api('/api/tenrouter/test', { json: { endpoint: f.endpoint, key: f.key } });
2184
2554
  $('tr-test-msg').style.color = r.ok ? 'var(--ok)' : 'var(--err)';
2185
2555
  $('tr-test-msg').textContent = !r.ok ? '✗ ' + r.error
2186
- : r.quotas ? '✓ 连接成功:' + r.connections + ' 个可查额度的供应商连接' : '✓ 已连接,但' + r.warning + '(用量同步可以正常使用)';
2187
- } catch (e) { $('tr-test-msg').style.color = 'var(--err)'; $('tr-test-msg').textContent = '✗ ' + e.message; }
2556
+ : r.quotas ? T('✓ 连接成功:') + r.connections + T(' 个可查额度的供应商连接') : T('✓ 已连接,但') + r.warning + T('(用量同步可以正常使用)');
2557
+ } catch (e) { $('tr-test-msg').style.color = 'var(--err)'; $('tr-test-msg').textContent = '✗ ' + T(e.message); }
2188
2558
  }
2189
2559
  async function trSave() {
2190
2560
  const f = trForm();
2191
- if (!f.endpoint) return toast('请填写 10Router 地址', false);
2192
- if (!f.key && !(tr.config && tr.config.keyMasked)) return toast('请填写虚拟 key', false);
2561
+ if (!f.endpoint) return toast(T('请填写 10Router 地址'), false);
2562
+ if (!f.key && !(tr.config && tr.config.keyMasked)) return toast(T('请填写虚拟 key'), false);
2193
2563
  // 显示偏好只存在面板本地(localStorage),不上服务端
2194
2564
  state.trHideEmpty = $('tr-hide-empty').checked;
2195
2565
  try { localStorage.setItem('qd_tr_hideempty', state.trHideEmpty ? '1' : '0'); } catch (e) {}
2196
2566
  try {
2197
2567
  tr.config = await api('/api/tenrouter', { method: 'PUT', json: f });
2198
2568
  closeModal('m-10r');
2199
- toast('已保存 10Router 设置');
2569
+ toast(T('已保存 10Router 设置'));
2200
2570
  tr.quotas = null; tr.error = null;
2201
2571
  loadTrQuotas(true);
2202
2572
  } catch (e) { toast(e.message, false); }
2203
2573
  }
2204
2574
  async function trDisconnect() {
2205
- if (!(await uiConfirm('断开后会删除保存的 10Router 地址、key 与同步进度(10Router 里已同步的数据不受影响)。', '断开 10Router', '断开'))) return;
2575
+ if (!(await uiConfirm(T('断开后会删除保存的 10Router 地址、key 与同步进度(10Router 里已同步的数据不受影响)。'), T('断开 10Router'), T('断开')))) return;
2206
2576
  try { tr.config = await api('/api/tenrouter', { method: 'DELETE' }); tr.quotas = null; tr.error = null; closeModal('m-10r'); render(); }
2207
2577
  catch (e) { toast(e.message, false); }
2208
2578
  }
2209
2579
  async function trSyncAccounts() {
2210
- if (!(tr.config && tr.config.configured)) return toast('请先保存 10Router 设置', false);
2580
+ if (!(tr.config && tr.config.configured)) return toast(T('请先保存 10Router 设置'), false);
2211
2581
  const btn = $('tr-sync-accounts');
2212
2582
  const pw = $('tr-admin-pw').value;
2213
2583
  btn.disabled = true;
@@ -2269,6 +2639,12 @@ async function loadAccounts() {
2269
2639
  renderStatus();
2270
2640
  loadQuotas();
2271
2641
  }
2642
+ // 轻量刷新账号列表(只重拉 /api/accounts + 重绘,不重查每账号额度):
2643
+ // 「缺网页会话」徽章的数据源 qoderWebCapturedAt 在账号 meta 里,网页会话是在登录窗
2644
+ // 关闭时才收割绑定的(晚于登录成功提示数秒),登录成功后与周期轮询里都要按这个口径补拉。
2645
+ async function refreshAccountList() {
2646
+ try { state.accounts = (await api('/api/accounts')).accounts; render(); } catch (e) { /* 下一轮再试 */ }
2647
+ }
2272
2648
  let quotaRun = 0;
2273
2649
  let quotaLoadedAt = 0;
2274
2650
  async function loadQuotas(ids) {
@@ -2313,6 +2689,25 @@ function applyLogTabs() {
2313
2689
  b.classList.toggle('active', b.dataset.logLevel === state.logLevel);
2314
2690
  });
2315
2691
  }
2692
+ // 加载今日整段历史(结构化归档,按当前语言实时重渲染,突破环形缓冲 300 条上限)。
2693
+ // 常规 10 秒轮询会把视图刷回最近 200 条——历史视图点「切标签/级别」即回到常规轮询。
2694
+ $('btn-logs-history').addEventListener('click', async () => {
2695
+ const el = $('logs');
2696
+ if (!el) return;
2697
+ const qs = state.logTab === 'run' ? '' : ('&tag=' + encodeURIComponent(GW_LOG_TAG[state.logTab]));
2698
+ try {
2699
+ const data = await api('/api/logs?history=1' + qs);
2700
+ let logs = data.logs || [];
2701
+ if (state.logLevel !== 'all') logs = logs.filter((l) => l.level === 'warn' || l.level === 'error');
2702
+ const withTag = state.logTab === 'run';
2703
+ const ico = (lvl) => lvl === 'error' ? '✗' : lvl === 'warn' ? '⚠' : lvl === 'info' ? '·' : '';
2704
+ el.innerHTML = logs.length
2705
+ ? logs.map((l) => '<div class="' + esc(l.level) + '">[' + esc(l.at) + '] <span class="lvl-ico">' + ico(l.level) + '</span>' + (withTag ? esc(l.tag) + ' · ' : '') + esc(l.msg) + '</div>').join('')
2706
+ : T('(暂无历史记录)');
2707
+ el.scrollTop = el.scrollHeight;
2708
+ toast(T('已加载今日历史 {n} 条', { n: logs.length }), true);
2709
+ } catch (e) { toast(e.message, false); }
2710
+ });
2316
2711
  async function loadCurrentLogs() {
2317
2712
  const el = $('logs');
2318
2713
  if (!el) return;
@@ -2333,7 +2728,7 @@ async function loadCurrentLogs() {
2333
2728
  const ico = (lvl) => lvl === 'error' ? '✗' : lvl === 'warn' ? '⚠' : lvl === 'info' ? '·' : '';
2334
2729
  el.innerHTML = logs.length
2335
2730
  ? logs.map((l) => '<div class="' + esc(l.level) + '">[' + esc(l.at) + '] <span class="lvl-ico">' + ico(l.level) + '</span>' + (withTag ? esc(l.tag) + ' · ' : '') + esc(l.msg) + '</div>').join('')
2336
- : (state.logLevel === 'all' ? '(暂无)' : '(暂无警告或错误)');
2731
+ : (state.logLevel === 'all' ? T('(暂无)') : T('(暂无警告或错误)'));
2337
2732
  if (atBottom) el.scrollTop = el.scrollHeight;
2338
2733
  } catch (e) {}
2339
2734
  }
@@ -2345,15 +2740,15 @@ async function checkinOne(id) {
2345
2740
  const d = await api('/api/accounts/' + encodeURIComponent(id) + '/checkin', { method: 'POST' });
2346
2741
  const r = d.results && d.results[0];
2347
2742
  const accObj = state.accounts.find((x) => x.id === id);
2348
- const accLabel = (r && r.account) || (accObj && (accObj.name || accObj.id)) || '该账号';
2349
- toast(r ? (accLabel + ':' + (r.status === 'checked-in' ? '领取成功 +' + r.claimedAmount + ' Credits' : (r.error || r.message || r.status))) : d.summary, !(r && r.status === 'failed'));
2743
+ const accLabel = (r && r.account) || (accObj && (accObj.name || accObj.id)) || T('该账号');
2744
+ toast(r ? (accLabel + T(':') + (r.status === 'checked-in' ? T('领取成功 +') + r.claimedAmount + ' Credits' : (r.error || r.message || r.status))) : d.summary, !(r && r.status === 'failed'));
2350
2745
  } catch (e) { toast(e.message, false); }
2351
2746
  delete state.busy[id];
2352
2747
  await loadStatus(); await loadAccounts(); loadLogs();
2353
2748
  }
2354
2749
  async function checkinAll(btn, product) {
2355
2750
  const label = btn.innerHTML;
2356
- btn.disabled = true; btn.innerHTML = I.refresh + '领取中…';
2751
+ btn.disabled = true; btn.innerHTML = I.refresh + T('领取中…');
2357
2752
  try {
2358
2753
  const d = await api('/api/checkin', { json: { skipIfCheckedToday: false, product } });
2359
2754
  toast(d.summary);
@@ -2371,30 +2766,30 @@ async function onCardClick(e) {
2371
2766
  if (act === 'claim') return openClaim(id);
2372
2767
  if (act === 'switch') {
2373
2768
  const a = state.accounts.find((x) => x.id === id);
2374
- const client = CLIENT_LABEL[a && a.product] || (a && prodLabel(a.product)) || '客户端';
2769
+ const client = (CLIENT_LABEL[a && a.product] ? T(CLIENT_LABEL[a && a.product]) : '') || (a && prodLabel(a.product)) || T('客户端');
2375
2770
  const tip = a && a.product === 'zcode'
2376
- ? '\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2771
+ ? T('\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
2377
2772
  : a && a.product === 'mirasim'
2378
- ? '\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2773
+ ? T('\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
2379
2774
  : a && a.product === 'catpaw'
2380
- ? '\n需先退出妙手;切换后重新打开妙手即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2775
+ ? T('\n需先退出妙手;切换后重新打开妙手即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
2381
2776
  : a && a.product === 'trae'
2382
- ? '\n需先退出 Trae;切换后重新打开 Trae 即为新账号。当前登录会先自动快照保存到 CreditDaddy,不会丢号。'
2777
+ ? T('\n需先退出 Trae;切换后重新打开 Trae 即为新账号。当前登录会先自动快照保存到 CreditDaddy,不会丢号。')
2383
2778
  : a && a.product === 'qoder'
2384
- ? '\n需先退出 Qoder;切换后重新打开 Qoder 即为新账号。运行中的 Qoder 退出时会把旧登录写回去,可选「关闭并强制切换」代为关进程。'
2385
- : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
2386
- if (!(await uiConfirm('把 ' + client + ' 切换到「' + (a && a.name || id) + '」?' + tip, '切换账号', '切换'))) return;
2779
+ ? T('\n需先退出 Qoder;切换后重新打开 Qoder 即为新账号。运行中的 Qoder 退出时会把旧登录写回去,可选「关闭并强制切换」代为关进程。')
2780
+ : T('\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。');
2781
+ if (!(await uiConfirm(T('把 {client} 切换到「{name}」?', { client, name: (a && a.name || id) }) + tip, T('切换账号'), T('切换')))) return;
2387
2782
  const doSwitch = (force) => api('/api/accounts/' + encodeURIComponent(id) + '/switch', { json: { force } });
2388
2783
  try {
2389
2784
  let r;
2390
2785
  try { r = await doSwitch(false); }
2391
2786
  catch (err) {
2392
2787
  if (err.code !== 'ZCODE_RUNNING' && err.code !== 'MIRASIM_RUNNING' && err.code !== 'CATPAW_RUNNING' && err.code !== 'TRAE_RUNNING' && err.code !== 'QODER_RUNNING') throw err;
2393
- if (!(await uiConfirm(err.message + '\n\n仍要强制切换吗?强制切换会先关闭客户端再写入登录,重新打开客户端即为新账号。', client + ' 正在运行', '关闭并强制切换'))) return;
2788
+ if (!(await uiConfirm(err.message + T('\n\n仍要强制切换吗?强制切换会先关闭客户端再写入登录,重新打开客户端即为新账号。'), client + T(' 正在运行'), T('关闭并强制切换')))) return;
2394
2789
  r = await doSwitch(true);
2395
2790
  }
2396
- const msg = r && r.alreadyActive ? client + ' 当前已是该账号'
2397
- : (r && r.closedClient ? '已关闭并切换 ' : '') + client + ' 已切换到 ' + (a && a.name || id);
2791
+ const msg = r && r.alreadyActive ? client + T(' 当前已是该账号')
2792
+ : (r && r.closedClient ? T('已关闭并切换 ') : '') + client + T(' 已切换到 ') + (a && a.name || id);
2398
2793
  toast(msg);
2399
2794
  await loadStatus(); await loadAccounts();
2400
2795
  // 桌面版切号后自动唤醒打开客户端,免去手动查找客户端快捷方式
@@ -2402,7 +2797,7 @@ async function onCardClick(e) {
2402
2797
  setTimeout(async () => {
2403
2798
  try {
2404
2799
  const re = await window.creditdaddy.openZcodeClient();
2405
- if (re && re.ok) toast('已重新启动 ZCode 客户端');
2800
+ if (re && re.ok) toast(T('已重新启动 ZCode 客户端'));
2406
2801
  } catch (e) {}
2407
2802
  }, 800);
2408
2803
  }
@@ -2410,7 +2805,7 @@ async function onCardClick(e) {
2410
2805
  setTimeout(async () => {
2411
2806
  try {
2412
2807
  const re = await window.creditdaddy.openMirasimClient();
2413
- if (re && re.ok) toast('已重新启动 Mirasim 客户端');
2808
+ if (re && re.ok) toast(T('已重新启动 Mirasim 客户端'));
2414
2809
  } catch (e) {}
2415
2810
  }, 800);
2416
2811
  }
@@ -2418,7 +2813,7 @@ async function onCardClick(e) {
2418
2813
  setTimeout(async () => {
2419
2814
  try {
2420
2815
  const re = await window.creditdaddy.openCatpawClient();
2421
- if (re && re.ok) toast('已重新启动妙手客户端');
2816
+ if (re && re.ok) toast(T('已重新启动妙手客户端'));
2422
2817
  } catch (e) {}
2423
2818
  }, 800);
2424
2819
  }
@@ -2426,8 +2821,8 @@ async function onCardClick(e) {
2426
2821
  setTimeout(async () => {
2427
2822
  try {
2428
2823
  const re = await window.creditdaddy.openTraeClient();
2429
- if (re && re.ok) toast('已重新启动 Trae 客户端');
2430
- else toast((re && re.error) || '无法打开 Trae 客户端(桌面版才能自动拉起)', false);
2824
+ if (re && re.ok) toast(T('已重新启动 Trae 客户端'));
2825
+ else toast((re && re.error) || T('无法打开 Trae 客户端(桌面版才能自动拉起)'), false);
2431
2826
  } catch (e) {}
2432
2827
  }, 800);
2433
2828
  }
@@ -2436,7 +2831,7 @@ async function onCardClick(e) {
2436
2831
  }
2437
2832
  if (act === 'rename') {
2438
2833
  const a = state.accounts.find((x) => x.id === id);
2439
- const name = await uiPrompt('修改备注名', a && a.name || '', { okText: '保存' });
2834
+ const name = await uiPrompt(T('修改备注名'), a && a.name || '', { okText: T('保存') });
2440
2835
  if (name === null) return;
2441
2836
  try { await api('/api/accounts/' + encodeURIComponent(id), { method: 'PATCH', json: { name } }); await loadAccounts(); }
2442
2837
  catch (err) { toast(err.message, false); }
@@ -2446,11 +2841,11 @@ async function onCardClick(e) {
2446
2841
  if (!armed[id]) {
2447
2842
  armed[id] = setTimeout(() => { delete armed[id]; btn.classList.remove('armed'); }, 3000);
2448
2843
  btn.classList.add('armed');
2449
- toast('再点一次确认删除');
2844
+ toast(T('再点一次确认删除'));
2450
2845
  return;
2451
2846
  }
2452
2847
  clearTimeout(armed[id]); delete armed[id];
2453
- try { await api('/api/accounts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('已删除'); delete state.quotas[id]; await loadAccounts(); }
2848
+ try { await api('/api/accounts/' + encodeURIComponent(id), { method: 'DELETE' }); toast(T('已删除')); delete state.quotas[id]; await loadAccounts(); }
2454
2849
  catch (err) { toast(err.message, false); }
2455
2850
  }
2456
2851
  }
@@ -2467,7 +2862,7 @@ function loadCaptchaSdk() {
2467
2862
  const s = document.createElement('script');
2468
2863
  s.src = CAPTCHA_SDK_URL;
2469
2864
  s.onload = () => resolve();
2470
- s.onerror = () => reject(new Error('验证码组件加载失败(网络问题?)'));
2865
+ s.onerror = () => reject(new Error(T('验证码组件加载失败(网络问题?)')));
2471
2866
  document.head.appendChild(s);
2472
2867
  });
2473
2868
  }
@@ -2480,7 +2875,7 @@ async function openClaim(id) {
2480
2875
  claimState.plans = [];
2481
2876
  claimState.captcha = null;
2482
2877
  claimState.busy = false;
2483
- $('claim-body').innerHTML = '<div class="dim">读取可领取的活动…</div>';
2878
+ $('claim-body').innerHTML = T('<div class="dim">读取可领取的活动…</div>');
2484
2879
  openModal('m-claim');
2485
2880
  try {
2486
2881
  const [plansResp, cap] = await Promise.all([
@@ -2498,17 +2893,17 @@ async function openClaim(id) {
2498
2893
 
2499
2894
  function renderClaimBody() {
2500
2895
  if (!claimState.plans.length) {
2501
- $('claim-body').innerHTML = '<div class="muted-empty">当前没有可领取的活动</div>';
2896
+ $('claim-body').innerHTML = T('<div class="muted-empty">当前没有可领取的活动</div>');
2502
2897
  return;
2503
2898
  }
2504
2899
  $('claim-body').innerHTML = claimState.plans.map((pl, i) =>
2505
2900
  '<div class="claim-plan"><div><b>' + esc(pl.name || pl.planId) + '</b>'
2506
2901
  + (pl.description ? '<div class="dim grants">' + esc(pl.description) + '</div>' : '')
2507
- + (pl.grants && pl.grants.length ? '<div class="dim grants">' + pl.grants.map(esc).join(';') + '</div>' : '')
2508
- + '</div><button class="btn primary" data-claim="' + i + '"' + (claimState.busy ? ' disabled' : '') + '>领取</button></div>'
2902
+ + (pl.grants && pl.grants.length ? '<div class="dim grants">' + pl.grants.map(esc).join(T(';')) + '</div>' : '')
2903
+ + '</div><button class="btn primary" data-claim="' + i + '"' + (claimState.busy ? ' disabled' : '') + T('>领取</button></div>')
2509
2904
  ).join('')
2510
2905
  + '<div id="claim-status" class="dim" style="margin-top:8px"></div>'
2511
- + '<div id="cap-holder"></div><button class="btn" id="cap-btn" hidden>打开验证码</button>';
2906
+ + T('<div id="cap-holder"></div><button class="btn" id="cap-btn" hidden>打开验证码</button>');
2512
2907
  }
2513
2908
 
2514
2909
  // 阿里云验证码:先静默验证(8s 超时),不行就让用户点按钮弹验证码。移植自 zcode-switch captcha.js。
@@ -2522,13 +2917,13 @@ function runCaptcha(cfg) {
2522
2917
  if (done || !param) return;
2523
2918
  done = true;
2524
2919
  clearTimeout(timer);
2525
- st.textContent = '人机验证已通过';
2920
+ st.textContent = T('人机验证已通过');
2526
2921
  resolve(param);
2527
2922
  };
2528
2923
  const interactive = (why) => {
2529
2924
  if (done) return;
2530
2925
  clearTimeout(timer);
2531
- st.textContent = '需要完成人机验证:点下方按钮,按提示完成验证码';
2926
+ st.textContent = T('需要完成人机验证:点下方按钮,按提示完成验证码');
2532
2927
  if (btn) { btn.hidden = false; btn.focus(); }
2533
2928
  if (why && typeof why !== 'string') st.title = JSON.stringify(why).slice(0, 160);
2534
2929
  };
@@ -2542,7 +2937,7 @@ function runCaptcha(cfg) {
2542
2937
  button: '#cap-btn',
2543
2938
  getInstance: (instance) => {
2544
2939
  if (instance && typeof instance.startTracelessVerification === 'function') {
2545
- st.textContent = '人机验证中…';
2940
+ st.textContent = T('人机验证中…');
2546
2941
  instance.startTracelessVerification();
2547
2942
  timer = setTimeout(() => interactive(), 8000);
2548
2943
  } else interactive();
@@ -2561,7 +2956,7 @@ async function doClaim(i) {
2561
2956
  const st = $('claim-status');
2562
2957
  claimState.busy = true;
2563
2958
  renderClaimBody();
2564
- $('claim-status').textContent = '准备领取…';
2959
+ $('claim-status').textContent = T('准备领取…');
2565
2960
  let captchaParam = '';
2566
2961
  let region = '';
2567
2962
  try {
@@ -2571,27 +2966,27 @@ async function doClaim(i) {
2571
2966
  region = cfg.region || '';
2572
2967
  captchaParam = await runCaptcha(cfg);
2573
2968
  } else {
2574
- $('claim-status').textContent = '验证码配置不可用,按无验证码直接尝试…';
2969
+ $('claim-status').textContent = T('验证码配置不可用,按无验证码直接尝试…');
2575
2970
  }
2576
2971
  } catch (e) {
2577
- $('claim-status').innerHTML = '<span class="msg err">' + esc('验证码失败:' + e.message) + '</span>';
2972
+ $('claim-status').innerHTML = '<span class="msg err">' + esc(T('验证码失败:') + e.message) + '</span>';
2578
2973
  claimState.busy = false;
2579
2974
  renderClaimBody();
2580
2975
  return;
2581
2976
  }
2582
- $('claim-status').textContent = '领取中…';
2977
+ $('claim-status').textContent = T('领取中…');
2583
2978
  try {
2584
2979
  const r = await api('/api/accounts/' + encodeURIComponent(claimState.accountId) + '/zcode/claim', {
2585
2980
  json: { planId: pl.planId, captchaParam, region },
2586
2981
  });
2587
- toast('领取成功:' + (r.planName || pl.name || pl.planId));
2982
+ toast(T('领取成功:') + (r.planName || pl.name || pl.planId));
2588
2983
  closeModal('m-claim');
2589
2984
  loadQuotas([claimState.accountId]);
2590
2985
  loadLogs();
2591
2986
  } catch (e) {
2592
2987
  const errBox = $('claim-status');
2593
2988
  if (errBox) {
2594
- errBox.innerHTML = '<span class="msg err">' + esc(e.message) + (e.nextAt ? '(' + fmtTime(e.nextAt).slice(5, 16) + ' 后可再试)' : '') + '</span>';
2989
+ errBox.innerHTML = '<span class="msg err">' + esc(e.message) + (e.nextAt ? T('(') + fmtTime(e.nextAt).slice(5, 16) + T(' 后可再试)') : '') + '</span>';
2595
2990
  }
2596
2991
  }
2597
2992
  claimState.busy = false;
@@ -2604,7 +2999,11 @@ $('claim-body').addEventListener('click', (e) => {
2604
2999
  });
2605
3000
 
2606
3001
  // ── 弹窗 ──
2607
- function openModal(id) { $(id).classList.add('show'); }
3002
+ function openModal(id) {
3003
+ // 设置弹出层不是遮罩弹窗:开其它弹窗前先收掉,避免浮在遮罩弹窗之上(层级错乱来源)
3004
+ if (id !== 'm-settings') { const st = $('m-settings'); if (st) st.classList.remove('show'); }
3005
+ $(id).classList.add('show');
3006
+ }
2608
3007
  function closeModal(id) { $(id).classList.remove('show'); if (id === 'm-add') deviceStop(); if (id === 'm-dialog') settleDialog(null); }
2609
3008
 
2610
3009
  // 页面内对话框(替代原生 confirm / prompt:面板嵌在飞牛桌面窗口里是跨域 iframe,原生弹框可能被浏览器拦截)
@@ -2618,12 +3017,12 @@ function uiDialog({ title, message, input, okText }) {
2618
3017
  $('dlg-msg').style.display = message ? '' : 'none';
2619
3018
  $('dlg-field').style.display = input ? '' : 'none';
2620
3019
  if (input) { $('dlg-input').type = input.type || 'text'; $('dlg-input').value = input.value || ''; $('dlg-input').placeholder = input.placeholder || ''; }
2621
- $('dlg-ok').textContent = okText || '确定';
3020
+ $('dlg-ok').textContent = okText || T('确定');
2622
3021
  openModal('m-dialog');
2623
3022
  setTimeout(() => (input ? $('dlg-input') : $('dlg-ok')).focus(), 30);
2624
3023
  return new Promise((resolve) => { dialogResolve = (v) => resolve(input ? v : v !== null); });
2625
3024
  }
2626
- const uiConfirm = (message, title, okText) => uiDialog({ title: title || '请确认', message, okText });
3025
+ const uiConfirm = (message, title, okText) => uiDialog({ title: title || T('请确认'), message, okText });
2627
3026
  const uiPrompt = (title, value, opts = {}) => uiDialog({ title, message: opts.message, input: { value, type: opts.type, placeholder: opts.placeholder }, okText: opts.okText });
2628
3027
  function okDialog() { const v = $('dlg-field').style.display === 'none' ? true : $('dlg-input').value; $('m-dialog').classList.remove('show'); settleDialog(v); }
2629
3028
  $('dlg-ok').addEventListener('click', okDialog);
@@ -2633,7 +3032,7 @@ $('dlg-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') okDia
2633
3032
  async function restartCreditDaddy() {
2634
3033
  const mask = document.createElement('div');
2635
3034
  mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;font-size:15px;gap:12px;backdrop-filter:blur(4px);user-select:none;';
2636
- mask.innerHTML = '<div style="font-size:28px">⏳</div><div style="font-weight:600">正在重启 CreditDaddy,请稍候…</div><div style="font-size:12px;color:rgba(255,255,255,.6)">服务恢复后将自动刷新页面</div>';
3035
+ mask.innerHTML = T('<div style="font-size:28px">⏳</div><div style="font-weight:600">正在重启 CreditDaddy,请稍候…</div><div style="font-size:12px;color:rgba(255,255,255,.6)">服务恢复后将自动刷新页面</div>');
2637
3036
  document.body.appendChild(mask);
2638
3037
 
2639
3038
  try {
@@ -2656,14 +3055,14 @@ async function restartCreditDaddy() {
2656
3055
  } catch {}
2657
3056
  if (attempts > 30) {
2658
3057
  clearInterval(poll);
2659
- mask.innerHTML = '<div>重启超时或端口已改变</div><button class="btn primary" onclick="location.reload()" style="margin-top:10px">手动刷新</button>';
3058
+ mask.innerHTML = T('<div>重启超时或端口已改变</div><button class="btn primary" onclick="location.reload()" style="margin-top:10px">手动刷新</button>');
2660
3059
  }
2661
3060
  }, 1000);
2662
3061
  }
2663
3062
 
2664
3063
  async function maybePromptRestartForLan(oldLan, newLan) {
2665
3064
  if (oldLan !== newLan) {
2666
- const ok = await uiConfirm('局域网开放绑定改动需重启服务生效,是否立即重启 CreditDaddy?', '配置已保存', '立即重启');
3065
+ const ok = await uiConfirm(T('局域网开放绑定改动需重启服务生效,是否立即重启 CreditDaddy?'), T('配置已保存'), T('立即重启'));
2667
3066
  if (ok) {
2668
3067
  await restartCreditDaddy();
2669
3068
  return true;
@@ -2672,13 +3071,6 @@ async function maybePromptRestartForLan(oldLan, newLan) {
2672
3071
  return false;
2673
3072
  }
2674
3073
 
2675
- $('btn-restart-app').addEventListener('click', async () => {
2676
- const ok = await uiConfirm('确定要重启 CreditDaddy 服务吗?正在进行的任务可能会中断。', '重启服务', '重启');
2677
- if (ok) {
2678
- closeModal('m-settings');
2679
- await restartCreditDaddy();
2680
- }
2681
- });
2682
3074
 
2683
3075
  // ── 检查更新(桌面版):主进程推状态(update:state),面板只管渲染 ──
2684
3076
  const updBridge = window.creditdaddy && window.creditdaddy.update ? window.creditdaddy.update : null;
@@ -2690,55 +3082,64 @@ function renderUpdateModal(s) {
2690
3082
  updState = s;
2691
3083
  const body = $('upd-body');
2692
3084
  const acts = $('upd-actions');
2693
- const cur = '当前 v' + (s.current || '?');
2694
- const rel = '<button class="btn" id="upd-rel">打开 Releases</button>';
2695
- const close = '<button class="btn" data-close-uu>稍后</button>';
3085
+ const cur = T('当前 v') + (s.current || '?');
3086
+ const rel = T('<button class="btn" id="upd-rel">打开 Releases</button>');
3087
+ const close = T('<button class="btn" data-close-uu>稍后</button>');
2696
3088
  switch (s.phase) {
3089
+ case 'unsupported-browser':
3090
+ body.innerHTML = T('<div class="hint">在浏览器里打开的面板没有本机更新通道——请从 GitHub Releases 下载最新安装包安装。</div>');
3091
+ acts.innerHTML = rel + T('<button class="btn" data-close-uu>取消</button>');
3092
+ break;
2697
3093
  case 'unsupported':
2698
- body.innerHTML = '<div class="hint">当前环境不支持应用内更新(开发模式 / Portable / 未签名的 macOS 版),请从 GitHub Releases 手动下载。</div>';
2699
- acts.innerHTML = rel + '<button class="btn" data-close-uu>取消</button>';
3094
+ body.innerHTML = T('<div class="hint">当前环境不支持应用内更新(开发模式 / Portable / 未签名的 macOS 版),请从 GitHub Releases 手动下载。</div>');
3095
+ acts.innerHTML = rel + T('<button class="btn" data-close-uu>取消</button>');
2700
3096
  break;
2701
3097
  case 'checking':
2702
- body.innerHTML = '<div class="hint">正在检查更新…</div>';
3098
+ body.innerHTML = T('<div class="hint">正在检查更新…</div>');
2703
3099
  acts.innerHTML = close;
2704
3100
  break;
2705
3101
  case 'latest':
2706
- body.innerHTML = '<div class="hint"><span class="dot on"></span> 已是最新版本(' + cur + ')</div>';
2707
- acts.innerHTML = '<button class="btn primary" data-close-uu>好的</button>';
3102
+ body.innerHTML = T('<div class="hint"><span class="dot on"></span> 已是最新版本(') + cur + ')</div>';
3103
+ acts.innerHTML = T('<button class="btn primary" data-close-uu>好的</button>');
2708
3104
  break;
2709
3105
  case 'available':
2710
3106
  body.innerHTML = s.via === 'updater'
2711
- ? '<div class="hint">发现新版本 <b>v' + esc(s.version) + '</b>(' + cur + ')——正在后台下载,完成后退出 CreditDaddy 时自动安装。</div>'
2712
- : '<div class="hint">发现新版本 <b>v' + esc(s.version) + '</b>(' + cur + (s.sizeMB ? ',约 ' + s.sizeMB + ' MB' : '') + '),可由本机直接下载安装。</div>';
2713
- acts.innerHTML = (s.via === 'github' ? updActBtn('upd-go', '立即下载安装', 'primary') : '') + rel + close;
3107
+ ? T('<div class="hint">发现新版本 <b>v') + esc(s.version) + T('</b>(') + cur + T(')——正在后台下载,完成后退出 CreditDaddy 时自动安装。</div>')
3108
+ : T('<div class="hint">发现新版本 <b>v') + esc(s.version) + T('</b>(') + cur + (s.sizeMB ? T(',约 ') + s.sizeMB + ' MB' : '') + T('),可由本机直接下载安装。</div>');
3109
+ acts.innerHTML = (s.via === 'github' ? updActBtn('upd-go', T('立即下载安装'), 'primary') : '') + rel + close;
2714
3110
  break;
2715
3111
  case 'downloading':
2716
- body.innerHTML = '<div class="hint">正在下载 v' + esc(s.version) + '</div>'
3112
+ body.innerHTML = T('<div class="hint">正在下载 v') + esc(s.version) + '</div>'
2717
3113
  + '<div class="meter" style="margin:8px 0 4px"><span style="width:' + (s.percent || 0) + '%"></span></div>'
2718
- + '<div class="hint">' + (s.percent != null ? s.percent + '%' : '') + '(' + (s.mb || '…') + ' MB)' + (s.percent == null ? ' 获取大小中…' : '') + '</div>';
2719
- acts.innerHTML = '<button class="btn" data-close-uu>后台继续</button>';
3114
+ + '<div class="hint">' + (s.percent != null ? s.percent + '%' : '') + T('(') + (s.mb || '…') + T(' MB)') + (s.percent == null ? T(' 获取大小中…') : '') + '</div>';
3115
+ acts.innerHTML = T('<button class="btn" data-close-uu>后台继续</button>');
2720
3116
  break;
2721
3117
  case 'downloaded':
2722
3118
  body.innerHTML = '<div class="hint"><span class="dot on pulse"></span> <b>v' + esc(s.version) + '</b> '
2723
- + (s.via === 'updater' ? '已下载完成,重启即完成更新(退出时也会自动安装)。' : '安装包已下载完成,安装时 CreditDaddy 会先退出。') + '</div>';
2724
- acts.innerHTML = updActBtn('upd-go', s.via === 'updater' ? '立即重启更新' : '立即安装', 'primary') + close;
3119
+ + (s.via === 'updater' ? T('已下载完成,重启即完成更新(退出时也会自动安装)。') : T('安装包已下载完成,安装时 CreditDaddy 会先退出。')) + '</div>';
3120
+ acts.innerHTML = updActBtn('upd-go', s.via === 'updater' ? T('立即重启更新') : T('立即安装'), 'primary') + close;
2725
3121
  break;
2726
3122
  case 'error':
2727
3123
  default:
2728
- body.innerHTML = '<div class="hint" style="color:var(--err)">检查更新失败:' + esc(s.error || '未知错误') + '</div>';
2729
- acts.innerHTML = updActBtn('upd-retry', '重试', 'primary') + rel + '<button class="btn" data-close-uu>取消</button>';
3124
+ body.innerHTML = T('<div class="hint" style="color:var(--err)">检查更新失败:') + esc(T(s.error || '') || T('未知错误')) + '</div>';
3125
+ acts.innerHTML = updActBtn('upd-retry', T('重试'), 'primary') + rel + T('<button class="btn" data-close-uu>取消</button>');
2730
3126
  break;
2731
3127
  }
2732
3128
  const relBtn = $('upd-rel');
2733
- if (relBtn) relBtn.onclick = () => updBridge.openReleases();
3129
+ if (relBtn) relBtn.onclick = () => { if (updBridge) updBridge.openReleases(); else openFullChangelog(); };
2734
3130
  const goBtn = $('upd-go');
2735
- if (goBtn) { goBtn.disabled = true; goBtn.textContent = '处理中…'; updBridge.install().then(renderUpdateModal).catch((e) => { goBtn.disabled = false; goBtn.textContent = '重试'; }); }
3131
+ if (goBtn) { goBtn.disabled = true; goBtn.textContent = T('处理中…'); updBridge.install().then(renderUpdateModal).catch((e) => { goBtn.disabled = false; goBtn.textContent = T('重试'); }); }
2736
3132
  const retryBtn = $('upd-retry');
2737
3133
  if (retryBtn) retryBtn.onclick = () => updBridge.check().then(renderUpdateModal).catch(() => {});
2738
3134
  acts.querySelectorAll('[data-close-uu]').forEach((b) => { b.onclick = () => closeModal('m-update'); });
2739
3135
  }
2740
3136
  async function openUpdateModal(autoCheck) {
2741
- if (!updBridge) return;
3137
+ if (!updBridge) {
3138
+ // 纯浏览器:没有本机 updater 通道,保留入口并给出 Releases 下载承接
3139
+ openModal('m-update');
3140
+ renderUpdateModal({ phase: 'unsupported-browser', current: (state.status && state.status.version) || '' });
3141
+ return;
3142
+ }
2742
3143
  openModal('m-update');
2743
3144
  try {
2744
3145
  updState = await updBridge.state();
@@ -2749,10 +3150,36 @@ async function openUpdateModal(autoCheck) {
2749
3150
  }
2750
3151
  }
2751
3152
  if (updBridge) {
2752
- updBridge.onState((s) => { updState = s; $('upd-ver') && ($('upd-ver').textContent = '当前 v' + (s.current || '?')); if (document.getElementById('m-update').classList.contains('show')) renderUpdateModal(s); });
3153
+ updBridge.onState((s) => { updState = s; $('upd-ver') && ($('upd-ver').textContent = T('当前 v') + (s.current || '?')); if (document.getElementById('m-update').classList.contains('show')) renderUpdateModal(s); });
2753
3154
  updBridge.onOpen(() => openUpdateModal(true));
2754
3155
  }
2755
- $('btn-update').onclick = () => openUpdateModal(true);
3156
+ // 更新日志小窗:桌面版读随包 CHANGELOG.md 的简要摘要;网页面板直接开 GitHub Releases
3157
+ function openFullChangelog() {
3158
+ if (updBridge && typeof updBridge.openReleases === 'function') updBridge.openReleases();
3159
+ else window.open('https://github.com/techysy/CreditDaddy/releases', '_blank', 'noopener');
3160
+ }
3161
+ function openChangelogModal() {
3162
+ const openReleases = openFullChangelog;
3163
+ if (!(window.creditdaddy && typeof window.creditdaddy.changelogBrief === 'function')) { openReleases(); return; }
3164
+ window.creditdaddy.changelogBrief().then((r) => {
3165
+ const box = $('changelog-body');
3166
+ const strip = (s) => esc(String(s || '').replace(/\*\*/g, ''));
3167
+ box.innerHTML = (r && r.ok && r.sections && r.sections.length)
3168
+ ? r.sections.map((s) => '<div class="changelog-sec">'
3169
+ + '<div style="font-weight:700;margin:0 0 4px">v' + esc(s.version)
3170
+ + (s.date ? ' <span class="dim" style="font-weight:400;font-size:11px">' + esc(s.date) + '</span>' : '') + '</div>'
3171
+ + (s.theme ? '<div class="hint">' + strip(s.theme) + '</div>' : '')
3172
+ + (s.topics && s.topics.length ? '<div class="hint" style="margin-top:4px">' + s.topics.map(strip).join(' · ') + '</div>' : '')
3173
+ + '</div>').join('')
3174
+ : T('<div class="hint">暂无本地更新日志,请到 GitHub Releases 查看。</div>');
3175
+ openModal('m-changelog');
3176
+ }).catch(openReleases);
3177
+ }
3178
+ $('btn-changelog-full').onclick = openFullChangelog;
3179
+ // 托盘菜单触发(IPC)
3180
+ if (updBridge && typeof updBridge.onChangelogOpen === 'function') {
3181
+ updBridge.onChangelogOpen(openChangelogModal);
3182
+ }
2756
3183
 
2757
3184
  // ZCode 自动领取计划:保存(按新配置启用)/ 停用
2758
3185
  $('zac-ok').addEventListener('click', async () => {
@@ -2763,7 +3190,7 @@ $('zac-ok').addEventListener('click', async () => {
2763
3190
  state.zClaimWindowMin = r.claimWindowMin;
2764
3191
  renderStatus(); render();
2765
3192
  closeModal('m-zac');
2766
- toast('自动领取已开启:每 ' + r.claimIntervalMin + ' 分钟检查' + (r.claimWindowMin > 0 ? ',运行 ' + r.claimWindowMin + ' 分钟后自动关闭' : ',一直运行直到手动关闭'));
3193
+ toast(T('自动领取已开启:每 ') + r.claimIntervalMin + T(' 分钟检查') + (r.claimWindowMin > 0 ? T(',运行 ') + r.claimWindowMin + T(' 分钟后自动关闭') : T(',一直运行直到手动关闭')));
2767
3194
  } catch (err) { toast(err.message, false); }
2768
3195
  });
2769
3196
  $('zac-off').addEventListener('click', async () => {
@@ -2772,7 +3199,7 @@ $('zac-off').addEventListener('click', async () => {
2772
3199
  state.zAutoClaim = r.autoClaim === true;
2773
3200
  renderStatus(); render();
2774
3201
  closeModal('m-zac');
2775
- toast('ZCode 自动领取:已关闭');
3202
+ toast(T('ZCode 自动领取:已关闭'));
2776
3203
  } catch (err) { toast(err.message, false); }
2777
3204
  });
2778
3205
 
@@ -2794,8 +3221,8 @@ $('zgw-ok').addEventListener('click', async () => {
2794
3221
  renderStatus(); render();
2795
3222
  closeModal('m-zgw');
2796
3223
  if (await maybePromptRestartForLan(oldLan, state.zGatewayLan)) return;
2797
- toast('体验包接口:' + (r.enabled ? '已开启' : '已关闭')
2798
- + (r.enabled && r.lan ? ',局域网开放' + (r.allow && r.allow.length ? ',白名单 ' + r.allow.join('、') : ',白名单为空——白名单外需虚拟 key') + ';绑定改动重启后生效' : ''));
3224
+ toast(T('体验包接口:') + (r.enabled ? T('已开启') : T('已关闭'))
3225
+ + (r.enabled && r.lan ? T(',局域网开放') + (r.allow && r.allow.length ? T(',白名单 ') + r.allow.join(T('、')) : T(',白名单为空——白名单外需虚拟 key')) + T(';绑定改动重启后生效') : ''));
2799
3226
  } catch (err) { toast(err.message, false); }
2800
3227
  });
2801
3228
 
@@ -2817,8 +3244,8 @@ $('mgw-ok').addEventListener('click', async () => {
2817
3244
  renderStatus(); render();
2818
3245
  closeModal('m-mgw');
2819
3246
  if (await maybePromptRestartForLan(oldLan, state.mGatewayLan)) return;
2820
- toast('MiniMax 接口:' + (r.enabled ? '已开启' : '已关闭')
2821
- + (r.enabled && r.lan ? ',局域网开放' + (r.allow && r.allow.length ? ',白名单 ' + r.allow.join('、') : '') + ';绑定改动重启后生效' : ''));
3247
+ toast(T('MiniMax 接口:') + (r.enabled ? T('已开启') : T('已关闭'))
3248
+ + (r.enabled && r.lan ? T(',局域网开放') + (r.allow && r.allow.length ? T(',白名单 ') + r.allow.join(T('、')) : '') + T(';绑定改动重启后生效') : ''));
2822
3249
  } catch (err) { toast(err.message, false); }
2823
3250
  });
2824
3251
 
@@ -2840,27 +3267,35 @@ $('tgw-ok').addEventListener('click', async () => {
2840
3267
  renderStatus(); render();
2841
3268
  closeModal('m-tgw');
2842
3269
  if (await maybePromptRestartForLan(oldLan, state.tGatewayLan)) return;
2843
- toast('Trae 接口:' + (r.enabled ? '已开启' : '已关闭')
2844
- + (r.enabled && r.lan ? ',局域网开放' + (r.allow && r.allow.length ? ',白名单 ' + r.allow.join('、') : '') + ';绑定改动重启后生效' : ''));
3270
+ toast(T('Trae 接口:') + (r.enabled ? T('已开启') : T('已关闭'))
3271
+ + (r.enabled && r.lan ? T(',局域网开放') + (r.allow && r.allow.length ? T(',白名单 ') + r.allow.join(T('、')) : '') + T(';绑定改动重启后生效') : ''));
2845
3272
  } catch (err) { toast(err.message, false); }
2846
3273
  });
2847
3274
 
2848
- // ZCode 网络出口:顺序 + 代理地址保存
3275
+ // 网络出口(全局,面板设置里):顺序 + 代理地址保存;代理只回填到 placeholder(脱敏),留空即清除
2849
3276
  $('znet-ok').addEventListener('click', async () => {
2850
3277
  try {
2851
3278
  const body = { proxyFirst: $('znet-proxy').checked, proxyUrl: $('znet-url').value.trim() };
2852
3279
  const r = await api('/api/zcode/net', { method: 'PUT', json: body });
3280
+ state.zProxyFirst = r.proxyFirst === true;
2853
3281
  await loadZNet();
2854
- renderStatus(); render();
2855
- closeModal('m-znet');
2856
- toast('ZCode 出口:' + (r.proxyFirst ? '代理优先(直连兜底)' : '直连优先(代理兜底)') + (body.proxyUrl === '' ? ',代理已清除' : ''));
3282
+ $('znet-url').value = '';
3283
+ $('znet-url').placeholder = state.zProxyUrl || (state.zHasEnvProxy ? T('跟随环境变量 HTTPS_PROXY') : 'http://192.168.31.101:7890');
3284
+ $('znet-state').textContent = T('已保存:') + (state.zProxyFirst === true ? T('代理优先') : T('直连优先'))
3285
+ + (state.zProxyUrl ? T(' · 代理 ') + state.zProxyUrl : '');
3286
+ toast(T('网络出口:') + (r.proxyFirst ? T('代理优先(直连兜底)') : T('直连优先(代理兜底)')) + (body.proxyUrl === '' ? T(',代理已清除') : T(',代理已保存')));
2857
3287
  } catch (err) { toast(err.message, false); }
2858
3288
  });
2859
- document.querySelectorAll('.mask').forEach((m) => {
3289
+ document.querySelectorAll('.mask, .setpop').forEach((m) => {
2860
3290
  m.addEventListener('mousedown', (e) => { if (e.target === m) closeModal(m.id); });
2861
3291
  m.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => closeModal(m.id)));
2862
3292
  });
2863
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') document.querySelectorAll('.mask.show').forEach((m) => closeModal(m.id)); });
3293
+ document.addEventListener('keydown', (e) => {
3294
+ if (e.key !== 'Escape') return;
3295
+ const pop = document.getElementById('hmenu-pop');
3296
+ if (pop && pop.classList.contains('show')) { pop.classList.remove('show'); return; } // ESC 先收下拉
3297
+ document.querySelectorAll('.mask.show, .setpop.show').forEach((m) => closeModal(m.id));
3298
+ });
2864
3299
  document.querySelectorAll('#m-add .tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
2865
3300
  function switchTab(name) {
2866
3301
  document.querySelectorAll('#m-add .tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
@@ -2883,7 +3318,7 @@ function openAdd(product) {
2883
3318
  // 各产品支持的添加方式:MiniMax 三种都支持(浏览器登录 = 官方设备码授权,独立凭据链不与本机客户端互斥)
2884
3319
  const tabShown = { local: true, device: product !== 'mirasim' && product !== 'catpaw' && product !== 'trae', token: product !== 'zcode' && product !== 'mirasim' && product !== 'catpaw' && product !== 'trae' };
2885
3320
  document.querySelectorAll('#m-add .tab').forEach((t) => { t.style.display = tabShown[t.dataset.tab] ? '' : 'none'; });
2886
- $('local-hint').textContent = LOCAL_HINT[state.addProduct];
3321
+ $('local-hint').textContent = T(LOCAL_HINT[state.addProduct]);
2887
3322
  let first = null;
2888
3323
  for (const o of $('f-provider').options) {
2889
3324
  const ok = !product || provOf(o.value).prod === product;
@@ -2899,8 +3334,8 @@ function openAdd(product) {
2899
3334
  if (ok && !firstDev) firstDev = o.value;
2900
3335
  }
2901
3336
  if (firstDev) $('dev-provider').value = firstDev;
2902
- $('dev-hint').textContent = (hasIncognito() ? '在内置隐私窗口里登录(不带出系统浏览器已登录的账号),' : '在浏览器里登录并授权,')
2903
- + '完成后账号自动添加。' + (product === 'qoder' ? '' : 'WorkBuddy / ZCode 用浏览器登录的账号同样可以一键切换客户端。');
3337
+ $('dev-hint').textContent = (hasIncognito() ? T('在内置隐私窗口里登录(不带出系统浏览器已登录的账号),') : T('在浏览器里登录并授权,'))
3338
+ + T('完成后账号自动添加。') + (product === 'qoder' ? '' : T('WorkBuddy / ZCode 用浏览器登录的账号同样可以一键切换客户端。'));
2904
3339
  openModal('m-add');
2905
3340
  switchTab('local');
2906
3341
  localScan();
@@ -2909,46 +3344,46 @@ function openAdd(product) {
2909
3344
  // 本机导入
2910
3345
  async function localScan() {
2911
3346
  const box = $('scan-result');
2912
- $('scan-status').textContent = '扫描中(最多约 10 秒)…';
2913
- box.innerHTML = '<div class="hint">正在读取本机客户端凭据(Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax)…</div>';
3347
+ $('scan-status').textContent = T('扫描中(最多约 10 秒)…');
3348
+ box.innerHTML = T('<div class="hint">正在读取本机客户端凭据(Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax)…</div>');
2914
3349
  try {
2915
3350
  const r = await api('/api/local/scan', { method: 'POST' });
2916
3351
  $('scan-status').textContent = '';
2917
3352
  let html = '';
2918
3353
  const cands = r.candidates.filter((c) => !state.addProduct || (c.provider ? provOf(c.provider).prod : 'qoder') === state.addProduct);
2919
3354
  for (const c of cands) {
2920
- const title = c.kind === 'app' ? esc(c.name || c.email || c.phone || '(未命名)') : '<span style="font-family:monospace">' + esc(c.tokenMasked) + '</span>';
2921
- const prov = (c.provider ? provBadges(c.provider) : '<span class="badge">' + esc(c.kind) + '</span>') + (c.current ? '<span class="pill cur">当前登录</span>' : '');
3355
+ const title = c.kind === 'app' ? esc(c.name || c.email || c.phone || T('(未命名)')) : '<span style="font-family:monospace">' + esc(c.tokenMasked) + '</span>';
3356
+ const prov = (c.provider ? provBadges(c.provider) : '<span class="badge">' + esc(c.kind) + '</span>') + (c.current ? T('<span class="pill cur">当前登录</span>') : '');
2922
3357
  const sub = c.kind === 'app'
2923
- ? esc(c.source) + (c.email ? ' · ' + esc(mask(c.email)) : c.phone ? ' · ' + esc(c.phone) : '') + (c.expiresAt ? ' · 有效期至 ' + esc(fmtTime(c.expiresAt)) : '')
2924
- : esc(c.source) + ' · ' + esc(String(c.file || '').split(/[\\/]/).pop()) + '(' + c.hits + ' 处)';
2925
- const btns = c.imported ? '<span class="pill ok">已导入</span>' + (c.provider ? '<button class="btn" data-cid="' + esc(c.id) + '" title="用本机凭据更新 token / 有效期 / 邮箱">同步信息</button>' : '')
2926
- : c.provider ? '<button class="btn primary" data-cid="' + esc(c.id) + '">导入</button>'
2927
- : '<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>';
3358
+ ? esc(c.source) + (c.email ? ' · ' + esc(mask(c.email)) : c.phone ? ' · ' + esc(c.phone) : '') + (c.expiresAt ? T(' · 有效期至 ') + esc(fmtTime(c.expiresAt)) : '')
3359
+ : esc(c.source) + ' · ' + esc(String(c.file || '').split(/[\\/]/).pop()) + T('(') + c.hits + T(' 处)');
3360
+ const btns = c.imported ? T('<span class="pill ok">已导入</span>') + (c.provider ? '<button class="btn" data-cid="' + esc(c.id) + T('" title="用本机凭据更新 token / 有效期 / 邮箱">同步信息</button>') : '')
3361
+ : c.provider ? '<button class="btn primary" data-cid="' + esc(c.id) + T('">导入</button>')
3362
+ : '<button class="btn" data-cid="' + esc(c.id) + T('" data-prov="qoder">导入·国际</button><button class="btn" data-cid="') + esc(c.id) + T('" data-prov="qoder-cn">导入·国内</button>');
2928
3363
  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>';
2929
3364
  }
2930
3365
  for (const e of r.errors || []) {
2931
- html += '<div class="hint" style="color:var(--err)">读取失败:' + esc(e.file) + ':' + esc(e.error) + '</div>';
3366
+ html += T('<div class="hint" style="color:var(--err)">读取失败:') + esc(e.file) + T(':') + esc(e.error) + '</div>';
2932
3367
  }
2933
3368
  if (!cands.length) {
2934
- const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax';
2935
- const alt = state.addProduct === 'zcode' || state.addProduct === 'mirasim' || state.addProduct === 'catpaw' || state.addProduct === 'trae' ? '' : ',或改用「浏览器登录」「粘贴 Token」';
2936
- html += '<div class="hint">未发现已登录的 ' + where + ' 账号。请先在客户端登录' + alt + '。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录、mirasim 数据目录、妙手凭据目录、Trae 数据目录、MiniMax 数据目录'
2937
- + (r.scannedDirs && r.scannedDirs.length ? '、' + r.scannedDirs.map(esc).join('、') : '') + '</div>';
3369
+ const where = state.addProduct ? prodLabel(state.addProduct) : T('Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax');
3370
+ const alt = state.addProduct === 'zcode' || state.addProduct === 'mirasim' || state.addProduct === 'catpaw' || state.addProduct === 'trae' ? '' : T(',或改用「浏览器登录」「粘贴 Token」');
3371
+ html += T('<div class="hint">未发现已登录的 ') + where + T(' 账号。请先在客户端登录') + alt + T('。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录、mirasim 数据目录、妙手凭据目录、Trae 数据目录、MiniMax 数据目录')
3372
+ + (r.scannedDirs && r.scannedDirs.length ? T('、') + r.scannedDirs.map(esc).join(T('、')) : '') + '</div>';
2938
3373
  }
2939
3374
  box.innerHTML = html;
2940
3375
  box.querySelectorAll('button[data-cid]').forEach((b) => b.addEventListener('click', async () => {
2941
3376
  b.disabled = true;
2942
3377
  try {
2943
3378
  const d = await api('/api/local/import', { json: { candidateId: b.dataset.cid, provider: b.dataset.prov } });
2944
- toast((d.updated ? '已更新:' : '已导入:') + (d.account.name || d.account.id));
3379
+ toast((d.updated ? T('已更新:') : T('已导入:')) + (d.account.name || d.account.id));
2945
3380
  await loadAccounts();
2946
3381
  localScan();
2947
3382
  } catch (e) { toast(e.message, false); b.disabled = false; }
2948
3383
  }));
2949
3384
  } catch (e) {
2950
3385
  $('scan-status').textContent = '';
2951
- box.innerHTML = '<div class="hint" style="color:var(--err)">扫描失败:' + esc(e.message) + '</div>';
3386
+ box.innerHTML = T('<div class="hint" style="color:var(--err)">扫描失败:') + esc(e.message) + '</div>';
2952
3387
  }
2953
3388
  }
2954
3389
 
@@ -2972,13 +3407,13 @@ async function deviceLogin() {
2972
3407
  const flow = await api('/api/auth/device/start', { json: { provider } });
2973
3408
  $('device-box').style.display = '';
2974
3409
  $('dev-cancel').style.display = '';
2975
- const where = hasIncognito() ? '已在隐私窗口打开登录页' : '请在浏览器打开下方链接';
2976
- $('dev-status').textContent = where + ',登录并授权(' + Math.floor(flow.expiresIn / 60) + ' 分钟内有效)';
3410
+ const where = hasIncognito() ? T('已在隐私窗口打开登录页') : T('请在浏览器打开下方链接');
3411
+ $('dev-status').textContent = where + T(',登录并授权(') + Math.floor(flow.expiresIn / 60) + T(' 分钟内有效)');
2977
3412
  $('dev-link').href = flow.url;
2978
3413
  $('dev-link').textContent = flow.url;
2979
3414
  openAuth(flow.url);
2980
3415
  pollDevice(flow.sessionId, 0, ++devGen);
2981
- } catch (e) { toast('发起授权失败:' + e.message, false); }
3416
+ } catch (e) { toast(T('发起授权失败:') + e.message, false); }
2982
3417
  }
2983
3418
  async function pollDevice(sessionId, n, gen) {
2984
3419
  if (gen !== devGen) return;
@@ -2986,20 +3421,24 @@ async function pollDevice(sessionId, n, gen) {
2986
3421
  const r = await api('/api/auth/device/poll', { json: { sessionId } });
2987
3422
  if (gen !== devGen) return;
2988
3423
  if (r.status === 'pending') {
2989
- if (n > 450) return deviceStop('授权超时,请重试');
2990
- $('dev-status').textContent = '等待授权中…(' + n + ')';
3424
+ if (n > 450) return deviceStop(T('授权超时,请重试'));
3425
+ $('dev-status').textContent = T('等待授权中…(') + n + T(')');
2991
3426
  devTimer = setTimeout(() => pollDevice(sessionId, n + 1, gen), 2000);
2992
3427
  } else if (r.status === 'ok') {
2993
- deviceStop('✓ 登录成功:' + (r.account.name || r.account.id) + (r.already ? '(账号已存在,已更新登录凭据)' : ''));
2994
- toast(r.already ? '已更新「' + (r.account.name || r.account.id) + '」的登录凭据' : '已添加:' + (r.account.name || r.account.id));
3428
+ deviceStop(T('✓ 登录成功:') + (r.account.name || r.account.id) + (r.already ? T('(账号已存在,已更新登录凭据)') : ''));
3429
+ toast(r.already ? T('已更新「') + (r.account.name || r.account.id) + T('」的登录凭据') : T('已添加:') + (r.account.name || r.account.id));
2995
3430
  if (r.account.id) delete state.quotas[r.account.id]; // 旧 token 的积分查询结果(可能是失败)作废,马上重查
2996
3431
  await loadStatus(); await loadAccounts();
3432
+ // 网页会话 Cookie 是登录窗「关闭」时才收割绑定的(比这里的成功提示晚好几秒)——
3433
+ // 补两轮轻量刷新,「缺网页会话」徽章随绑定落地即时消失,不必等 30s 周期轮。
3434
+ setTimeout(refreshAccountList, 12000);
3435
+ setTimeout(refreshAccountList, 30000);
2997
3436
  } else if (r.status === 'expired') {
2998
- deviceStop('会话已过期,请重新发起');
3437
+ deviceStop(T('会话已过期,请重新发起'));
2999
3438
  } else {
3000
- deviceStop('授权失败:' + (r.error || r.status));
3439
+ deviceStop(T('授权失败:') + (r.error || r.status));
3001
3440
  }
3002
- } catch (e) { if (gen === devGen) deviceStop('轮询异常:' + e.message); }
3441
+ } catch (e) { if (gen === devGen) deviceStop(T('轮询异常:') + e.message); }
3003
3442
  }
3004
3443
  function deviceStop(msg) {
3005
3444
  devGen++;
@@ -3011,11 +3450,11 @@ function deviceStop(msg) {
3011
3450
  // 粘贴 token
3012
3451
  async function addToken() {
3013
3452
  const token = $('f-token').value.trim();
3014
- if (!token) return toast('请粘贴 token', false);
3453
+ if (!token) return toast(T('请粘贴 token'), false);
3015
3454
  const b = $('btn-add-token'); b.disabled = true;
3016
3455
  try {
3017
3456
  const d = await api('/api/accounts', { json: { provider: $('f-provider').value, name: $('f-name').value.trim(), token } });
3018
- toast('已添加:' + (d.account.name || d.account.id) + (d.account.verified === false ? '(token 校验失败,请检查)' : ''), d.account.verified !== false);
3457
+ toast(T('已添加:') + (d.account.name || d.account.id) + (d.account.verified === false ? T('(token 校验失败,请检查)') : ''), d.account.verified !== false);
3019
3458
  $('f-token').value = ''; $('f-name').value = '';
3020
3459
  closeModal('m-add');
3021
3460
  await loadAccounts();
@@ -3031,10 +3470,11 @@ function qoderWebBadge(a) {
3031
3470
  const at = a.qoderWebCapturedAt;
3032
3471
  if (at && Date.now() - new Date(at).getTime() <= 7 * 86400000) return '';
3033
3472
  const stale = Boolean(at);
3473
+ const proxyHint = a.provider === 'qoder' ? T('(国际版按 IP 识别地区:需先在「面板设置 → 网络出口」配置出口代理)') : '';
3034
3474
  const tip = stale
3035
- ? '网页会话已超过 7 天(' + fmtTime(at).slice(0, 10) + ' 抓取),可能已过期:导出到 10Router 后「套餐内 Credits」可能缺失,用「浏览器登录」重新登录一次 Qoder 即可补上'
3036
- : '缺少 Qoder 网页会话(Cookie):导出到 10Router 后「套餐内 Credits」可能缺失,用「浏览器登录」登录一次 Qoder 即可自动抓取';
3037
- return '<span class="badge web web-warn" title="' + esc(tip) + '">' + (stale ? '网页会话过期' : '缺网页会话') + '</span>';
3475
+ ? T('网页会话已超过 7 天(') + fmtTime(at).slice(0, 10) + T(' 抓取),可能已过期:导出到 10Router 后「套餐内 Credits」可能缺失,用「浏览器登录」重新登录一次 Qoder 即可补上') + proxyHint
3476
+ : T('缺少 Qoder 网页会话(Cookie):导出到 10Router 后「套餐内 Credits」可能缺失,用「浏览器登录」登录一次 Qoder 即可自动抓取') + proxyHint;
3477
+ return '<span class="badge web web-warn" title="' + esc(tip) + '">' + (stale ? T('网页会话过期') : T('缺网页会话')) + '</span>';
3038
3478
  }
3039
3479
 
3040
3480
  // 导出弹窗里的网页会话提示(10Router issue #44):
@@ -3056,12 +3496,12 @@ function qoderWebSessionNote() {
3056
3496
  else if (Date.now() - new Date(at).getTime() > 7 * 86400000) stale.push(a);
3057
3497
  }
3058
3498
  if (!missing.length && !stale.length) return '';
3059
- const names = (arr) => arr.map((x) => x.name || x.email || x.uid || x.id).join('、');
3499
+ const names = (arr) => arr.map((x) => x.name || x.email || x.uid || x.id).join(T('、'));
3060
3500
  const bits = [];
3061
- if (missing.length) bits.push('没有网页会话:' + names(missing));
3062
- if (stale.length) bits.push('会话已超过 7 天:' + names(stale));
3063
- return '10router 的「套餐内 Credits」逐资源包明细依赖 Qoder 网页会话(' + bits.join(';')
3064
- + ')。这些账号过去后套餐积分可能显示缺失——在「浏览器登录」重新登录一次 Qoder 再导出即可补上。';
3501
+ if (missing.length) bits.push(T('没有网页会话:') + names(missing));
3502
+ if (stale.length) bits.push(T('会话已超过 7 天:') + names(stale));
3503
+ return T('10router 的「套餐内 Credits」逐资源包明细依赖 Qoder 网页会话(') + bits.join(T(';'))
3504
+ + T(')。这些账号过去后套餐积分可能显示缺失——在「浏览器登录」重新登录一次 Qoder 再导出即可补上。');
3065
3505
  }
3066
3506
  function refreshExportNote() {
3067
3507
  const msg = qoderWebSessionNote();
@@ -3073,8 +3513,8 @@ function refreshExportNote() {
3073
3513
  // 导出
3074
3514
  async function doExport() {
3075
3515
  const password = $('ex-password').value;
3076
- if (!password.trim()) return toast('导出必须设置加密口令', false);
3077
- if (password.length < 4) return toast('口令至少 4 位', false);
3516
+ if (!password.trim()) return toast(T('导出必须设置加密口令'), false);
3517
+ if (password.length < 4) return toast(T('口令至少 4 位'), false);
3078
3518
  try {
3079
3519
  const sel = $('ex-provider').value;
3080
3520
  const scope = sel.startsWith('p:') ? { product: sel.slice(2) } : { provider: sel || undefined };
@@ -3087,7 +3527,7 @@ async function doExport() {
3087
3527
  URL.revokeObjectURL(a.href);
3088
3528
  $('ex-password').value = '';
3089
3529
  closeModal('m-export');
3090
- toast('已导出加密文件(可在 10router 或本面板导入)');
3530
+ toast(T('已导出加密文件(可在 10router 或本面板导入)'));
3091
3531
  } catch (e) { toast(e.message, false); }
3092
3532
  }
3093
3533
 
@@ -3098,10 +3538,10 @@ $('file-input').addEventListener('change', async () => {
3098
3538
  const f = $('file-input').files[0];
3099
3539
  if (!f) return;
3100
3540
  let data;
3101
- try { data = JSON.parse(await f.text()); } catch (e) { return toast('文件不是合法 JSON', false); }
3541
+ try { data = JSON.parse(await f.text()); } catch (e) { return toast(T('文件不是合法 JSON'), false); }
3102
3542
  pendingImport = data;
3103
3543
  if (data && data.format === '10router-oauth-secure-v1') {
3104
- $('pass-hint').textContent = '「' + f.name + '」已加密(CreditDaddy / 10router 迁移格式),请输入导出时设置的口令。';
3544
+ $('pass-hint').textContent = T('「') + f.name + T('」已加密(CreditDaddy / 10router 迁移格式),请输入导出时设置的口令。');
3105
3545
  $('pass-input').value = '';
3106
3546
  openModal('m-pass');
3107
3547
  setTimeout(() => $('pass-input').focus(), 50);
@@ -3113,7 +3553,7 @@ async function submitImport(password) {
3113
3553
  try {
3114
3554
  const d = await api('/api/import', { json: { data: pendingImport, password } });
3115
3555
  closeModal('m-pass');
3116
- toast('导入完成(' + (d.source === '10router' ? '10router' : 'CreditDaddy') + '):新增 ' + d.added + ',续期 ' + d.updated + ',跳过 ' + d.skipped);
3556
+ toast(T('导入完成(') + (d.source === '10router' ? '10router' : 'CreditDaddy') + T('):新增 ') + d.added + T(',续期 ') + d.updated + T(',跳过 ') + d.skipped);
3117
3557
  pendingImport = null;
3118
3558
  await loadAccounts();
3119
3559
  } catch (e) {
@@ -3124,7 +3564,7 @@ async function submitImport(password) {
3124
3564
 
3125
3565
  // ── 绑定 ──
3126
3566
  // 全局组件开关:收起 Qoder / WorkBuddy / ZCode / 10Router 的标签页与仪表盘对应卡片
3127
- $('btn-comp').innerHTML = I.tune;
3567
+ $('btn-comp').innerHTML = I.settings;
3128
3568
 
3129
3569
  // ── 卡片拖拽排序(产品面板 + 10Router):手动顺序优先级最高,落库 localStorage ──
3130
3570
  const dragState = { kind: null, id: null, vendor: null };
@@ -3241,13 +3681,13 @@ function applyComponents() {
3241
3681
  // 右上角面板设置:访问密码 + 组件显隐与排序 + 每产品账号排序(默认积分多者靠前,可手动固定)
3242
3682
  function secStatusHtml() {
3243
3683
  if (!state.sec.loaded) return '<span class="sec-status">…</span>';
3244
- return state.sec.enabled ? '<span class="sec-status on">已开启</span>' : '<span class="sec-status">未开启</span>';
3684
+ return state.sec.enabled ? T('<span class="sec-status on">已开启</span>') : T('<span class="sec-status">未开启</span>');
3245
3685
  }
3246
3686
  function renderSecActions() {
3247
3687
  if (!state.sec.loaded) return '';
3248
3688
  return state.sec.enabled
3249
- ? '<button class="btn" data-sec-action="change">修改密码</button><button class="btn" data-sec-action="disable">关闭</button>'
3250
- : '<button class="btn primary" data-sec-action="enable">开启面板密码</button>';
3689
+ ? T('<button class="btn" data-sec-action="change">修改密码</button><button class="btn" data-sec-action="disable">关闭</button>')
3690
+ : T('<button class="btn primary" data-sec-action="enable">开启面板密码</button>');
3251
3691
  }
3252
3692
  async function applyPanelKey(next) {
3253
3693
  setKey(next);
@@ -3255,86 +3695,87 @@ async function applyPanelKey(next) {
3255
3695
  }
3256
3696
  async function doSecAction(action) {
3257
3697
  if (action === 'enable' || action === 'change') {
3258
- const pw = await uiPrompt(action === 'enable' ? '设置面板访问密码' : '输入新密码', '', {
3259
- type: 'password', okText: action === 'enable' ? '开启' : '保存',
3260
- placeholder: '至少 4 位,不能包含空格',
3261
- message: '设置后每次打开面板都需要输入此密码。导出账号的加密口令是独立设置,始终必填。',
3698
+ const pw = await uiPrompt(action === 'enable' ? T('设置面板访问密码') : T('输入新密码'), '', {
3699
+ type: 'password', okText: action === 'enable' ? T('开启') : T('保存'),
3700
+ placeholder: T('至少 4 位,不能包含空格'),
3701
+ message: T('设置后每次打开面板都需要输入此密码。导出账号的加密口令是独立设置,始终必填。'),
3262
3702
  });
3263
3703
  if (!pw) return;
3264
3704
  const val = String(pw).trim();
3265
- if (val.length < 4) return toast('面板访问密码至少 4 位', false);
3266
- if (/\s/.test(val)) return toast('面板访问密码不能包含空格', false);
3705
+ if (val.length < 4) return toast(T('面板访问密码至少 4 位'), false);
3706
+ if (/\s/.test(val)) return toast(T('面板访问密码不能包含空格'), false);
3267
3707
  try {
3268
3708
  const r = await api('/api/settings', { method: 'PUT', json: { panelKey: val } });
3269
3709
  await applyPanelKey(val);
3270
3710
  renderCompMenu();
3271
- toast(r.panelKeyEnabled ? '已设置面板访问密码,下次打开面板需要输入' : '已保存', true);
3711
+ toast(r.panelKeyEnabled ? T('已设置面板访问密码,下次打开面板需要输入') : T('已保存'), true);
3272
3712
  } catch (e) { toast(e.message, false); }
3273
3713
  return;
3274
3714
  }
3275
3715
  if (action === 'disable') {
3276
3716
  const ok = await uiConfirm(
3277
- '关闭后打开面板不再需要密码(局域网 / 公网访问将无保护,仅建议本机使用)。账号导出仍必须设置加密口令。\n\n确定关闭面板访问密码吗?',
3278
- '关闭面板访问密码', '关闭');
3717
+ T('关闭后打开面板不再需要密码(局域网 / 公网访问将无保护,仅建议本机使用)。账号导出仍必须设置加密口令。\n\n确定关闭面板访问密码吗?'),
3718
+ T('关闭面板访问密码'), T('关闭'));
3279
3719
  if (!ok) return;
3280
3720
  try {
3281
3721
  const r = await api('/api/settings', { method: 'PUT', json: { disable: true } });
3282
3722
  await loadSettings();
3283
3723
  await applyPanelKey(r.panelKeyEnabled ? getKey() : '');
3284
3724
  renderCompMenu();
3285
- toast(r.panelKeyEnabled ? '该部署环境(fnOS / 环境变量)强制启用访问密码,无法在面板内关闭' : '已关闭面板访问密码', true);
3725
+ toast(r.panelKeyEnabled ? T('该部署环境(fnOS / 环境变量)强制启用访问密码,无法在面板内关闭') : T('已关闭面板访问密码'), true);
3286
3726
  } catch (e) { toast(e.message, false); }
3287
3727
  }
3288
3728
  }
3289
3729
  function renderCompMenu() {
3290
- const compRows = state.compOrder.map((id) => {
3730
+ // 组件顺序横向 chips:一行放下全部产品,勾选 = 显隐,拖动 = 前后移(顺序与标签栏同步)
3731
+ const compChips = state.compOrder.map((id) => {
3291
3732
  const c = COMPONENTS.find((x) => x.id === id);
3292
3733
  if (!c) return '';
3293
3734
  const vis = compVisible(id);
3294
- return '<div class="comp-row' + (vis ? '' : ' off') + '">'
3295
- + '<label class="check"><input type="checkbox" data-comp="' + id + '"' + (vis ? ' checked' : '') + '>' + esc(c.label) + '</label>'
3296
- + '<span class="comp-ops">'
3297
- + '<button class="icon-btn mini" data-move="up" data-comp="' + id + '" title="上移(仪表盘与标签栏同步)">' + I.chevUp + '</button>'
3298
- + '<button class="icon-btn mini" data-move="down" data-comp="' + id + '" title="下移(仪表盘与标签栏同步)">' + I.chevDown + '</button>'
3299
- + '</span></div>';
3735
+ return '<span class="comp-chip' + (vis ? '' : ' off') + '" draggable="true" data-drag-comp="' + id + '" data-comp-toggle="' + id + '"'
3736
+ + ' title="' + T('点击切换显隐') + ' · ' + T('按住拖动调整顺序') + '">'
3737
+ + T('<span class="drag-grip">') + I.grip + '</span>'
3738
+ + '<span class="sort-name">' + esc(T(c.label)) + '</span>'
3739
+ + '</span>';
3300
3740
  }).join('');
3301
3741
  const sortSections = PRODUCTS.filter((p) => compVisible(p.id) && ofProduct(p.id).length).map((p) => {
3302
3742
  const manual = state.cardOrder[p.id];
3303
- const rows = sortedAccounts(p.id, ofProduct(p.id)).map((a) => '<div class="sort-row" data-aid="' + esc(a.id) + '">'
3304
- + '<span class="sort-name" title="' + esc(a.name || a.email || a.uid || a.id) + '">' + esc(a.name || a.email || ('#' + a.id.slice(0, 6))) + '</span>'
3305
- + '<span class="comp-ops">'
3306
- + '<button class="icon-btn mini" data-acc-move="up" data-prod="' + p.id + '" data-aid="' + esc(a.id) + '" title="上移">' + I.chevUp + '</button>'
3307
- + '<button class="icon-btn mini" data-acc-move="down" data-prod="' + p.id + '" data-aid="' + esc(a.id) + '" title="下移">' + I.chevDown + '</button>'
3308
- + '</span></div>').join('');
3309
- return '<details class="sort-sec"' + (manual && manual.length ? ' open' : '') + '><summary>' + esc(p.label)
3310
- + (manual && manual.length ? ' <span class="badge">手动排序</span>' : '')
3743
+ const rows = sortedAccounts(p.id, ofProduct(p.id)).map((a) => '<span class="comp-chip" draggable="true" data-drag-acc="' + esc(a.id) + '" data-prod="' + p.id + '" title="' + esc(a.name || a.email || a.uid || a.id) + '">'
3744
+ + T('<span class="drag-grip" title="按住拖动调整顺序">') + I.grip + '</span>'
3745
+ + '<span class="sort-name">' + esc(a.name || a.email || ('#' + a.id.slice(0, 6))) + '</span>'
3746
+ + '</span>').join('');
3747
+ return '<details class="sort-sec"' + (manual && manual.length ? ' open' : '') + '><summary>' + esc(T(p.label))
3748
+ + (manual && manual.length ? T(' <span class="badge">手动排序</span>') : '')
3311
3749
  + '<span class="dim" style="font-size:11px">' + ofProduct(p.id).length + '</span></summary>'
3312
- + '<div class="sort-hint">默认按剩余积分从多到少;拖拽卡片或用上下按钮固定顺序。</div>'
3313
- + rows
3314
- + (manual && manual.length ? '<button class="btn" data-acc-reset="' + p.id + '" style="margin-top:6px;width:100%">恢复默认排序</button>' : '')
3750
+ + T('<div class="sort-hint">默认按剩余积分从多到少;直接按住拖动卡片调整顺序。</div>')
3751
+ + '<div class="comp-chips">' + rows + '</div>'
3752
+ + (manual && manual.length ? '<button class="btn" data-acc-reset="' + p.id + T('" style="margin-top:6px">恢复默认排序</button>') : '')
3315
3753
  + '</details>';
3316
3754
  }).join('');
3755
+ $('sec-block').innerHTML =
3756
+ secStatusHtml()
3757
+ + T('<div class="hint">开启后每次打开面板都需要输入密码;密码可随时在此修改或关闭。')
3758
+ + (state.sec.enabled ? '' : T('账号导出不受影响(导出口令始终必填)。')) + '</div>'
3759
+ + '<div class="row">' + renderSecActions() + '</div>';
3317
3760
  $('comp-menu').innerHTML =
3318
- '<div class="comp-menu-title">面板访问密码 ' + secStatusHtml() + '</div>'
3319
- + '<div class="hint">开启后每次打开面板都需要输入密码;密码可随时在此修改或关闭。'
3320
- + (state.sec.enabled ? '' : '账号导出不受影响(导出口令始终必填)。') + '</div>'
3321
- + '<div class="row">' + renderSecActions() + '</div>'
3322
- + '<div class="comp-menu-title">自动签到</div>'
3761
+ T('<div class="comp-menu-title">自动签到</div>')
3323
3762
  + '<div class="hint">' + autoCheckinHint() + '</div>'
3324
- + '<div class="comp-menu-title">组件顺序(仪表盘与标签栏同步)</div>' + compRows
3325
- + '<div class="comp-menu-title">账号卡片排序</div>'
3326
- + (sortSections || '<div class="sort-hint">暂无账号</div>');
3763
+ + T('<div class="comp-menu-title">组件顺序(仪表盘与标签栏同步)</div>')
3764
+ + T('<div class="sort-hint">点按切换显隐(置灰 = 隐藏);按住拖动调整顺序。</div>')
3765
+ + '<div class="comp-chips">' + compChips + '</div>'
3766
+ + T('<div class="comp-menu-title">账号卡片排序</div>')
3767
+ + (sortSections || T('<div class="sort-hint">暂无账号</div>'));
3327
3768
  }
3328
3769
 
3329
3770
  /** 自动签到口径说明:调度周期 + 各产品业务日刷新时点 */
3330
3771
  function autoCheckinHint() {
3331
3772
  const sch = state.status && state.status.scheduler;
3332
- const next = sch && sch.nextTickAt ? ',下次执行 ' + esc(String(sch.nextTickAt).slice(11, 16)) : '';
3773
+ const next = sch && sch.nextTickAt ? T(',下次执行 ') + esc(String(sch.nextTickAt).slice(11, 16)) : '';
3333
3774
  const zc = sch && sch.zcode;
3334
3775
  const zcText = zc && zc.enabled
3335
- ? 'ZCode 自动领取运行中(每 ' + (zc.intervalMin || 2) + ' 分钟' + (zc.autoOffAt ? ',约 ' + Math.max(1, Math.ceil((new Date(zc.autoOffAt).getTime() - Date.now()) / 60000)) + ' 分钟后自动关闭' : ',一直运行') + '),周期可在 ZCode 页「自动领取计划」里改'
3336
- : 'ZCode 活动领取默认关闭,可在 ZCode 页「自动领取计划」里开启并配置轮询周期';
3337
- return '守护进程每 ~2 小时自动扫描执行一轮全部账号' + next + '。各产品「业务日」刷新时点(UTC+8):<b>Qoder 每日 10:00</b>(每台设备每日限领一个国际版账号)、<b>WorkBuddy 每日 00:00</b>,其余产品按 00:00;已领成功的账号按对应业务日记忆跳过,跨过刷新点后当天照常重试。' + esc(zcText) + '。';
3776
+ ? T('ZCode 自动领取运行中(每 ') + (zc.intervalMin || 2) + T(' 分钟') + (zc.autoOffAt ? T(',约 ') + Math.max(1, Math.ceil((new Date(zc.autoOffAt).getTime() - Date.now()) / 60000)) + T(' 分钟后自动关闭') : T(',一直运行')) + T('),周期可在 ZCode 页「自动领取计划」里改')
3777
+ : T('ZCode 活动领取默认关闭,可在 ZCode 页「自动领取计划」里开启并配置轮询周期');
3778
+ return T('守护进程每 ~2 小时自动扫描执行一轮全部账号') + next + T('。各产品「业务日」刷新时点(UTC+8):<b>Qoder 每日 10:00</b>(每台设备每日限领一个国际版账号)、<b>WorkBuddy 每日 00:00</b>,其余产品按 00:00;已领成功的账号按对应业务日记忆跳过,跨过刷新点后当天照常重试。') + esc(zcText) + T('。');
3338
3779
  }
3339
3780
 
3340
3781
  /** 在手动排序列表里移动一个 id;列表不存在时先按当前生效顺序物化一份 */
@@ -3350,49 +3791,45 @@ function moveAccountId(prod, aid, dir) {
3350
3791
  saveCardOrder();
3351
3792
  }
3352
3793
  // 右上角齿轮 → 面板设置弹窗(访问密码 + 组件显隐/排序 + 账号卡片排序)
3794
+ // 面板设置分页签:通用 / 网络 / 组件与排序(内容越来越长的解法——分 pane 而不是一条流水账)
3795
+ $('set-tabs').addEventListener('click', (e) => {
3796
+ const b = e.target.closest('button[data-set-tab]');
3797
+ if (!b) return;
3798
+ document.querySelectorAll('#set-tabs .tab').forEach((x) => x.classList.toggle('active', x === b));
3799
+ document.querySelectorAll('#m-settings .pane[data-set-pane]').forEach((p) => p.classList.toggle('active', p.dataset.setPane === b.dataset.setTab));
3800
+ });
3353
3801
  $('btn-comp').addEventListener('click', async () => {
3802
+ // 已打开再点齿轮 = 关闭(普通弹出层的开合直觉)
3803
+ if ($('m-settings').classList.contains('show')) { closeModal('m-settings'); return; }
3354
3804
  renderCompMenu();
3355
3805
  openModal('m-settings');
3806
+ // 每次打开都回到「通用」页签
3807
+ document.querySelectorAll('#set-tabs .tab').forEach((x) => x.classList.toggle('active', x.dataset.setTab === 'net'));
3808
+ document.querySelectorAll('#m-settings .pane[data-set-pane]').forEach((p) => p.classList.toggle('active', p.dataset.setPane === 'net'));
3356
3809
  await loadSettings();
3357
3810
  renderCompMenu();
3358
- // 桌面版才有检查更新入口(走 preload 桥到主进程的更新状态机)
3359
- if (updBridge) {
3360
- $('upd-entry').style.display = '';
3361
- try { const s = await updBridge.state(); updState = s; $('upd-ver').textContent = '当前 v' + (s.current || '?'); } catch {}
3362
- }
3363
- });
3364
- $('comp-menu').addEventListener('change', (e) => {
3365
- const c = e.target.dataset && e.target.dataset.comp;
3366
- if (!c) return;
3367
- if (e.target.checked) state.hidden.delete(c); else state.hidden.add(c);
3368
- saveHiddenComps();
3369
- applyComponents();
3370
- renderCompMenu();
3811
+ fillAutoCheckin();
3812
+ $('cfg-logs-display').value = logsDisplayDefault();
3813
+ $('cfg-wb-activity').checked = wbActOn();
3814
+ // 网络出口(全局):预填当前顺序与脱敏代理地址
3815
+ await loadZNet();
3816
+ (state.zProxyFirst === true ? $('znet-proxy') : $('znet-direct')).checked = true;
3817
+ $('znet-url').value = '';
3818
+ $('znet-url').placeholder = state.zProxyUrl || (state.zHasEnvProxy ? T('跟随环境变量 HTTPS_PROXY') : 'http://192.168.31.101:7890');
3819
+ $('znet-state').textContent = '';
3371
3820
  });
3372
3821
  $('comp-menu').addEventListener('click', (e) => {
3373
- const secBtn = e.target.closest && e.target.closest('[data-sec-action]');
3374
- if (secBtn) { doSecAction(secBtn.dataset.secAction); return; }
3375
- const move = e.target.closest && e.target.closest('[data-move]');
3376
- if (move) {
3377
- e.stopPropagation();
3378
- const id = move.dataset.comp;
3379
- const i = state.compOrder.indexOf(id);
3380
- const j = move.dataset.move === 'up' ? i - 1 : i + 1;
3381
- if (i < 0 || j < 0 || j >= state.compOrder.length) return;
3382
- [state.compOrder[i], state.compOrder[j]] = [state.compOrder[j], state.compOrder[i]];
3383
- saveCompOrder();
3822
+ const toggle = e.target.closest && e.target.closest('[data-comp-toggle]');
3823
+ if (toggle && Date.now() >= suppressToggleUntil) {
3824
+ const c = toggle.dataset.compToggle;
3825
+ if (state.hidden.has(c)) state.hidden.delete(c); else state.hidden.add(c);
3826
+ saveHiddenComps();
3384
3827
  applyComponents();
3385
3828
  renderCompMenu();
3386
3829
  return;
3387
3830
  }
3388
- const accMove = e.target.closest && e.target.closest('[data-acc-move]');
3389
- if (accMove) {
3390
- e.stopPropagation();
3391
- moveAccountId(accMove.dataset.prod, accMove.dataset.aid, accMove.dataset.accMove);
3392
- render();
3393
- renderCompMenu();
3394
- return;
3395
- }
3831
+ const secBtn = e.target.closest && e.target.closest('[data-sec-action]');
3832
+ if (secBtn) { doSecAction(secBtn.dataset.secAction); return; }
3396
3833
  const reset = e.target.closest && e.target.closest('[data-acc-reset]');
3397
3834
  if (reset) {
3398
3835
  e.stopPropagation();
@@ -3402,13 +3839,81 @@ $('comp-menu').addEventListener('click', (e) => {
3402
3839
  renderCompMenu();
3403
3840
  }
3404
3841
  });
3405
- applyComponents();
3842
+
3843
+ // ── 拖拽重排:组件与账号卡片共用(HTML5 DnD,横向 chip 按中线决定插前/插后)──
3844
+ let dragCtx = null; // {type:'comp'|'acc', id, prod}
3845
+ let suppressToggleUntil = 0; // 拖放结束瞬-ms 内不触发显隐切换(防止拖着拖着被当成点击)
3846
+ const clearDropMarks = () => document.querySelectorAll('.comp-chip.drop-before, .comp-chip.drop-after, .comp-chip.dragging')
3847
+ .forEach((x) => x.classList.remove('drop-before', 'drop-after', 'dragging'));
3848
+ $('comp-menu').addEventListener('dragstart', (e) => {
3849
+ const compEl = e.target.closest('[data-drag-comp]');
3850
+ const accEl = e.target.closest('[data-drag-acc]');
3851
+ dragCtx = compEl ? { type: 'comp', id: compEl.dataset.dragComp }
3852
+ : accEl ? { type: 'acc', prod: accEl.dataset.prod, id: accEl.dataset.dragAcc } : null;
3853
+ if (!dragCtx) return;
3854
+ e.dataTransfer.effectAllowed = 'move';
3855
+ try { e.dataTransfer.setData('text/plain', dragCtx.id); } catch {}
3856
+ (compEl || accEl).classList.add('dragging');
3857
+ });
3858
+ $('comp-menu').addEventListener('dragend', () => { dragCtx = null; suppressToggleUntil = Date.now() + 150; clearDropMarks(); });
3859
+ $('comp-menu').addEventListener('dragover', (e) => {
3860
+ if (!dragCtx) return;
3861
+ const sel = dragCtx.type === 'comp' ? '[data-drag-comp]' : '[data-drag-acc]';
3862
+ const t = e.target.closest(sel);
3863
+ if (!t) { e.dataTransfer.dropEffect = 'none'; return; }
3864
+ if (dragCtx.type === 'acc' && t.dataset.prod !== dragCtx.prod) { e.dataTransfer.dropEffect = 'none'; return; }
3865
+ e.preventDefault();
3866
+ e.dataTransfer.dropEffect = 'move';
3867
+ const r = t.getBoundingClientRect();
3868
+ const before = e.clientX < r.left + r.width / 2;
3869
+ t.classList.toggle('drop-before', before);
3870
+ t.classList.toggle('drop-after', !before);
3871
+ });
3872
+ $('comp-menu').addEventListener('dragleave', (e) => {
3873
+ const t = e.target.closest('.comp-chip');
3874
+ if (t) t.classList.remove('drop-before', 'drop-after');
3875
+ });
3876
+ $('comp-menu').addEventListener('drop', (e) => {
3877
+ if (!dragCtx) return;
3878
+ const sel = dragCtx.type === 'comp' ? '[data-drag-comp]' : '[data-drag-acc]';
3879
+ const t = e.target.closest(sel);
3880
+ clearDropMarks();
3881
+ if (!t) { dragCtx = null; return; }
3882
+ e.preventDefault();
3883
+ const r = t.getBoundingClientRect();
3884
+ const after = e.clientX >= r.left + r.width / 2;
3885
+ if (dragCtx.type === 'comp') {
3886
+ const arr = state.compOrder.filter((id) => id !== dragCtx.id);
3887
+ let idx = arr.indexOf(t.dataset.dragComp);
3888
+ if (idx < 0) { dragCtx = null; return; }
3889
+ if (after) idx += 1;
3890
+ arr.splice(idx, 0, dragCtx.id);
3891
+ state.compOrder = arr;
3892
+ saveCompOrder();
3893
+ applyComponents();
3894
+ } else {
3895
+ const baseIds = sortedAccounts(dragCtx.prod, ofProduct(dragCtx.prod)).map((a) => a.id);
3896
+ const arr = (state.cardOrder[dragCtx.prod] && state.cardOrder[dragCtx.prod].length ? state.cardOrder[dragCtx.prod].slice() : baseIds)
3897
+ .filter((id) => id !== dragCtx.id);
3898
+ let idx = arr.indexOf(t.dataset.dragAcc);
3899
+ if (idx < 0) { dragCtx = null; return; }
3900
+ if (after) idx += 1;
3901
+ arr.splice(idx, 0, dragCtx.id);
3902
+ state.cardOrder[dragCtx.prod] = arr;
3903
+ saveCardOrder();
3904
+ }
3905
+ suppressToggleUntil = Date.now() + 150;
3906
+ dragCtx = null;
3907
+ renderCompMenu();
3908
+ });
3909
+
3910
+ applyComponents();
3406
3911
  renderCompMenu();
3407
3912
 
3408
- $('btn-checkin-all').innerHTML = I.play + '全部领取';
3409
- for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + '添加账号';
3410
- for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + '导入'; $(id).onclick = pickImport; }
3411
- for (const id of ['btn-export', 'btn-export-dash']) $(id).innerHTML = I.download + '导出';
3913
+ $('btn-checkin-all').innerHTML = I.play + T('全部领取');
3914
+ for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + T('添加账号');
3915
+ for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + T('导入'); $(id).onclick = pickImport; }
3916
+ for (const id of ['btn-export', 'btn-export-dash']) $(id).innerHTML = I.download + T('导出');
3412
3917
  $('btn-checkin-all').onclick = () => checkinAll($('btn-checkin-all'));
3413
3918
  $('btn-checkin').onclick = () => checkinAll($('btn-checkin'), state.view);
3414
3919
  $('btn-add-dash').onclick = () => openAdd('');
@@ -3427,7 +3932,7 @@ $('btn-pass-ok').onclick = () => submitImport($('pass-input').value);
3427
3932
  $('pass-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitImport($('pass-input').value); });
3428
3933
  $('btn-scan').onclick = localScan;
3429
3934
  $('btn-device').onclick = deviceLogin;
3430
- $('dev-cancel').onclick = () => deviceStop('已取消');
3935
+ $('dev-cancel').onclick = () => deviceStop(T('已取消'));
3431
3936
  $('dev-link').addEventListener('click', (e) => { e.preventDefault(); openAuth($('dev-link').href); });
3432
3937
  $('btn-add-token').onclick = addToken;
3433
3938
  $('f-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') addToken(); });
@@ -3476,15 +3981,16 @@ $('region').addEventListener('click', (e) => {
3476
3981
  renderStatus();
3477
3982
  });
3478
3983
  $('f-provider').addEventListener('change', () => {
3479
- $('f-token').placeholder = $('f-provider').value === 'minimax'
3480
- ? 'MiniMax OAuth Bearer Token(mmoat_…)'
3984
+ $('f-token').placeholder = $('f-provider').value.startsWith('minimax')
3985
+ ? T('MiniMax OAuth Bearer Token(mmoat_…)')
3481
3986
  : $('f-provider').value.startsWith('workbuddy')
3482
- ? 'WorkBuddy / CodeBuddy 登录 token(eyJ…,可在 CodeBuddyExtension 的 auth/*.info 中找到)'
3483
- : 'dt-… / jt-… 或 PAT(pt-…,自动兑换)';
3987
+ ? T('WorkBuddy / CodeBuddy 登录 token(eyJ…,可在 CodeBuddyExtension 的 auth/*.info 中找到)')
3988
+ : T('dt-… / jt-… 或 PAT(pt-…,自动兑换)');
3484
3989
  });
3485
3990
  $('btn-logs').onclick = () => {
3486
3991
  const c = $('log-card').classList.toggle('collapsed');
3487
- $('btn-logs').textContent = c ? '展开' : '收起';
3992
+ try { localStorage.setItem('qd_logs_expanded', c ? '0' : '1'); } catch {}
3993
+ $('btn-logs').textContent = c ? T('展开') : T('收起');
3488
3994
  if (!c) { loadCurrentLogs(); $('logs').scrollTop = $('logs').scrollHeight; }
3489
3995
  };
3490
3996
  $('log-tabs').addEventListener('click', (e) => {
@@ -3516,7 +4022,7 @@ function applyDashFold() {
3516
4022
  });
3517
4023
  }
3518
4024
  document.querySelectorAll('.dash-cols .fold-head').forEach((h) => {
3519
- h.title = '展开 / 收起';
4025
+ h.title = T('展开 / 收起');
3520
4026
  h.addEventListener('click', () => {
3521
4027
  const k = h.closest('.pcard').dataset.fold;
3522
4028
  state.dashFold[k] = state.dashFold[k] !== true;
@@ -3528,11 +4034,15 @@ applyDashFold();
3528
4034
 
3529
4035
  // ── 启动:先直接请求(本机模式无需密钥);只有服务端返回 401 时才询问密钥 ──
3530
4036
  initTheme();
3531
- $('btn-home').innerHTML = I.github;
3532
4037
  applyView();
4038
+ function hideBootBusy() { const b = $('boot-busy'); if (!b) return; clearTimeout(hideBootBusy._t); b.classList.add('fade'); setTimeout(() => b.remove(), 300); }
3533
4039
  (function boot() {
3534
4040
  function start() {
3535
- loadStatus().then(loadAccounts).catch((e) => toast(e.message, false));
4041
+ // 兜底:接口长时间不返回也不能让加载态永久盖着
4042
+ hideBootBusy._t = setTimeout(hideBootBusy, 9000);
4043
+ applyDictFromServer().then(() => {
4044
+ window.__syncTheme?.(); // 头部主题按钮的图标/标题在字典到达后重渲染(initTheme 时字典还没到,T() 当时只能回落中文)
4045
+ loadStatus().then(loadAccounts).then(hideBootBusy, (e) => { toast(e.message, false); hideBootBusy(); });
3536
4046
  loadSettings().then(renderCompMenu);
3537
4047
  loadZNet();
3538
4048
  loadZGateway();
@@ -3547,12 +4057,15 @@ applyView();
3547
4057
  });
3548
4058
  loadLogs();
3549
4059
  setInterval(loadLogs, 10000);
3550
- setInterval(async () => { await loadStatus(); await loadZNet(); await loadZGateway(); await loadMGateway(); await loadTGateway(); render(); }, 30000);
4060
+ setInterval(async () => { await loadStatus(); await loadZNet(); await loadZGateway(); await loadMGateway(); await loadTGateway(); await refreshAccountList(); render(); }, 30000);
4061
+ });
3551
4062
  }
3552
4063
  // 先直接请求(本机模式无需密码);需要密码时 api() 会弹框并重试
3553
- api('/api/status').then(start).catch((e) => toast(e.code === 'PANEL_KEY'
3554
- ? '未输入面板访问密码,刷新页面可重试' : '无法连接守护进程:' + e.message, false));
4064
+ api('/api/status').then(start).catch((e) => { hideBootBusy(); toast(e.code === 'PANEL_KEY'
4065
+ ? T('未输入面板访问密码,刷新页面可重试') : T('无法连接守护进程:') + e.message, false); });
3555
4066
  })();
3556
4067
  </script>
4068
+ <!-- 语言切换加载态 -->
4069
+ <div id="locale-busy" hidden><div class="lb-card"><span class="lb-spin"></span><span data-i18n="正在切换语言…">正在切换语言…</span></div></div>
3557
4070
  </body>
3558
4071
  </html>