creditdaddy 1.3.0 → 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
@@ -7,6 +7,7 @@
7
7
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPjxwYXRoIGQ9Ik0zNzIuNDggMTM4LjY2NjY2N0ExMjggMTI4IDAgMCAwIDI5OC42NjY2NjcgMTAxLjU0NjY2N2ExMTcuNzYgMTE3Ljc2IDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzNsNzEuMjUzMzMzIDkxLjUyLTgxLjI4IDI4LjM3MzMzM2ExMC42NjY2NjcgMTAuNjY2NjY3IDAgMCAwLTIuMzQ2NjY3IDE4Ljk4NjY2NyAyNDIuNTYgMjQyLjU2IDAgMCAwIDY5Ljc2IDMyLjg1MzMzMyAyNzMuNzA2NjY3IDI3My43MDY2NjcgMCAwIDAgNTYuOTYgOC4zMnoiIGZpbGw9IiNGRjhBMTQiPjwvcGF0aD48cGF0aCBkPSJNMjQ1Ljc2IDEzOC4wMjY2NjdhMTMwLjk4NjY2NyAxMzAuOTg2NjY3IDAgMCAxIDUyLjA1MzMzMyAyLjEzMzMzM0ExNDguMDUzMzMzIDE0OC4wNTMzMzMgMCAwIDEgMzY2LjcyIDE3MC42NjY2NjdsNS43Ni0zMi44NTMzMzRBMTI4IDEyOCAwIDAgMCAyOTguNjY2NjY3IDEwMS41NDY2NjdhMTE3Ljk3MzMzMyAxMTcuOTczMzMzIDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzN6IiBmaWxsPSIjRkZBQTU0Ij48L3BhdGg+PHBhdGggZD0iTTIzNC42NjY2NjcgOTcwLjY2NjY2N2EyNzcuMzMzMzMzIDMyIDAgMSAwIDU1NC42NjY2NjYgMCAyNzcuMzMzMzMzIDMyIDAgMSAwLTU1NC42NjY2NjYgMFoiIGZpbGw9IiM0NTQxM0MiIG9wYWNpdHk9Ii4xNSI+PC9wYXRoPjxwYXRoIGQ9Ik02MjAuMzczMzMzIDQ2MC41ODY2NjdzMTUxLjA0LTkwLjY2NjY2NyAxODEuMzMzMzM0LTc0LjY2NjY2Ny0xMi41ODY2NjcgMTc0LjI5MzMzMy0xMDYuNjY2NjY3IDIxMy4zMzMzMzMtNzQuNjY2NjY3LTEzOC42NjY2NjctNzQuNjY2NjY3LTEzOC42NjY2NjZ6IiBmaWxsPSIjRkZFNTAwIj48L3BhdGg+PHBhdGggZD0iTTYyMC4zNzMzMzMgNTEzLjkyczE1MS4wNC05MC42NjY2NjcgMTgxLjMzMzMzNC03NC42NjY2NjdhMTQuNzIgMTQuNzIgMCAwIDEgNS4zMzMzMzMgNS45NzMzMzRjNi44MjY2NjctMjkuODY2NjY3IDUuMzMzMzMzLTUzLjU0NjY2Ny01LjMzMzMzMy01OS4zMDY2NjctMzAuMjkzMzMzLTE2LTE4MS4zMzMzMzMgNzQuNjY2NjY3LTE4MS4zMzMzMzQgNzQuNjY2NjY3YTM4Ni45ODY2NjcgMzg2Ljk4NjY2NyAwIDAgMCAwIDYxLjg2NjY2NmMtMC40MjY2NjctNS4zMzMzMzMgMC04LjUzMzMzMyAwLTguNTMzMzMzeiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik00MDMuNjI2NjY3IDQ2MC41ODY2NjdzLTE1MS4wNC05MC42NjY2NjctMTgxLjMzMzMzNC03NC42NjY2NjcgMTIuMzczMzMzIDE3NC4yOTMzMzMgMTA2LjY2NjY2NyAyMTMuMzMzMzMzIDc0LjY2NjY2Ny0xMzguNjY2NjY3IDc0LjY2NjY2Ny0xMzguNjY2NjY2eiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik0yMjIuMjkzMzMzIDQzOS4yNTMzMzNjMzAuMjkzMzMzLTE2IDE4MS4zMzMzMzMgNzQuNjY2NjY3IDE4MS4zMzMzMzQgNzQuNjY2NjY3djguNTMzMzMzYTM3My41NDY2NjcgMzczLjU0NjY2NyAwIDAgMCAwLTYxLjg2NjY2NnMtMTUxLjA0LTkwLjY2NjY2Ny0xODEuMzMzMzM0LTc0LjY2NjY2N2MtMTAuNjY2NjY3IDUuNzYtMTIuMTYgMjkuNDQtNS4zMzMzMzMgNTkuMzA2NjY3YTEzLjQ0IDEzLjQ0IDAgMCAxIDUuMzMzMzMzLTUuOTczMzM0eiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik0yODggNjUwLjY2NjY2N2EyNjYuNjY2NjY3IDIyNCA5MCAxIDAgNDQ4IDAgMjY2LjY2NjY2NyAyMjQgOTAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik02ODAuMTA2NjY3IDQ3NC4yNGEyMjQgMjI0IDAgMCAxLTMzNi4yMTMzMzQgMEEyMTAuNTYgMjEwLjU2IDAgMCAxIDUxMiAzODRhMjEwLjU2IDIxMC41NiAwIDAgMSAxNjguMTA2NjY3IDkwLjI0eiIgZmlsbD0iI0VCQ0IwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMjY2LjY2NjY2N20tMjI0IDBhMjI0IDIyNCAwIDEgMCA0NDggMCAyMjQgMjI0IDAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMTAxLjk3MzMzM2EyMjQuMjEzMzMzIDIyNC4yMTMzMzMgMCAwIDEgMjIxLjg2NjY2NyAxOTQuMzQ2NjY3IDIyNCAyMjQgMCAxIDAtNDQzLjczMzMzNCAwQTIyMy43ODY2NjcgMjIzLjc4NjY2NyAwIDAgMSA1MTIgMTAxLjk3MzMzM3oiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48cGF0aCBkPSJNODMzLjQ5MzMzMyA1OTguODI2NjY3bC03MC40IDcwLjRhMTguMzQ2NjY3IDE4LjM0NjY2NyAwIDAgMS0yNS44MTMzMzMgMEw2NDQuMDUzMzMzIDU3NkExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAwIDYxOC42NjY2NjcgNTc2bC05My4wMTMzMzQgOTMuMjI2NjY3YTE4Ljc3MzMzMyAxOC43NzMzMzMgMCAwIDEtMjYuMDI2NjY2IDBMNDA1LjMzMzMzMyA1NzZhMTguMTMzMzMzIDE4LjEzMzMzMyAwIDAgMC0yNS44MTMzMzMgMGwtOTMuMjI2NjY3IDkzLjIyNjY2N2ExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwbC03MC40LTcwLjRhOS4xNzMzMzMgOS4xNzMzMzMgMCAwIDAtMTUuNzg2NjY3IDYuODI2NjY2QzE4OS44NjY2NjcgODQxLjgxMzMzMyAzMzQuOTMzMzMzIDk2MCA1MTIgOTYwczMyMi4xMzMzMzMtMTE4LjE4NjY2NyAzMzcuMjgtMzU0LjM0NjY2N2E5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZ6IiBmaWxsPSIjRjBGMEYwIj48L3BhdGg+PHBhdGggZD0iTTE5MC41MDY2NjcgNjY1LjgxMzMzM2w3MC40IDYxLjIyNjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAwIDI1LjgxMzMzMyAwbDkzLjIyNjY2Ny04MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMSAyNS44MTMzMzMgMGw5My4yMjY2NjcgODEuMDY2NjY3YTIxLjMzMzMzMyAyMS4zMzMzMzMgMCAwIDAgMjYuMDI2NjY2IDBsOTMuNjUzMzM0LTgxLjA2NjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAxIDI2LjAyNjY2NiAwbDkzLjIyNjY2NyA4MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMCAyNS44MTMzMzMgMGw3MC40LTYxLjIyNjY2N2E5LjgxMzMzMyA5LjgxMzMzMyAwIDAgMSA4LjUzMzMzNC0xLjkyIDUzNi4xMDY2NjcgNTM2LjEwNjY2NyAwIDAgMCA3LjI1MzMzMy01OC4yNCA5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZsLTcwLjQgNzAuNGExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwTDY0NC4wNTMzMzMgNTc2QTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDAgNjE4LjY2NjY2NyA1NzZsLTkzLjAxMzMzNCA5My4yMjY2NjdhMTguNzczMzMzIDE4Ljc3MzMzMyAwIDAgMS0yNi4wMjY2NjYgMEw0MDUuMzMzMzMzIDU3NmExOC4xMzMzMzMgMTguMTMzMzMzIDAgMCAwLTI1LjgxMzMzMyAwbC05My4yMjY2NjcgOTMuMjI2NjY3YTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDEtMjUuODEzMzMzIDBsLTcwLjQtNzAuNGE5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjcgNi44MjY2NjYgNTM3LjE3MzMzMyA1MzcuMTczMzMzIDAgMCAwIDcuMDQgNTguMjQgMTAuMDI2NjY3IDEwLjAyNjY2NyAwIDAgMSA5LjE3MzMzNCAxLjkyeiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPjxwYXRoIGQ9Ik00OTguMTMzMzMzIDE4NC41MzMzMzNtLTI5LjAxMzMzMyAwYTI5LjAxMzMzMyAyOS4wMTMzMzMgMCAxIDAgNTguMDI2NjY3IDAgMjkuMDEzMzMzIDI5LjAxMzMzMyAwIDEgMC01OC4wMjY2NjcgMFoiIGZpbGw9IiM0NTQxM0MiPjwvcGF0aD48cGF0aCBkPSJNNTc2IDI5MC45ODY2NjdjMCAxMS43MzMzMzMtMTUuNzg2NjY3IDIxLjMzMzMzMy0zNC45ODY2NjcgMjEuMzMzMzMzcy0zNS4yLTkuNi0zNS4yLTIxLjMzMzMzMyAxNS43ODY2NjctMjEuMzMzMzMzIDM1LjItMjEuMzMzMzM0IDM0Ljk4NjY2NyA5LjYgMzQuOTg2NjY3IDIxLjMzMzMzNHoiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48L3N2Zz4=">
8
8
  <style>
9
9
  :root {
10
+ color-scheme: light;
10
11
  --bg: #f5f6f8; --surface: #ffffff; --surface-2: #fafafa; --tertiary: #f0f0f0; --hover: #f5f6f8; --active: #eef0f3;
11
12
  --border-subtle: #eeeeee; --border: #e5e5e5; --border-strong: #cccccc;
12
13
  --text: #1f1f1f; --text-2: #5f6368; --text-3: #999999;
@@ -17,6 +18,7 @@
17
18
  --mask: rgba(0, 0, 0, .4);
18
19
  }
19
20
  html[data-theme="dark"] {
21
+ color-scheme: dark;
20
22
  --bg: #0a0a0c; --surface: #131316; --surface-2: #17171b; --tertiary: #1b1b20; --hover: rgba(255,255,255,.06); --active: rgba(255,255,255,.1);
21
23
  --border-subtle: #202025; --border: rgba(255,255,255,.13); --border-strong: #3a3a42;
22
24
  --text: #e6e6e9; --text-2: rgba(230,230,233,.72); --text-3: rgba(230,230,233,.5);
@@ -32,7 +34,7 @@
32
34
  font-size: 13px; color: var(--text); background: var(--bg); min-height: 100vh;
33
35
  -webkit-font-smoothing: antialiased; transition: background .2s;
34
36
  }
35
- button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
37
+ button, input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
36
38
  .wrap { max-width: 1120px; margin: 0 auto; padding: 18px 22px 40px; }
37
39
  .num { font-variant-numeric: tabular-nums; }
38
40
 
@@ -41,11 +43,56 @@
41
43
  .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
42
44
  .logo { width: 36px; height: 36px; flex-shrink: 0; display: block; }
43
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; }
44
- .ver { color: var(--text-3); font-size: 11px; line-height: 1.3; font-variant-numeric: tabular-nums; }
45
- .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; } }
46
93
  /* 面板设置(右上角齿轮 → 弹窗):访问密码 + 组件显隐与排序 + 账号卡片排序 */
47
94
  .comp-menu { display: flex; flex-direction: column; gap: 8px; }
48
- .comp-menu-title { font-size: 11px; color: var(--text-3); margin: 10px 0 4px; padding-top: 8px; border-top: 1px dashed var(--border-subtle); }
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); }
49
96
  .comp-menu-title:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
50
97
  .comp-menu .check { margin-right: 0; font-size: 12.5px; color: var(--text); }
51
98
  /* 产品页 notice 的功能分区行 */
@@ -63,12 +110,25 @@
63
110
  .comp-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
64
111
  .comp-row.off .check { opacity: .45; }
65
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; }
66
126
  .icon-btn.mini { width: 20px; height: 20px; border-radius: 6px; font-size: 11px; line-height: 1; padding: 0; }
67
127
  .icon-btn.mini svg { width: 12px; height: 12px; }
68
128
  .sort-sec { border-top: 1px dashed var(--border-subtle); padding-top: 6px; font-size: 12px; }
69
129
  .sort-sec summary { cursor: pointer; color: var(--text-2); user-select: none; display: flex; align-items: center; gap: 6px; }
70
130
  .sort-sec summary::-webkit-details-marker { display: none; }
71
- .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; }
72
132
  .sort-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 2px 0; }
73
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; }
74
134
  .card.dragging, .tr-card.dragging { opacity: .45; }
@@ -96,6 +156,8 @@
96
156
  .btn svg, .icon-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
97
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; }
98
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); }
99
161
  .icon-btn.del:hover { background: #ffecec; color: #f53f3f; }
100
162
  html[data-theme="dark"] .icon-btn.del:hover { background: rgba(245,63,63,.15); }
101
163
  .icon-btn.del.armed { background: #f53f3f; color: #fff; }
@@ -116,6 +178,8 @@
116
178
  .badge.intl { color: #3b82f6; background: rgba(59,130,246,.1); }
117
179
  .badge.cn { color: #d97706; background: rgba(245,158,11,.12); }
118
180
  .badge.prod { font-weight: 600; }
181
+ .badge.web { font-weight: 600; cursor: help; }
182
+ .badge.web-warn { color: var(--warn); background: var(--warn-soft); }
119
183
  .badge.prod.qoder { color: #6d28d9; background: rgba(124,58,237,.1); }
120
184
  .badge.prod.workbuddy { color: #0f766e; background: rgba(20,184,166,.12); }
121
185
  html[data-theme="dark"] .badge.prod.qoder { color: #b8a4ff; }
@@ -182,13 +246,32 @@
182
246
  .pcard.open .fold-body { display: block; }
183
247
  .logs { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.65; max-height: 240px; overflow-y: auto; background: var(--tertiary); border-radius: 9px; padding: 8px 10px; }
184
248
  .logs .info { color: var(--text-2); } .logs .warn { color: var(--warn); } .logs .error { color: var(--err); } .logs .debug { color: var(--text-3); }
249
+ .logs .lvl-ico { margin-right: 5px; font-weight: 700; }
250
+ .logs .warn .lvl-ico { color: var(--warn); }
251
+ .logs .error .lvl-ico { color: var(--err); }
252
+ .log-filter { display: inline-flex; gap: 2px; padding: 2px; border-radius: 9px; background: var(--hover); }
253
+ .log-filter button { border: none; background: transparent; color: var(--text-2); font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 7px; cursor: pointer; }
254
+ .log-filter button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
255
+ html[data-theme="dark"] .log-filter button.active { background: rgba(255,255,255,.1); }
185
256
  .collapsed .logs { display: none; }
186
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
+
187
269
  /* modal */
188
270
  .mask { position: fixed; inset: 0; z-index: 50; background: var(--mask); display: none; align-items: center; justify-content: center; padding: 16px; }
189
271
  .mask.show { display: flex; }
190
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); }
191
273
  .modal.sm { width: 360px; }
274
+ .changelog-sec + .changelog-sec { border-top: 1px dashed var(--border-subtle); margin-top: 10px; padding-top: 10px; }
192
275
  .modal-title { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 700; margin-bottom: 12px; }
193
276
  .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
194
277
  .close { border: none; background: none; color: var(--text-3); font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
@@ -198,13 +281,15 @@
198
281
  .tab:hover { color: var(--text); }
199
282
  .tab.active { background: var(--btn-bg); color: var(--btn-fg); }
200
283
  .pane { display: none; } .pane.active { display: block; }
201
- .field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
202
- .field label { font-size: 12px; color: var(--text-2); font-weight: 600; }
203
- .input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); outline: none; transition: border-color .15s; }
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; }
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; }
287
+ .input::placeholder { color: var(--text-3); opacity: .7; }
204
288
  .input:focus { border-color: #4f86ff; }
289
+ .input:disabled { opacity: .6; cursor: not-allowed; }
205
290
  .input.mono { font-family: ui-monospace, Consolas, monospace; }
206
291
  .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
207
- .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; }
208
293
  .hint a { color: #4f86ff; word-break: break-all; }
209
294
  .box { border: 1px solid var(--border-subtle); border-radius: 10px; padding: 10px 12px; margin-top: 10px; background: var(--surface-2); }
210
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); }
@@ -340,30 +425,132 @@
340
425
  </style>
341
426
  </head>
342
427
  <body>
428
+ <!-- 首次加载态:数据首屏后由 boot 隐藏 -->
429
+ <div id="boot-busy"><div class="lb-card boot"><span class="lb-spin"></span><strong>CreditDaddy</strong></div></div>
343
430
  <div class="wrap">
344
431
  <header class="head">
345
432
  <div class="brand">
346
433
  <img class="logo" alt="" src="data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPjxwYXRoIGQ9Ik0zNzIuNDggMTM4LjY2NjY2N0ExMjggMTI4IDAgMCAwIDI5OC42NjY2NjcgMTAxLjU0NjY2N2ExMTcuNzYgMTE3Ljc2IDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzNsNzEuMjUzMzMzIDkxLjUyLTgxLjI4IDI4LjM3MzMzM2ExMC42NjY2NjcgMTAuNjY2NjY3IDAgMCAwLTIuMzQ2NjY3IDE4Ljk4NjY2NyAyNDIuNTYgMjQyLjU2IDAgMCAwIDY5Ljc2IDMyLjg1MzMzMyAyNzMuNzA2NjY3IDI3My43MDY2NjcgMCAwIDAgNTYuOTYgOC4zMnoiIGZpbGw9IiNGRjhBMTQiPjwvcGF0aD48cGF0aCBkPSJNMjQ1Ljc2IDEzOC4wMjY2NjdhMTMwLjk4NjY2NyAxMzAuOTg2NjY3IDAgMCAxIDUyLjA1MzMzMyAyLjEzMzMzM0ExNDguMDUzMzMzIDE0OC4wNTMzMzMgMCAwIDEgMzY2LjcyIDE3MC42NjY2NjdsNS43Ni0zMi44NTMzMzRBMTI4IDEyOCAwIDAgMCAyOTguNjY2NjY3IDEwMS41NDY2NjdhMTE3Ljk3MzMzMyAxMTcuOTczMzMzIDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzN6IiBmaWxsPSIjRkZBQTU0Ij48L3BhdGg+PHBhdGggZD0iTTIzNC42NjY2NjcgOTcwLjY2NjY2N2EyNzcuMzMzMzMzIDMyIDAgMSAwIDU1NC42NjY2NjYgMCAyNzcuMzMzMzMzIDMyIDAgMSAwLTU1NC42NjY2NjYgMFoiIGZpbGw9IiM0NTQxM0MiIG9wYWNpdHk9Ii4xNSI+PC9wYXRoPjxwYXRoIGQ9Ik02MjAuMzczMzMzIDQ2MC41ODY2NjdzMTUxLjA0LTkwLjY2NjY2NyAxODEuMzMzMzM0LTc0LjY2NjY2Ny0xMi41ODY2NjcgMTc0LjI5MzMzMy0xMDYuNjY2NjY3IDIxMy4zMzMzMzMtNzQuNjY2NjY3LTEzOC42NjY2NjctNzQuNjY2NjY3LTEzOC42NjY2NjZ6IiBmaWxsPSIjRkZFNTAwIj48L3BhdGg+PHBhdGggZD0iTTYyMC4zNzMzMzMgNTEzLjkyczE1MS4wNC05MC42NjY2NjcgMTgxLjMzMzMzNC03NC42NjY2NjdhMTQuNzIgMTQuNzIgMCAwIDEgNS4zMzMzMzMgNS45NzMzMzRjNi44MjY2NjctMjkuODY2NjY3IDUuMzMzMzMzLTUzLjU0NjY2Ny01LjMzMzMzMy01OS4zMDY2NjctMzAuMjkzMzMzLTE2LTE4MS4zMzMzMzMgNzQuNjY2NjY3LTE4MS4zMzMzMzQgNzQuNjY2NjY3YTM4Ni45ODY2NjcgMzg2Ljk4NjY2NyAwIDAgMCAwIDYxLjg2NjY2NmMtMC40MjY2NjctNS4zMzMzMzMgMC04LjUzMzMzMyAwLTguNTMzMzMzeiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik00MDMuNjI2NjY3IDQ2MC41ODY2NjdzLTE1MS4wNC05MC42NjY2NjctMTgxLjMzMzMzNC03NC42NjY2NjcgMTIuMzczMzMzIDE3NC4yOTMzMzMgMTA2LjY2NjY2NyAyMTMuMzMzMzMzIDc0LjY2NjY2Ny0xMzguNjY2NjY3IDc0LjY2NjY2Ny0xMzguNjY2NjY2eiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik0yMjIuMjkzMzMzIDQzOS4yNTMzMzNjMzAuMjkzMzMzLTE2IDE4MS4zMzMzMzMgNzQuNjY2NjY3IDE4MS4zMzMzMzQgNzQuNjY2NjY3djguNTMzMzMzYTM3My41NDY2NjcgMzczLjU0NjY2NyAwIDAgMCAwLTYxLjg2NjY2NnMtMTUxLjA0LTkwLjY2NjY2Ny0xODEuMzMzMzM0LTc0LjY2NjY2N2MtMTAuNjY2NjY3IDUuNzYtMTIuMTYgMjkuNDQtNS4zMzMzMzMgNTkuMzA2NjY3YTEzLjQ0IDEzLjQ0IDAgMCAxIDUuMzMzMzMzLTUuOTczMzM0eiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik0yODggNjUwLjY2NjY2N2EyNjYuNjY2NjY3IDIyNCA5MCAxIDAgNDQ4IDAgMjY2LjY2NjY2NyAyMjQgOTAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik02ODAuMTA2NjY3IDQ3NC4yNGEyMjQgMjI0IDAgMCAxLTMzNi4yMTMzMzQgMEEyMTAuNTYgMjEwLjU2IDAgMCAxIDUxMiAzODRhMjEwLjU2IDIxMC41NiAwIDAgMSAxNjguMTA2NjY3IDkwLjI0eiIgZmlsbD0iI0VCQ0IwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMjY2LjY2NjY2N20tMjI0IDBhMjI0IDIyNCAwIDEgMCA0NDggMCAyMjQgMjI0IDAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMTAxLjk3MzMzM2EyMjQuMjEzMzMzIDIyNC4yMTMzMzMgMCAwIDEgMjIxLjg2NjY2NyAxOTQuMzQ2NjY3IDIyNCAyMjQgMCAxIDAtNDQzLjczMzMzNCAwQTIyMy43ODY2NjcgMjIzLjc4NjY2NyAwIDAgMSA1MTIgMTAxLjk3MzMzM3oiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48cGF0aCBkPSJNODMzLjQ5MzMzMyA1OTguODI2NjY3bC03MC40IDcwLjRhMTguMzQ2NjY3IDE4LjM0NjY2NyAwIDAgMS0yNS44MTMzMzMgMEw2NDQuMDUzMzMzIDU3NkExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAwIDYxOC42NjY2NjcgNTc2bC05My4wMTMzMzQgOTMuMjI2NjY3YTE4Ljc3MzMzMyAxOC43NzMzMzMgMCAwIDEtMjYuMDI2NjY2IDBMNDA1LjMzMzMzMyA1NzZhMTguMTMzMzMzIDE4LjEzMzMzMyAwIDAgMC0yNS44MTMzMzMgMGwtOTMuMjI2NjY3IDkzLjIyNjY2N2ExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwbC03MC40LTcwLjRhOS4xNzMzMzMgOS4xNzMzMzMgMCAwIDAtMTUuNzg2NjY3IDYuODI2NjY2QzE4OS44NjY2NjcgODQxLjgxMzMzMyAzMzQuOTMzMzMzIDk2MCA1MTIgOTYwczMyMi4xMzMzMzMtMTE4LjE4NjY2NyAzMzcuMjgtMzU0LjM0NjY2N2E5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZ6IiBmaWxsPSIjRjBGMEYwIj48L3BhdGg+PHBhdGggZD0iTTE5MC41MDY2NjcgNjY1LjgxMzMzM2w3MC40IDYxLjIyNjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAwIDI1LjgxMzMzMyAwbDkzLjIyNjY2Ny04MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMSAyNS44MTMzMzMgMGw5My4yMjY2NjcgODEuMDY2NjY3YTIxLjMzMzMzMyAyMS4zMzMzMzMgMCAwIDAgMjYuMDI2NjY2IDBsOTMuNjUzMzM0LTgxLjA2NjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAxIDI2LjAyNjY2NiAwbDkzLjIyNjY2NyA4MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMCAyNS44MTMzMzMgMGw3MC40LTYxLjIyNjY2N2E5LjgxMzMzMyA5LjgxMzMzMyAwIDAgMSA4LjUzMzMzNC0xLjkyIDUzNi4xMDY2NjcgNTM2LjEwNjY2NyAwIDAgMCA3LjI1MzMzMy01OC4yNCA5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZsLTcwLjQgNzAuNGExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwTDY0NC4wNTMzMzMgNTc2QTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDAgNjE4LjY2NjY2NyA1NzZsLTkzLjAxMzMzNCA5My4yMjY2NjdhMTguNzczMzMzIDE4Ljc3MzMzMyAwIDAgMS0yNi4wMjY2NjYgMEw0MDUuMzMzMzMzIDU3NmExOC4xMzMzMzMgMTguMTMzMzMzIDAgMCAwLTI1LjgxMzMzMyAwbC05My4yMjY2NjcgOTMuMjI2NjY3YTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDEtMjUuODEzMzMzIDBsLTcwLjQtNzAuNGE5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjcgNi44MjY2NjYgNTM3LjE3MzMzMyA1MzcuMTczMzMzIDAgMCAwIDcuMDQgNTguMjQgMTAuMDI2NjY3IDEwLjAyNjY2NyAwIDAgMSA5LjE3MzMzNCAxLjkyeiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPjxwYXRoIGQ9Ik00OTguMTMzMzMzIDE4NC41MzMzMzNtLTI5LjAxMzMzMyAwYTI5LjAxMzMzMyAyOS4wMTMzMzMgMCAxIDAgNTguMDI2NjY3IDAgMjkuMDEzMzMzIDI5LjAxMzMzMyAwIDEgMC01OC4wMjY2NjcgMFoiIGZpbGw9IiM0NTQxM0MiPjwvcGF0aD48cGF0aCBkPSJNNTc2IDI5MC45ODY2NjdjMCAxMS43MzMzMzMtMTUuNzg2NjY3IDIxLjMzMzMzMy0zNC45ODY2NjcgMjEuMzMzMzMzcy0zNS4yLTkuNi0zNS4yLTIxLjMzMzMzMyAxNS43ODY2NjctMjEuMzMzMzMzIDM1LjItMjEuMzMzMzM0IDM0Ljk4NjY2NyA5LjYgMzQuOTg2NjY3IDIxLjMzMzMzNHoiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48L3N2Zz4=">
347
434
  <div>
348
435
  <div class="title">CreditDaddy</div>
349
- <div class="ver" id="ver">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取</div>
436
+ <div class="ver" id="ver" data-i18n="Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动领取</div>
350
437
  </div>
351
438
  </div>
352
439
  <div class="head-right">
353
- <span id="sched" class="num"></span>
354
- <button class="icon-btn" id="btn-comp" title="面板设置:访问密码、组件显隐与排序、账号卡片排序"></button>
355
- <a class="icon-btn" id="btn-home" href="https://github.com/techysy/CreditDaddy" target="_blank" rel="noopener" title="项目主页(GitHub)"></a>
356
- <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>
357
544
  </div>
358
545
  </header>
359
546
 
360
547
  <nav class="nav" id="nav">
361
- <button data-view="dash" class="active">仪表盘</button>
548
+ <button data-view="dash" class="active" data-i18n="仪表盘">仪表盘</button>
362
549
  <button data-view="qoder">Qoder<span class="cnt" id="cnt-qoder">0</span></button>
363
550
  <button data-view="workbuddy">WorkBuddy<span class="cnt" id="cnt-workbuddy">0</span></button>
364
551
  <button data-view="zcode">ZCode<span class="cnt" id="cnt-zcode">0</span></button>
365
552
  <button data-view="mirasim">mirasim<span class="cnt" id="cnt-mirasim">0</span></button>
366
- <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>
367
554
  <button data-view="trae">Trae<span class="cnt" id="cnt-trae">0</span></button>
368
555
  <button data-view="minimax">MiniMax<span class="cnt" id="cnt-minimax">0</span></button>
369
556
  <button data-view="tenrouter">10Router<span class="cnt" id="cnt-tenrouter">–</span></button>
@@ -373,7 +560,7 @@
373
560
  <section class="view active" id="view-dash">
374
561
  <div class="panel dash-panel">
375
562
  <div class="toolbar">
376
- <div class="summary"><span class="dash-title">总览</span></div>
563
+ <div class="summary"><span class="dash-title" data-i18n="总览">总览</span></div>
377
564
  <div class="actions">
378
565
  <button class="btn primary" id="btn-checkin-all"></button>
379
566
  <button class="btn" id="btn-add-dash"></button>
@@ -385,8 +572,8 @@
385
572
  </div>
386
573
  <div class="prods" id="prods"></div>
387
574
  <div class="dash-cols">
388
- <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>
389
- <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>
390
577
  </div>
391
578
  </section>
392
579
 
@@ -395,27 +582,27 @@
395
582
  <div class="panel">
396
583
  <div class="toolbar">
397
584
  <div class="summary">
398
- <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>
399
586
  <div class="divider"></div>
400
587
  <div id="s-done-wrap" style="display:contents">
401
- <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>
402
589
  <div class="divider"></div>
403
590
  </div>
404
- <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>
405
592
  <div class="divider" id="region-div"></div>
406
593
  <div class="seg-ctl" id="region">
407
- <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>
408
595
  </div>
409
596
  </div>
410
597
  <div class="actions">
411
598
  <button class="btn primary" id="btn-checkin"></button>
412
599
  <span id="z-actions" style="display:none">
413
600
  <button class="btn" id="zt-ac"></button>
414
- <button class="btn" id="zt-net"></button>
415
601
  <button class="btn" id="zt-gw"></button>
416
602
  <button class="btn" id="zt-open" style="display:none"></button>
417
603
  </span>
418
604
  <span id="t-actions" style="display:none">
605
+ <button class="btn" id="tt-gw"></button>
419
606
  <button class="btn" id="tt-open" style="display:none"></button>
420
607
  </span>
421
608
  <span id="m-actions" style="display:none">
@@ -428,57 +615,65 @@
428
615
  </div>
429
616
  </div>
430
617
  <div class="notice" id="notice" style="display:none"></div>
431
- <div class="list" id="list"><div class="empty">加载中…</div></div>
618
+ <div class="list" id="list"><div class="empty" data-i18n="加载中…">加载中…</div></div>
432
619
  </div>
433
620
  </section>
434
621
 
435
622
  <!-- 10Router:其他供应商额度 + 用量同步 -->
436
623
  <section class="view" id="view-10r">
437
- <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>
438
625
  </section>
439
626
 
440
627
  <!-- 日志卡:运行日志 + 各网关调用日志,卡内标签切换(默认跟随当前页,可手动切) -->
441
628
  <section class="pcard collapsed" id="log-card">
442
629
  <div class="pcard-title">
443
630
  <div class="seg-ctl" id="log-tabs">
444
- <button data-log-tab="run" class="active">运行日志</button>
445
- <button data-log-tab="zcode">ZCode 网关</button>
446
- <button data-log-tab="minimax">MiniMax 网关</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>
635
+ </div>
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>
447
643
  </div>
448
- <button class="btn" id="btn-logs">展开</button>
449
644
  </div>
450
- <div class="logs" id="logs">(暂无)</div>
645
+ <div class="logs" id="logs" data-i18n="(暂无)">(暂无)</div>
451
646
  </section>
452
647
  </div>
453
648
 
454
649
  <!-- 添加账号 -->
455
650
  <div class="mask" id="m-add">
456
651
  <div class="modal">
457
- <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>
458
653
  <div class="tabs">
459
- <button class="tab active" data-tab="local">本机导入</button>
460
- <button class="tab" data-tab="device">浏览器登录</button>
461
- <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>
462
657
  </div>
463
658
 
464
659
  <div class="pane active" data-pane="local">
465
660
  <div class="hint" id="local-hint"></div>
466
- <div class="row" style="margin-top:10px"><button class="btn primary" id="btn-scan">重新扫描</button><span class="hint" id="scan-status"></span></div>
467
- <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>
468
663
  </div>
469
664
 
470
665
  <div class="pane" data-pane="device">
471
666
  <div class="hint" id="dev-hint" style="margin-bottom:10px"></div>
472
667
  <div class="field">
473
- <label>登录到</label>
668
+ <label data-i18n="登录到">登录到</label>
474
669
  <select class="input" id="dev-provider">
475
670
  <option value="qoder">Qoder 国际版</option><option value="qoder-cn">Qoder 国内版</option>
476
- <option value="workbuddy">WorkBuddy / CodeBuddy 国内版</option><option value="workbuddy-intl">WorkBuddy / CodeBuddy 国际版</option>
477
- <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>
478
673
  <option value="minimax">MiniMax Code</option>
479
674
  </select>
480
675
  </div>
481
- <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>
482
677
  <div class="box" id="device-box" style="display:none">
483
678
  <div class="hint" id="dev-status"></div>
484
679
  <div class="hint" style="margin-top:6px"><a id="dev-link" href="#" target="_blank" rel="noopener"></a></div>
@@ -487,12 +682,12 @@
487
682
 
488
683
  <div class="pane" data-pane="token">
489
684
  <div class="field">
490
- <label>版本</label>
491
- <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>
492
687
  </div>
493
- <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>
494
689
  <div class="field"><label>Token</label><input class="input mono" id="f-token" placeholder="dt-… / jt-… 或 PAT(pt-…,自动兑换)"></div>
495
- <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>
496
691
  </div>
497
692
  </div>
498
693
  </div>
@@ -500,40 +695,28 @@
500
695
  <!-- 导出 -->
501
696
  <div class="mask" id="m-export">
502
697
  <div class="modal sm">
503
- <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>
504
699
  <div class="field">
505
- <label>范围</label>
506
- <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>
507
702
  </div>
508
703
  <div class="field">
509
- <label>加密口令(必填)</label>
704
+ <label data-i18n="加密口令(必填)">加密口令(必填)</label>
510
705
  <input class="input" id="ex-password" type="password" placeholder="至少 4 位" autocomplete="new-password">
511
706
  </div>
512
- <div class="hint">导出文件一律加密(10router 迁移格式,scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。<b>口令不会被保存</b>,请牢记——遗失后文件无法解开、无法导入。</div>
513
- <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-do-export">导出</button></div>
707
+ <div class="hint">导出文件一律加密(10router 迁移格式,scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。<b data-i18n="口令不会被保存">口令不会被保存</b>,请牢记——遗失后文件无法解开、无法导入。</div>
708
+ <div class="notice" id="ex-session-note" style="display:none"></div>
709
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-do-export" data-i18n="导出">导出</button></div>
514
710
  </div>
515
711
  </div>
516
712
 
517
713
  <!-- 口令输入 -->
518
714
  <div class="mask" id="m-pass">
519
715
  <div class="modal sm">
520
- <div class="modal-title"><span>输入口令</span><button class="close" data-close>✕</button></div>
521
- <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>
522
718
  <div class="field" style="margin-top:10px"><input class="input" id="pass-input" type="password"></div>
523
- <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-pass-ok">导入</button></div>
524
- </div>
525
- </div>
526
-
527
- <!-- 面板设置(右上角齿轮) -->
528
- <div class="mask" id="m-settings">
529
- <div class="modal">
530
- <div class="modal-title"><span>面板设置</span><button class="close" data-close>✕</button></div>
531
- <div class="comp-menu" id="comp-menu"></div>
532
- <div class="field" id="upd-entry" style="display:none;margin-top:12px">
533
- <label>版本与更新</label>
534
- <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>
535
- </div>
536
- <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>
537
720
  </div>
538
721
  </div>
539
722
 
@@ -546,24 +729,33 @@
546
729
  </div>
547
730
  </div>
548
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
+
549
741
  <!-- 10Router 设置 -->
550
742
  <div class="mask" id="m-10r">
551
743
  <div class="modal">
552
- <div class="modal-title"><span>10Router 连接设置</span><button class="close" data-close>✕</button></div>
553
- <div class="hint">填 10Router 的访问地址和仪表盘里创建的<b>虚拟 key(sk-…)</b>。key 只保存在 CreditDaddy 守护进程的数据目录(0600),面板里只显示脱敏值。</div>
554
- <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>
555
- <div class="field"><label>虚拟 key</label><input class="input mono" id="tr-key" placeholder="sk-…" autocomplete="off"></div>
556
- <div class="field"><label>10Router 面板密码(可选)</label><input class="input mono" id="tr-admin-pw" type="password" placeholder="配置后「同步账号」走 OAuth 通道(带 refreshToken 自动续期);留空保持不变" autocomplete="new-password"></div>
557
- <div class="field"><label>账号同步</label>
558
- <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>
559
751
  </div>
560
- <div class="field"><label>显示</label>
752
+ <div class="field"><label data-i18n="显示">显示</label>
561
753
  <div><label class="check"><input type="checkbox" id="tr-hide-empty"> 「全部」视图隐藏没有额度 / 余额为 0 的供应商汇总卡</label></div>
562
754
  </div>
563
755
  <div class="hint" id="tr-test-msg"></div>
564
756
  <div class="modal-actions">
565
- <button class="btn" id="tr-disconnect" style="margin-right:auto">断开</button>
566
- <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>
567
759
  <button class="btn primary" id="tr-save">保存</button>
568
760
  </div>
569
761
  </div>
@@ -572,20 +764,20 @@
572
764
  <!-- 10Router 用量同步 -->
573
765
  <div class="mask" id="m-trsync">
574
766
  <div class="modal sm">
575
- <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>
576
768
  <div class="field"><label class="check"><input type="checkbox" id="tr-auto"> 每小时自动同步本机用量到 10Router</label></div>
577
- <div class="field"><label>同步来源</label><div id="tr-sources"></div></div>
578
- <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>
579
771
  <div class="hint" id="tr-sync-status" style="margin-top:8px"></div>
580
- <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>
581
773
  </div>
582
774
  </div>
583
775
 
584
776
  <!-- ZCode 活动领取 -->
585
777
  <div class="mask" id="m-claim">
586
778
  <div class="modal">
587
- <div class="modal-title"><span>活动领取</span><button class="close" data-close>✕</button></div>
588
- <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>
589
781
  <div id="claim-body" style="margin-top:10px"></div>
590
782
  </div>
591
783
  </div>
@@ -596,59 +788,41 @@
596
788
  <div class="modal-title"><span id="dlg-title"></span><button class="close" data-close>✕</button></div>
597
789
  <div class="hint" id="dlg-msg" style="white-space:pre-wrap"></div>
598
790
  <div class="field" id="dlg-field" style="margin-top:10px"><input class="input" id="dlg-input"></div>
599
- <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>
600
792
  </div>
601
793
  </div>
602
794
 
603
795
  <!-- ZCode 自动领取计划 -->
604
796
  <div class="mask" id="m-zac">
605
797
  <div class="modal sm">
606
- <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>
607
799
  <div class="field">
608
- <label>轮询周期(分钟,1–720)</label>
800
+ <label data-i18n="轮询周期(分钟,1–720)">轮询周期(分钟,1–720)</label>
609
801
  <input class="input" id="zac-interval" type="number" min="1" max="720" step="1">
610
802
  </div>
611
803
  <div class="field">
612
- <label>单次运行时长(分钟,0 = 一直运行直到手动关闭)</label>
804
+ <label data-i18n="单次运行时长(分钟,0 = 一直运行直到手动关闭)">单次运行时长(分钟,0 = 一直运行直到手动关闭)</label>
613
805
  <input class="input" id="zac-window" type="number" min="0" max="1440" step="5">
614
806
  </div>
615
- <div class="hint">开启后按此周期检查活动资格并自动领取(首次检查在开启 2 分钟后开始)。活动包由服务端在每日 0 点左右刷新放出——若运行时段没有覆盖 0 点,会错过当次领取,建议把运行时长设为 0,或让时段跨过 0 点。需要验证码时无法自动通过,卡片上会提示手动领取。</div>
616
- <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>
617
- </div>
618
- </div>
619
-
620
- <!-- ZCode 网络出口 -->
621
- <div class="mask" id="m-znet">
622
- <div class="modal sm">
623
- <div class="modal-title"><span>ZCode 网络出口</span><button class="close" data-close>✕</button></div>
624
- <div class="field">
625
- <label>上游接口访问顺序(失败 / 被拦时自动回退另一条)</label>
626
- <label class="check"><input type="radio" name="znet-order" id="znet-direct"> 直连优先(代理兜底)</label>
627
- <label class="check"><input type="radio" name="znet-order" id="znet-proxy"> 代理优先(直连兜底)</label>
628
- </div>
629
- <div class="field">
630
- <label>出口代理(http://host:port,支持口令 http://user:pass@host:port)</label>
631
- <input class="input" id="znet-url" placeholder="http://192.168.31.101:7890">
632
- </div>
633
- <div class="hint">留空 = 清除已保存的代理,退回 HTTPS_PROXY 环境变量;取消 = 不改动。当前代理只显示脱敏后的地址。</div>
634
- <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>
635
809
  </div>
636
810
  </div>
637
811
 
638
812
  <!-- ZCode 体验包接口 -->
639
813
  <div class="mask" id="m-zgw">
640
814
  <div class="modal sm">
641
- <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>
642
816
  <div class="hint" id="zgw-url" style="margin-bottom:10px"></div>
643
817
  <div class="field">
644
- <label class="check"><input type="checkbox" id="zgw-on"> 启用体验包接口(/gateway/v1/messages)</label>
818
+ <label class="check"><input type="checkbox" id="zgw-on"> 启用体验包接口(/gateway/zcode/v1/messages)</label>
645
819
  </div>
646
820
  <div class="field" id="zgw-lan-fields">
647
821
  <label class="check"><input type="checkbox" id="zgw-lan"> 对局域网开放(重启后绑定 0.0.0.0)</label>
648
822
  <label>IP 白名单(每行一个,支持 192.168.31.* 通配)</label>
649
823
  <textarea class="input" id="zgw-allow" rows="4" placeholder="192.168.31.101"></textarea>
650
824
  </div>
651
- <div class="hint">开启后,领到的体验包额度变成本机的一个补全接口——10Router 建一个 anthropic-compatible 节点指向本机 /gateway 即可调用(需要桌面版,隐藏窗口过验证码)。白名单内的机器<b>免密直连</b>:10Router 的 zcode-free 本就是免授权供应商,把 10Router 服务地址的 IP 加白即可;白名单外的请求一律拒绝。本机回环(127.0.0.1)始终放行。</div>
825
+ <div class="hint">开启后,领到的体验包额度变成本机的一个补全接口——10Router 建一个 anthropic-compatible 节点指向本机 <code>/gateway/zcode/v1/messages</code> 即可调用(需要桌面版,隐藏窗口过验证码)。白名单内的机器<b>免密直连</b>:10Router 的 zcode-free 本就是免授权供应商,把 10Router 服务地址的 IP 加白即可;白名单外的请求一律拒绝。本机回环(127.0.0.1)始终放行。</div>
652
826
  <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="zgw-ok">保存</button></div>
653
827
  </div>
654
828
  </div>
@@ -656,13 +830,13 @@
656
830
  <!-- MiniMax 本地接口 -->
657
831
  <div class="mask" id="m-mgw">
658
832
  <div class="modal sm">
659
- <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>
660
834
  <div class="hint" id="mgw-url" style="margin-bottom:10px"></div>
661
835
  <div class="field">
662
836
  <label class="check"><input type="checkbox" id="mgw-on"> 启用本地接口(/gateway/minimax/v1/messages)</label>
663
837
  </div>
664
838
  <div class="field" id="mgw-lan-fields">
665
- <label class="check"><input type="checkbox" id="mgw-lan"> 对局域网开放(需全局局域网已开启)</label>
839
+ <label class="check"><input type="checkbox" id="mgw-lan"> 对局域网开放(重启后绑定 0.0.0.0)</label>
666
840
  <label>IP 白名单(每行一个,支持 192.168.31.* 通配)</label>
667
841
  <textarea class="input" id="mgw-allow" rows="4" placeholder="192.168.31.101"></textarea>
668
842
  </div>
@@ -671,12 +845,57 @@
671
845
  </div>
672
846
  </div>
673
847
 
848
+ <!-- Trae 本地接口 -->
849
+ <div class="mask" id="m-tgw">
850
+ <div class="modal sm">
851
+ <div class="modal-title"><span data-i18n="Trae 本地接口">Trae 本地接口</span><button class="close" data-close>✕</button></div>
852
+ <div class="hint" id="tgw-url" style="margin-bottom:10px"></div>
853
+ <div class="field">
854
+ <label class="check"><input type="checkbox" id="tgw-on"> 启用本地接口(/gateway/trae/v1/messages)</label>
855
+ </div>
856
+ <div class="field" id="tgw-lan-fields">
857
+ <label class="check"><input type="checkbox" id="tgw-lan"> 对局域网开放(重启后绑定 0.0.0.0)</label>
858
+ <label>IP 白名单(每行一个,支持 192.168.31.* 通配)</label>
859
+ <textarea class="input" id="tgw-allow" rows="4" placeholder="192.168.31.101"></textarea>
860
+ </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>
862
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="tgw-ok">保存</button></div>
863
+ </div>
864
+ </div>
865
+
674
866
  <input type="file" id="file-input" accept=".json,application/json" style="display:none">
675
867
  <div id="toast"></div>
676
868
 
677
869
  <script>
678
870
  'use strict';
679
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
+
680
899
  // ── 图标(Lucide 风格,currentColor) ──
681
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>';
682
901
  const I = {
@@ -693,14 +912,24 @@ const I = {
693
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"/>'),
694
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"/>'),
695
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"/>'),
696
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"/>'),
697
919
  chevDown: svg('<path d="M6 9l6 6 6-6"/>'),
698
920
  chevRight: svg('<path d="M9 18l6-6-6-6"/>'),
699
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"/>'),
700
929
  };
701
930
 
702
931
  const $ = (id) => document.getElementById(id);
703
- const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '', logTab: 'run' };
932
+ const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '', logTab: 'run', logLevel: 'all', locale: 'zh-CN' };
704
933
  const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax', 'tenrouter'];
705
934
  // 10Router 集成:config 来自 /api/tenrouter,quotas 来自 /api/tenrouter/quotas
706
935
  const tr = { config: null, quotas: null, error: null, loading: false, syncing: false };
@@ -713,7 +942,7 @@ const PRODUCTS = [
713
942
  { id: 'trae', label: 'Trae' },
714
943
  { id: 'minimax', label: 'MiniMax' },
715
944
  ];
716
- 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);
717
946
 
718
947
  // ── 全局组件开关:不用某个产品 / 10Router 时整块收起(偏好存浏览器本地) ──
719
948
  const COMPONENTS = [
@@ -853,7 +1082,7 @@ const CLIENT_LABEL = { qoder: 'Qoder', workbuddy: 'WorkBuddy', zcode: 'ZCode', m
853
1082
  // 额度单位为 Credits/时长而非「积分」口径的产品(不计入剩余积分合计)
854
1083
  const CREDIT_VIEWS = new Set(['zcode', 'mirasim', 'catpaw']);
855
1084
  // 网关调用日志:tag → 所属产品页(无网关的产品不展示日志卡)
856
- const GW_LOG_TAG = { zcode: 'ZCODE-GW', minimax: 'MINIMAX-GW' };
1085
+ const GW_LOG_TAG = { zcode: 'ZCODE-GW', minimax: 'MINIMAX-GW', trae: 'TRAE-GW' };
857
1086
  const provOf = (p) => PROV[p] || { prod: 'qoder', prodLabel: p, region: '', cls: '' };
858
1087
  const provBadges = (p, regionOnly, flavor) => {
859
1088
  const v = provOf(p);
@@ -862,8 +1091,8 @@ const provBadges = (p, regionOnly, flavor) => {
862
1091
  : flavor === 'zai' ? '<span class="badge intl">Z.ai</span>'
863
1092
  : '<span class="badge cn">BigModel</span>')
864
1093
  : '';
865
- return (regionOnly ? '' : '<span class="badge prod ' + v.prod + '">' + esc(v.prodLabel) + '</span>')
866
- + (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>' : '')
867
1096
  + zBadge;
868
1097
  };
869
1098
 
@@ -891,7 +1120,7 @@ let toastTimer = null;
891
1120
  const armed = {};
892
1121
  function toast(msg, ok) {
893
1122
  const t = $('toast');
894
- t.textContent = msg;
1123
+ t.textContent = T(msg);
895
1124
  t.classList.toggle('err', ok === false);
896
1125
  t.classList.add('show');
897
1126
  clearTimeout(toastTimer);
@@ -914,11 +1143,11 @@ let keyPrompt = null;
914
1143
  /** 弹一次密码输入框(并发的请求共用同一个);返回 true = 已输入 */
915
1144
  function promptKey(invalid) {
916
1145
  if (!keyPrompt) {
917
- keyPrompt = uiPrompt('输入面板访问密码', '', {
918
- type: 'password', okText: '进入',
919
- message: (invalid ? '密码不正确,请重新输入。\n' : '')
920
- + '面板访问密码在右上角「设置」里管理。\n'
921
- + '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 的值。'),
922
1151
  }).then((k) => { keyPrompt = null; if (k === null || !k.trim()) return false; setKey(k.trim()); return true; });
923
1152
  }
924
1153
  return keyPrompt;
@@ -952,24 +1181,217 @@ async function loadSettings() {
952
1181
  try {
953
1182
  const d = await api('/api/settings');
954
1183
  state.sec = { enabled: Boolean(d.panelKeyEnabled), loaded: true };
1184
+ state.wbActivity = d.workbuddyActivity !== false; // WB 国际版活跃请求总开关(默认开)
955
1185
  if (!d.panelKeyEnabled) setKey(''); // 关闭后清掉本地缓存的旧密钥
1186
+ if (d.locale) renderLocale(d.locale);
956
1187
  } catch (e) { /* 401(密码开启但未输入)等状态由 promptKey 流程处理 */ }
957
1188
  }
958
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
+
959
1258
  // ── 主题 ──
1259
+ // 应用的是「解析后的」亮/暗;用户选的三态(跟随系统 / 亮色 / 暗色)另存于 qd_theme,
1260
+ // 由 renderThemeMode() 反映到菜单里的分段控件(主题只有那一个入口)。
960
1261
  function applyTheme(t) {
961
1262
  document.documentElement.dataset.theme = t;
962
- $('btn-theme').innerHTML = t === 'dark' ? I.sun : I.moon;
963
1263
  }
964
1264
  function initTheme() {
965
1265
  let t = null;
966
1266
  try { t = localStorage.getItem('qd_theme'); } catch (e) {}
967
- if (!t) t = window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1267
+ const mq = window.matchMedia ? matchMedia('(prefers-color-scheme: dark)') : null;
1268
+ if (!t) t = mq && mq.matches ? 'dark' : 'light';
968
1269
  applyTheme(t);
969
- $('btn-theme').onclick = () => {
970
- const n = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
971
- applyTheme(n);
972
- try { localStorage.setItem('qd_theme', n); } catch (e) {}
1270
+ // 自动跟随系统:用户没手动切过(localStorage 无值)时,系统换深色/浅色实时响应,
1271
+ // 不用刷新页面;手动切过则尊重用户选择,不再跟系统跑。
1272
+ const onSysTheme = (e) => {
1273
+ let saved = null;
1274
+ try { saved = localStorage.getItem('qd_theme'); } catch (err) {}
1275
+ if (!saved) applyTheme(e.matches ? 'dark' : 'light');
1276
+ };
1277
+ if (mq) {
1278
+ if (mq.addEventListener) mq.addEventListener('change', onSysTheme);
1279
+ else if (mq.addListener) mq.addListener(onSysTheme); // 旧 Safari
1280
+ }
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');
973
1395
  };
974
1396
  }
975
1397
 
@@ -983,17 +1405,17 @@ function statusOf(a) {
983
1405
  const done = state.status && state.status.todayDone && state.status.todayDone[a.id] === today;
984
1406
  const r = a.lastResult;
985
1407
  const fresh = r && r.at && state.status && (Date.now() - new Date(r.at).getTime() < 26 * 3600e3);
986
- 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 };
987
1409
  if (done) {
988
- 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 : '' };
989
1411
  const amt = r && r.status === 'checked-in' && fresh && r.amount ? ' +' + fmtNum(r.amount) : '';
990
- const streak = r && !intl && r.streakDays ? ' · 连续 ' + r.streakDays + ' 天' : '';
991
- 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 : '' };
992
1414
  }
993
- const failText = intl ? '活跃失败' : '领取失败';
1415
+ const failText = intl ? T('活跃失败') : T('领取失败');
994
1416
  if (r && fresh && r.status === 'failed') return { cls: 'fail', text: failText, msg: r.message, err: true };
995
- if (r && fresh && r.status === 'no-activity') return { cls: 'pending', text: intl ? '待活跃' : '待领取', msg: r.message };
996
- 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: '' };
997
1419
  }
998
1420
 
999
1421
  function expiryCell(a) {
@@ -1002,11 +1424,11 @@ function expiryCell(a) {
1002
1424
  if (q && q.planExpiresAt) {
1003
1425
  const msPlan = new Date(q.planExpiresAt).getTime() - Date.now();
1004
1426
  const planName = q.plan ? esc(q.plan) + ' ' : '';
1005
- if (msPlan <= 0) return { label: planName + '已到期', warn: true, title: '套餐已到期' };
1427
+ if (msPlan <= 0) return { label: planName + T('已到期'), warn: true, title: T('套餐已到期') };
1006
1428
  return {
1007
1429
  label: planName + fmtTime(q.planExpiresAt).slice(0, 10),
1008
1430
  warn: msPlan < 3 * 86400e3,
1009
- title: '套餐有效期至 ' + fmtTime(q.planExpiresAt),
1431
+ title: T('套餐有效期至 ') + fmtTime(q.planExpiresAt),
1010
1432
  isPlan: true,
1011
1433
  };
1012
1434
  }
@@ -1014,17 +1436,17 @@ function expiryCell(a) {
1014
1436
  // 具有 refreshToken 的账号会自动在后台续期,短期 exp 属于正常轮换,不应显示刺眼红字误导用户
1015
1437
  if (a.hasRefreshToken) {
1016
1438
  return {
1017
- label: '凭据有效 · 自动续期',
1439
+ label: T('凭据有效 · 自动续期'),
1018
1440
  warn: false,
1019
- title: a.expiresAt ? '本次登录凭据有效期至 ' + fmtTime(a.expiresAt) + '(过期将自动通过 refreshToken 刷新续期,无需手动干预)' : '支持自动刷新续期',
1441
+ title: a.expiresAt ? T('本次登录凭据有效期至 ') + fmtTime(a.expiresAt) + T('(过期将自动通过 refreshToken 刷新续期,无需手动干预)') : T('支持自动刷新续期'),
1020
1442
  isAuto: true,
1021
1443
  };
1022
1444
  }
1023
1445
 
1024
- if (!a.expiresAt) return { label: a.isPat ? 'PAT 长期有效' : '—', warn: false };
1446
+ if (!a.expiresAt) return { label: a.isPat ? T('PAT 长期有效') : '—', warn: false };
1025
1447
  const ms = new Date(a.expiresAt).getTime() - Date.now();
1026
- if (ms <= 0) return { label: '已过期', warn: true, title: '登录凭据已过期' };
1027
- 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) };
1028
1450
  }
1029
1451
 
1030
1452
  // 通用相对重置标签:「3 天 后重置」「45 分钟 后到期」;一小时内标红,悬停看绝对时间
@@ -1033,11 +1455,11 @@ function relReset(q) {
1033
1455
  if (!at) return null;
1034
1456
  const ms = new Date(at).getTime() - Date.now();
1035
1457
  if (!Number.isFinite(ms)) return null;
1036
- if (ms <= 0) return { text: '即将' + (q.recurring === false ? '到期' : '重置'), soon: true };
1458
+ if (ms <= 0) return { text: T('即将') + (q.recurring === false ? T('到期') : T('重置')), soon: true };
1037
1459
  const m = Math.ceil(ms / 60000);
1038
1460
  const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mm = m % 60;
1039
- const dur = d > 0 ? d + ' 天' + (h ? h + ' 小时' : '') : h > 0 ? h + ' 小时' + (mm ? mm + ' 分' : '') : Math.max(1, mm) + ' 分钟';
1040
- 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 };
1041
1463
  }
1042
1464
  function relResetHtml(q) {
1043
1465
  const r = relReset(q);
@@ -1057,38 +1479,38 @@ function trLine(name, valHtml, title) {
1057
1479
 
1058
1480
  function creditHtml(a) {
1059
1481
  const q = state.quotas[a.id];
1060
- if (!q) return '<div class="credit-line"><span class="dim">读取积分中…</span></div><div class="bar"></div>';
1061
- 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>';
1062
- 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>');
1063
1485
 
1064
1486
  // 妙手:Credits 钱包余额 + 当前版本(体验版 / 专业版),无「总量」口径,不画水位条
1065
1487
  if (a.product === 'catpaw') {
1066
1488
  const planBadge = q.plan ? '<span class="badge">' + esc(q.plan) + '</span>' : '';
1067
- const expHtml = q.planExpiresAt ? '<span class="dim">' + fmtTime(q.planExpiresAt).slice(0, 10) + ' 到期</span>'
1068
- : q.nextRefreshAt ? '<span class="dim">' + fmtTime(q.nextRefreshAt).slice(0, 10) + ' 下次刷新</span>' : '';
1069
- 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>'
1070
1492
  + '<span class="credit dim">' + planBadge + (planBadge && expHtml ? ' · ' : '') + expHtml + '</span></div>';
1071
1493
  }
1072
1494
 
1073
1495
  // 多窗口配额体系(mirasim 等):每个窗口平等等权,独立一行带分散对齐的名称、数值与三档细水位条
1074
1496
  if (a.product === 'mirasim' && q.parts && q.parts.length) {
1075
1497
  const isExhausted = q.exceeded || (q.remaining <= 0);
1076
- const planBadge = q.plan ? '<span class="badge">' + esc(q.plan) + ' 套餐</span>' : '';
1077
- 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>') : '';
1078
1500
  // 状态采用温和的徽章,避免整行大红字制造恐慌
1079
1501
  const topStatus = isExhausted
1080
- ? '<span class="pill pending">' + esc(q.bottleneckLabel || '额度已用尽') + '</span>'
1081
- : '<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>';
1082
1504
 
1083
- 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)));
1084
1506
  const rows = q.parts.map((p) => {
1085
1507
  const pct = p.remainingPercentage ?? (p.total > 0 ? Math.max(0, Math.min(100, (p.remaining / p.total) * 100)) : 0);
1086
1508
  const isZero = pct <= 0;
1087
1509
  const numTxt = p.total > 0 ? fmtUnits(p.remaining) + ' / ' + fmtUnits(p.total) : '';
1088
1510
  // 数值保持中性温和排版,不用鲜红大字恐吓用户
1089
1511
  const pctTxt = isZero
1090
- ? '<span class="dim">剩 0%</span>'
1091
- : '<span>剩 ' + fmtNum(pct) + '%</span>';
1512
+ ? T('<span class="dim">剩 0%</span>')
1513
+ : T('<span>剩 ') + fmtNum(pct) + '%</span>';
1092
1514
  const valHtml = pctTxt + (numTxt ? ' <small class="dim">(' + numTxt + ')</small>' : '') + ' ' + relResetHtml(p);
1093
1515
  return trLine(p.name, valHtml) + trMeter(pct);
1094
1516
  }).join('');
@@ -1098,16 +1520,16 @@ function creditHtml(a) {
1098
1520
  + '<div class="mirasim-windows">' + rows + '</div>';
1099
1521
  }
1100
1522
 
1101
- const unit = q.unit ? ' ' + q.unit : '';
1523
+ const unit = q.unit ? ' ' + T(q.unit) : '';
1102
1524
  if (q.unit) {
1103
1525
  const items = q.parts.map((x) => {
1104
- const base = esc(x.name) + ' ' + fmtNum(x.remaining) + '/' + fmtNum(x.total) + (x.unit ? ' ' + esc(x.unit) : '');
1105
- if (x.pending && x.startsAt) return base + '(' + fmtTime(x.startsAt).slice(5, 16) + ' 生效)';
1106
- 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(' 到期)')) : '');
1107
1529
  }).join(' · ');
1108
1530
  const pct = q.total > 0 ? Math.min(1, q.remaining / q.total) : 0;
1109
1531
  // 满额(100%)不再渲染灰色「已使用」尾段——它只占 0 宽也会造成几像素的灰边
1110
- 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>'
1111
1533
  + '<span class="credit dim" title="' + items + '">' + (q.plan ? esc(q.plan) + ' · ' : '') + items + '</span></div>'
1112
1534
  + '<div class="bar"><span class="seg" style="flex:' + pct.toFixed(4) + ' 1 0"></span>'
1113
1535
  + (pct < 0.9999 ? '<span class="seg used" style="flex:' + (1 - pct).toFixed(4) + ' 1 0"></span>' : '')
@@ -1118,14 +1540,14 @@ function creditHtml(a) {
1118
1540
  if (total > 0) {
1119
1541
  q.parts.filter((x) => x.remaining > 0).forEach((x, i) => {
1120
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="'
1121
- + 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>';
1122
1544
  });
1123
- 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>';
1124
1546
  }
1125
1547
  const soon = q.parts.filter((x) => x.remaining > 0 && !x.recurring && x.expiresAt).sort((x, y) => x.expiresAt.localeCompare(y.expiresAt))[0];
1126
1548
  return '<div class="credit-line">'
1127
- + '<span class="credit"><span class="lbl">剩余</span>' + I.credit + '<b>' + fmtNum(q.remaining) + '</b></span>'
1128
- + '<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>'
1129
1551
  + '</div><div class="bar">' + bar + '</div>';
1130
1552
  }
1131
1553
 
@@ -1137,29 +1559,31 @@ function cardHtml(a) {
1137
1559
  const canCheckin = CHECKIN_PRODUCTS.has(a.product);
1138
1560
  return '<div class="card" draggable="true" data-id="' + esc(a.id) + '">'
1139
1561
  + '<div class="row1"><div class="name-group">'
1140
- + '<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>'
1141
1563
  + provBadges(a.provider, true, a.flavor)
1142
- + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] || '') + ' 客户端当前登录的账号">客户端当前</span>' : '')
1143
- + (a.product === 'zcode' && isGatewayCurrent(a) ? '<span class="pill gw" title="体验包接口最近一次补全用的账号(多账号时按轮换自动选择,与客户端登录无关)">接口使用中</span>' : '')
1144
- + (a.product === 'minimax' && isMiniMaxGatewayCurrent(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>') : '')
1145
1568
  + (a.isPat ? '<span class="badge">PAT</span>' : '')
1146
- + (a.verified === false ? '<span class="pill fail">token 无效</span>' : '')
1569
+ + qoderWebBadge(a)
1570
+ + (a.verified === false ? T('<span class="pill fail">token 无效</span>') : '')
1147
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>' : '')
1148
1572
  + '</div><div class="ops">'
1149
- + (a.product === 'zcode' ? '<button class="icon-btn" data-act="claim" title="活动领取(billing/preview → claim,可能需过一次验证码)">' + I.gift + '</button>' : '')
1150
- + (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>' : '')
1151
- + (a.canSwitch && !isCurrent && st.cls !== 'fail' ? '<button class="icon-btn" data-act="switch" title="切换 ' + (CLIENT_LABEL[a.product] || prodLabel(a.product)) + ' 客户端到此账号">' + I.swap + '</button>' : '')
1152
- + '<button class="icon-btn" data-act="quota" title="刷新积分">' + I.refresh + '</button>'
1153
- + '<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>'
1154
1578
  + '</div></div>'
1155
1579
  + '<div class="meta">'
1156
- + '<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>'
1157
- + '<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>'
1158
1582
  + '<span class="val' + (ex.warn ? ' warn' : '') + '" title="' + esc(ex.title || '') + '">' + esc(ex.label) + '</span></div>'
1159
1583
  + '</div>'
1160
1584
  + creditHtml(a)
1161
1585
  + (st.msg && !(state.quotas[a.id] && state.quotas[a.id].error && state.quotas[a.id].error.includes(st.msg.slice(-24)))
1162
- ? '<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>' : '')
1163
1587
  + '</div>';
1164
1588
  }
1165
1589
 
@@ -1183,6 +1607,11 @@ function isMiniMaxGatewayCurrent(a) {
1183
1607
  const last = state.mGatewayStats && state.mGatewayStats.lastAccount;
1184
1608
  return Boolean(last) && (a.name === last || a.uid === last || a.id === last);
1185
1609
  }
1610
+ /** Trae 本地接口使用中判定 */
1611
+ function isTraeGatewayCurrent(a) {
1612
+ const last = state.tGatewayStats && state.tGatewayStats.lastAccount;
1613
+ return Boolean(last) && (a.name === last || a.uid === last || a.id === last);
1614
+ }
1186
1615
 
1187
1616
  // 客户端当前登录判定:WorkBuddy / mirasim / 妙手 / MiniMax 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
1188
1617
  function isClientCurrent(a) {
@@ -1235,7 +1664,7 @@ const EMPTY_TIP = {
1235
1664
  mirasim: '还没有 mirasim 账号。<br>点「添加账号」可读取本机 mirasim 客户端当前登录的账号(本地解密,token 不出本机)。',
1236
1665
  catpaw: '还没有妙手账号。<br>点「添加账号」可读取本机妙手(美团 CatPaw)客户端当前登录的账号(本地解密凭据,token 不出本机)。',
1237
1666
  trae: '还没有 Trae 账号。<br>点「添加账号 → 本机导入」可读取本机 Trae 客户端当前登录的账号(本地解密 storage.json 凭据,token 不出本机)。多个账号需先在 Trae 里分别登录并逐个导入。',
1238
- minimax: '还没有 MiniMax 账号。<br>点「添加账号 → 本机导入」可读取本机 MiniMax Code 客户端当前登录的账号,或粘贴 Bearer Token 添加。',
1667
+ minimax: '还没有 MiniMax 账号。<br>点「添加账号 → 本机导入」可读取本机 MiniMax Code 客户端当前登录的账号,或用浏览器登录 / 粘贴 Bearer Token 添加。',
1239
1668
  };
1240
1669
 
1241
1670
  function renderProd() {
@@ -1243,7 +1672,7 @@ function renderProd() {
1243
1672
  const all = ofProduct(prod);
1244
1673
  const acc = all.filter((a) => !state.region || provOf(a.provider).cls === state.region);
1245
1674
  const list = $('list');
1246
- 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>';
1247
1676
  else list.innerHTML = sortedAccounts(prod, acc).map(cardHtml).join('');
1248
1677
  const canCheckin = CHECKIN_PRODUCTS.has(prod);
1249
1678
  $('s-count').textContent = acc.length;
@@ -1251,16 +1680,16 @@ function renderProd() {
1251
1680
  if (prod === 'workbuddy') {
1252
1681
  // 国内版 = 每日签到送积分;国际版 = 保持活跃(活跃不等于必有奖励)
1253
1682
  const cnAcc = acc.filter((a) => a.provider === 'workbuddy');
1254
- const intlAcc = acc.filter((a) => a.provider === 'workbuddy-intl');
1255
- $('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>' : '')
1256
- + (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>' : '');
1257
1686
  } else {
1258
1687
  // 从 WorkBuddy 页切回来时 s-done 可能被 innerHTML 换没了,先重建结构再写值
1259
- 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>');
1260
1689
  $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
1261
1690
  }
1262
1691
  const c = creditSum(acc);
1263
- $('s-credits-label').textContent = CREDIT_VIEWS.has(prod) ? '有效套餐' : '剩余积分';
1692
+ $('s-credits-label').textContent = CREDIT_VIEWS.has(prod) ? T('有效套餐') : T('剩余积分');
1264
1693
  $('s-credits').textContent = CREDIT_VIEWS.has(prod)
1265
1694
  ? acc.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length + ' / ' + acc.length
1266
1695
  : c.loading ? '…' : (c.known ? fmtNum(c.sum) : '-');
@@ -1270,8 +1699,8 @@ function renderProd() {
1270
1699
  document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
1271
1700
  $('btn-checkin').style.display = canCheckin ? '' : 'none';
1272
1701
  if (!$('btn-checkin').disabled) {
1273
- const hasIntl = acc.some((a) => a.provider === 'workbuddy-intl');
1274
- $('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));
1275
1704
  }
1276
1705
  // 顶栏产品专属入口:ZCode 设置组、Trae 打开客户端,带状态灯(运行中绿点呼吸)
1277
1706
  const dot = (on, pulse) => '<span class="dot' + (on ? ' on' : '') + (on && pulse ? ' pulse' : '') + '"></span>';
@@ -1279,41 +1708,42 @@ function renderProd() {
1279
1708
  if (prod === 'zcode') {
1280
1709
  const acOn = state.zAutoClaim === true;
1281
1710
  const gwOn = state.zGateway === true;
1282
- $('zt-ac').innerHTML = dot(acOn) + '自动领取';
1283
- $('zt-ac').title = acOn ? '自动领取运行中——点我调整轮询周期 / 运行时长' : '自动领取已关闭——点我设置轮询周期与运行时长并开启';
1284
- $('zt-net').innerHTML = '出口:<b style="font-weight:700;color:var(--text)">' + (state.zProxyFirst === true ? '代理优先' : '直连优先') + '</b>';
1285
- $('zt-net').title = '网络出口:直连 / 代理顺序与代理地址';
1286
- $('zt-gw').innerHTML = dot(gwOn) + '接口' + (gwOn && state.zGatewayLan === true ? '·局域网' : '');
1287
- $('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 白名单');
1288
1715
  const zc = (state.status && state.status.zcodeClient) || {};
1289
1716
  const canOpen = zc.installed && window.creditdaddy && typeof window.creditdaddy.openZcodeClient === 'function';
1290
1717
  $('zt-open').style.display = canOpen ? '' : 'none';
1291
1718
  if (canOpen) {
1292
- $('zt-open').innerHTML = dot(zc.running === true, true) + '打开客户端';
1293
- $('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 客户端未运行,点我启动');
1294
1721
  }
1295
1722
  }
1296
1723
  $('t-actions').style.display = prod === 'trae' ? 'contents' : 'none';
1297
1724
  if (prod === 'trae') {
1725
+ const gwOn = state.tGateway === true;
1726
+ $('tt-gw').innerHTML = dot(gwOn) + T('接口') + (gwOn && state.tGatewayLan === true ? T('·局域网') : '');
1727
+ $('tt-gw').title = T('Trae 本地接口:开关、局域网开放与 IP 白名单');
1298
1728
  const tc = (state.status && state.status.traeClient) || {};
1299
1729
  const canOpen = tc.installed && window.creditdaddy && typeof window.creditdaddy.openTraeClient === 'function';
1300
1730
  $('tt-open').style.display = canOpen ? '' : 'none';
1301
1731
  if (canOpen) {
1302
- $('tt-open').innerHTML = dot(tc.running === true, true) + '打开客户端';
1303
- $('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 客户端未运行,点我启动');
1304
1734
  }
1305
1735
  }
1306
1736
  $('m-actions').style.display = prod === 'minimax' ? 'contents' : 'none';
1307
1737
  if (prod === 'minimax') {
1308
1738
  const gwOn = state.mGateway === true;
1309
- $('mt-gw').innerHTML = dot(gwOn) + '接口' + (gwOn && state.mGatewayLan === true ? '·局域网' : '');
1310
- $('mt-gw').title = 'MiniMax 本地接口:开关、局域网开放与 IP 白名单';
1739
+ $('mt-gw').innerHTML = dot(gwOn) + T('接口') + (gwOn && state.mGatewayLan === true ? T('·局域网') : '');
1740
+ $('mt-gw').title = T('MiniMax 本地接口:开关、局域网开放与 IP 白名单');
1311
1741
  const mc = (state.status && state.status.minimaxClient) || {};
1312
1742
  const canOpen = mc.installed && window.creditdaddy && typeof window.creditdaddy.openMiniMaxClient === 'function';
1313
1743
  $('mt-open').style.display = canOpen ? '' : 'none';
1314
1744
  if (canOpen) {
1315
- $('mt-open').innerHTML = dot(mc.running === true, true) + '打开客户端';
1316
- $('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 客户端未运行,点我启动');
1317
1747
  }
1318
1748
  }
1319
1749
  }
@@ -1334,29 +1764,29 @@ function renderDash() {
1334
1764
  const byProd = orderedVisibleComps().filter((id) => id !== 'tenrouter').map((id) => prodLabel(id) + ' ' + ofProduct(id).length).join(' · ');
1335
1765
  const creditByProd = ['qoder', 'workbuddy', 'minimax'].filter(compVisible).map((id) => { const c = creditSum(ofProduct(id)); return prodLabel(id) + ' ' + (c.known ? fmtNum(c.sum) : '-'); }).join(' · ');
1336
1766
  // 今日进度拆「国内领取 / 国际活跃」:国际版 WorkBuddy 是保持活跃(活跃不等于必有奖励)
1337
- const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl');
1767
+ const intlAcc = signable.filter((a) => a.provider === 'workbuddy-intl' && wbActOn());
1338
1768
  const domAcc = signable.filter((a) => a.provider !== 'workbuddy-intl');
1339
1769
  const doneDom = domAcc.filter(isDone).length;
1340
1770
  const doneIntl = intlAcc.filter(isDone).length;
1341
1771
  $('kpis').innerHTML =
1342
- kpi('账号', state.accounts.length, byProd)
1343
- + kpi('今日领取' + (intlAcc.length ? ' / 活跃' : ''), intlAcc.length
1344
- ? 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>'
1345
1775
  : done + ' <small>/ ' + signable.length + '</small>',
1346
- !signable.length ? '暂无可领取的账号'
1347
- : (doneDom === domAcc.length && doneIntl === intlAcc.length ? (intlAcc.length ? '今天全部完成(国际为活跃请求,不保证奖励)' : '今天全部签完')
1348
- : '待办 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : '')),
1349
- '领取 = 国内版每日签到;活跃 = 国际版定期活跃请求(保持资格,不保证奖励)')
1350
- + kpi('剩余积分', credit.loading ? '…' : (credit.known ? fmtNum(credit.sum) : '-'), credit.loading ? '正在读取各账号额度…' : creditByProd)
1351
- + kpi('下次自动领取', sch.ticking ? '领取中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
1352
- 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 小时一轮'));
1353
1783
 
1354
1784
  // 仪表盘产品卡按组件设置里的顺序渲染(与标签栏同步),10Router 汇总卡按其顺序插入
1355
1785
  const prodCardHtml = (p) => {
1356
1786
  const list = ofProduct(p.id);
1357
1787
  let rows = '';
1358
1788
  if (!list.length) {
1359
- rows = '<span class="lbl">账号</span><span class="val dim">还没有,点击进入添加</span>';
1789
+ rows = '<span class="lbl">' + T('账号') + '</span><span class="val dim">' + T('还没有,点击进入添加') + '</span>';
1360
1790
  } else if (CHECKIN_PRODUCTS.has(p.id)) {
1361
1791
  const d = list.filter(isDone).length;
1362
1792
  const c = creditSum(list);
@@ -1365,57 +1795,57 @@ function renderDash() {
1365
1795
  const cnList = list.filter((a) => a.provider === 'workbuddy');
1366
1796
  const intlList = list.filter((a) => a.provider === 'workbuddy-intl');
1367
1797
  const parts = [];
1368
- if (cnList.length) parts.push(cnList.filter(isDone).length + ' / ' + cnList.length + '(国内)');
1369
- if (intlList.length) parts.push(intlList.filter(isDone).length + ' / ' + intlList.length + '(国际 · 不保证奖励)');
1370
- rows += '<span class="lbl">领取 / 活跃</span><span class="val" title="' + esc(parts.join(',') + (intlList.length ? '(国际为活跃请求,不保证奖励)' : '')) + '">'
1371
- + (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(' 国内') : '')
1372
1802
  + (cnList.length && intlList.length ? ' · ' : '')
1373
- + (intlList.length ? intlList.filter(isDone).length + ' / ' + intlList.length + ' 国际' : '')
1803
+ + (intlList.length ? intlList.filter(isDone).length + ' / ' + intlList.length + T(' 国际') : '')
1374
1804
  + '</span>';
1375
1805
  } else if (p.id === 'minimax') {
1376
1806
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1377
- 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>'
1378
1808
  + '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
1379
- + '<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>';
1380
1810
  } else {
1381
- 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>';
1382
1812
  }
1383
1813
  if (p.id !== 'minimax') {
1384
1814
  rows += '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
1385
- + '<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>';
1386
1816
  }
1387
1817
  } else if (p.id === 'mirasim') {
1388
1818
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1389
- rows = '<span class="lbl">额度体系</span><span class="val dim">5h / 7d 滚动窗口</span>'
1390
- + '<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>';
1391
1821
  } else if (p.id === 'catpaw') {
1392
1822
  const withPlan = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && q.plan; });
1393
1823
  const credits = withPlan.reduce((s, a) => s + (Number(state.quotas[a.id].remaining) || 0), 0);
1394
- rows = '<span class="lbl">额度体系</span><span class="val dim">Credits 余额 + 套餐版本</span>'
1395
- + (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>') : '');
1396
1826
  } else {
1397
1827
  const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
1398
1828
  // ZCode 没有每日签到:不展示签到语义的行
1399
- rows = '<span class="lbl">活动领取</span><span class="val dim">手动领取,或在标签页开自动轮询</span>'
1400
- + '<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>';
1401
1831
  }
1402
1832
  if (p.id === 'qoder' && list.some((a) => a.provider === 'qoder')) {
1403
- 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>';
1404
1834
  }
1405
1835
  if (CLIENT_LABEL[p.id]) {
1406
1836
  const cur = list.find((a) => a.product === p.id && isClientCurrent(a));
1407
1837
  const zid = p.id === 'zcode' ? s.zcodeCurrentIdentity : null;
1408
1838
  const live = cur ? esc(cur.name || cur.email || cur.uid)
1409
- : zid && (zid.email || zid.uid || zid.username) ? '未导入的账号(' + esc(zid.email || zid.username || zid.uid) + ')'
1410
- : p.id === 'mirasim' && s.mirasimCurrentUid ? '未导入的账号'
1411
- : p.id === 'catpaw' && s.catpawClient && s.catpawClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1412
- : p.id === 'trae' && s.traeClient && s.traeClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1413
- : p.id === 'minimax' && s.minimaxClient && s.minimaxClient.signedIn && !list.some((a) => isClientCurrent(a)) ? '未导入的账号'
1414
- : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? '未导入的账号' : '—');
1415
- 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>';
1416
1846
  }
1417
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
1418
- + '<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>';
1419
1849
  };
1420
1850
  $('prods').innerHTML = orderedVisibleComps().map((id) => {
1421
1851
  if (id === 'tenrouter') return trDashCard();
@@ -1426,101 +1856,112 @@ function renderDash() {
1426
1856
  // 需要处理:token 失效 / 即将过期、领取失败、国际版缺风控身份、10Router 额度告急 / 查询失败
1427
1857
  const att = [];
1428
1858
  if (!s.riskIdentity && state.accounts.some((a) => a.provider === 'qoder')) {
1429
- 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 客户端,国际版无法领取') });
1430
1860
  }
1431
1861
  for (const a of state.accounts) {
1432
1862
  const ex = expiryCell(a);
1433
1863
  const st = statusOf(a);
1434
1864
  const base = { view: a.product, name: a.name || a.email || a.id, badges: provBadges(a.provider) };
1435
- if (a.verified === false) att.push(Object.assign({ pill: 'fail', text: 'token 无效' }, base));
1436
- else if (ex.label === '已过期') att.push(Object.assign({ pill: 'fail', text: 'token 已过期' }, base));
1437
- else if (ex.warn) att.push(Object.assign({ pill: 'limited', text: ex.label.slice(5, 10) + ' 过期' }, base));
1438
- 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));
1439
1869
  }
1440
1870
  for (const c of (tr.quotas && tr.quotas.connections) || []) {
1441
1871
  if (!compVisible('tenrouter')) break;
1442
1872
  const base = { view: 'tenrouter', name: c.name || c.email || c.providerName, badges: '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>' };
1443
- if (c.error) att.push(Object.assign({ pill: 'fail', text: '额度查询失败', title: c.error }, base));
1444
- 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));
1445
1875
  }
1446
1876
  // 隐藏掉的组件不进「需要处理」(如收起 10Router 后不再展示其额度告急)
1447
1877
  const attVisible = att.filter((x) => compVisible(x.view));
1448
- $('att-count').textContent = attVisible.length ? attVisible.length + ' 项' : '';
1878
+ $('att-count').textContent = attVisible.length ? attVisible.length + T(' 项') : '';
1449
1879
  $('attention').innerHTML = attVisible.length
1450
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>'
1451
1881
  + '<span class="item-side"><span class="pill ' + x.pill + '">' + esc(x.text) + '</span></span></div>').join('')
1452
- + (attVisible.length > 8 ? '<div class="muted-empty">还有 ' + (attVisible.length - 8) + ' 项</div>' : '')
1453
- : '<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>';
1454
1884
 
1455
1885
  // 最近领取记录
1456
- 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('失败')] };
1457
1887
  const recent = state.accounts.filter((a) => a.lastResult && a.lastResult.at && compVisible(a.product))
1458
1888
  .sort((x, y) => String(y.lastResult.at).localeCompare(String(x.lastResult.at))).slice(0, 8);
1459
- $('last-tick').textContent = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) : '';
1889
+ $('last-tick').textContent = sch.lastTick ? T('上一轮 ') + fmtTime(sch.lastTick.at).slice(5) : '';
1460
1890
  $('recent').innerHTML = recent.length
1461
1891
  ? recent.map((a) => {
1462
1892
  const r = a.lastResult;
1463
1893
  let [cls, txt] = RES[r.status] || ['pending', r.status];
1464
1894
  // 国际版 WorkBuddy 是保持活跃(不保证奖励),不叫「领取」
1465
- if (a.provider === 'workbuddy-intl' && r.status === 'checked-in') txt = '活跃达成';
1895
+ if (a.provider === 'workbuddy-intl' && r.status === 'checked-in') txt = T('活跃达成');
1466
1896
  const label = r.status === 'checked-in' && r.amount ? txt + ' +' + fmtNum(r.amount) : txt;
1467
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>'
1468
1898
  + '<span class="item-side"><span class="pill ' + cls + '">' + esc(label) + '</span><span class="item-time">' + fmtTime(r.at).slice(5) + '</span></span></div>';
1469
1899
  }).join('')
1470
- : '<div class="muted-empty">还没有领取记录</div>';
1900
+ : '<div class="muted-empty">' + T('还没有领取记录') + '</div>';
1471
1901
  }
1472
1902
 
1473
1903
  function renderStatus() {
1474
1904
  const s = state.status;
1475
1905
  if (!s) return;
1476
- $('ver').textContent = 'v' + s.version + ' · 数据目录 ' + s.dataDir;
1906
+ $('ver').textContent = 'v' + s.version + T(' · 数据目录 ') + s.dataDir;
1477
1907
  document.title = 'CreditDaddy v' + s.version;
1478
- 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> 提权重)
1479
1913
  const sch = s.scheduler || {};
1480
- $('sched').textContent = sch.ticking ? '正在领取…'
1481
- : sch.nextTickAt ? '下次自动领取 ' + fmtTime(sch.nextTickAt).slice(11) : '';
1482
- $('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 : '';
1483
1924
  // 产品相关提示只在对应标签页出现
1484
1925
  const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
1485
1926
  const n = $('notice');
1486
1927
  n.classList.toggle('info', state.view === 'zcode' || state.view === 'mirasim' || state.view === 'catpaw' || state.view === 'trae' || state.view === 'minimax' || (hasIntl && Boolean(s.riskIdentity)));
1487
1928
  if (state.view === 'minimax') {
1488
1929
  n.style.display = '';
1489
- 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>');
1490
1931
  } else if (state.view === 'trae') {
1491
1932
  n.style.display = '';
1492
- 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>');
1493
1934
  } else if (state.view === 'catpaw') {
1494
1935
  n.style.display = '';
1495
- n.innerHTML = '妙手(美团 CatPaw)按套餐发放 Credits(体验版 / 专业版…),没有每日签到。凭据由妙手客户端登录管理:读取额度用本机解密出的登录 token 直连桌面网关,token 失效时到妙手客户端重新登录后再「本机导入」即可。'
1496
- + '多账号切换:写入本机凭据后重启妙手客户端即生效(切换会自动保存当前登录,不丢号)。'
1936
+ n.innerHTML = T('妙手(美团 CatPaw)按套餐发放 Credits(体验版 / 专业版…),没有每日签到。凭据由妙手客户端登录管理:读取额度用本机解密出的登录 token 直连桌面网关,token 失效时到妙手客户端重新登录后再「本机导入」即可。')
1937
+ + T('多账号切换:写入本机凭据后重启妙手客户端即生效(切换会自动保存当前登录,不丢号)。')
1497
1938
  + catpawClientChip(s);
1498
1939
  } else if (state.view === 'mirasim') {
1499
1940
  n.style.display = '';
1500
- n.innerHTML = 'mirasim(原生 AI 编程开发环境)额度按「平台额度」(5h 滚动窗口、7d 全局上限与分模型上限)分配。Token 约 1 小时过期,读取额度时会自动用 refreshToken 静默续期并同步回客户端,无需手动处理。'
1501
- + '切换账号需要至少两个已导入的账号(当前登录 + 目标账号):先在「添加账号 → 本机导入」把各账号分别导入,切换时当前登录会自动保存、目标账号写入客户端,桌面版切完自动重启 mirasim。'
1941
+ n.innerHTML = T('mirasim(原生 AI 编程开发环境)额度按「平台额度」(5h 滚动窗口、7d 全局上限与分模型上限)分配。Token 约 1 小时过期,读取额度时会自动用 refreshToken 静默续期并同步回客户端,无需手动处理。')
1942
+ + T('切换账号需要至少两个已导入的账号(当前登录 + 目标账号):先在「添加账号 → 本机导入」把各账号分别导入,切换时当前登录会自动保存、目标账号写入客户端,桌面版切完自动重启 mirasim。')
1502
1943
  + mirasimClientChip(s);
1503
1944
  } else if (state.view === 'zcode') {
1504
1945
  n.style.display = '';
1505
1946
  const ac = state.zAutoClaim === true;
1506
1947
  const ziv = state.zClaimIntervalMin || 2;
1507
1948
  const zwin = state.zClaimWindowMin === 0 ? 0 : (state.zClaimWindowMin || 60);
1508
- n.innerHTML = '<div class="z-row"><span class="z-label">活动领取</span><span class="z-text">活动包积分,每日 0 点左右刷新(无每日签到)。自动领取'
1509
- + (ac ? '每 ' + ziv + ' 分钟检查资格' + (zwin > 0 ? ',本次运行 ' + zwin + ' 分钟' : ',持续运行') : '未开启(右上角设置轮询计划)')
1510
- + ';礼物图标 = 手动临时领。</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>');
1511
1952
  } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
1512
1953
  n.style.display = '';
1513
1954
  const busy = s.umid.installing || umidBusy;
1514
- n.innerHTML = 'Qoder 国际版只向带设备风控身份的请求下发「每天领 100 Credits」。这台 Linux / NAS 上没有 Qoder 客户端,'
1515
- + '可以安装「设备身份组件」:从 npm 官方包 @qoder-ai/qodercli 提取其内置的 UMID 程序(约 30MB 下载,校验 npm 完整性),装好后国际版即可领取(本机每天限领一个账号)。'
1516
- + ' <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>';
1517
1958
  } else if (hasIntl && !s.riskIdentity) {
1518
1959
  n.style.display = '';
1519
- n.textContent = 'Qoder 国际版领取需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。';
1960
+ n.textContent = T('Qoder 国际版领取需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。');
1520
1961
  } else if (hasIntl) {
1521
1962
  n.style.display = '';
1522
- n.textContent = '提示:Qoder 国际版每台设备每天只能有一个账号领取每日 100 Credits(服务端按设备限领),其余国际版账号会显示「本机已领」。'
1523
- + (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(')') : '');
1524
1965
  } else {
1525
1966
  n.style.display = 'none';
1526
1967
  }
@@ -1529,20 +1970,20 @@ function renderStatus() {
1529
1970
  // mirasim 客户端状态芯片 + 「打开客户端」按钮
1530
1971
  function mirasimClientChip(s) {
1531
1972
  const c = s.mirasimClient || {};
1532
- if (!c.installed) return ' <span class="dim">客户端:未安装</span>';
1973
+ if (!c.installed) return T(' <span class="dim">客户端:未安装</span>');
1533
1974
  const canOpen = window.creditdaddy && typeof window.creditdaddy.openMirasimClient === 'function';
1534
- return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + '</span>'
1535
- + (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>') : '');
1536
1977
  }
1537
1978
 
1538
1979
 
1539
1980
  // 妙手客户端状态芯片 + 「打开客户端」按钮
1540
1981
  function catpawClientChip(s) {
1541
1982
  const c = s.catpawClient || {};
1542
- if (!c.installed) return ' <span class="dim">客户端:未安装(本机导入 / 切换需要 Windows 妙手客户端)</span>';
1983
+ if (!c.installed) return T(' <span class="dim">客户端:未安装(本机导入 / 切换需要 Windows 妙手客户端)</span>');
1543
1984
  const canOpen = window.creditdaddy && typeof window.creditdaddy.openCatpawClient === 'function';
1544
- return ' <span class="dim">客户端:' + (c.running ? '运行中' : '未运行') + (c.signedIn ? '' : ' · 未登录') + '</span>'
1545
- + (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>') : '');
1546
1987
  }
1547
1988
 
1548
1989
  // Qoder 设备身份组件(Linux / fnOS)
@@ -1552,7 +1993,7 @@ async function installUmid() {
1552
1993
  umidBusy = true; renderStatus();
1553
1994
  try {
1554
1995
  const r = await api('/api/qoder/umid/install', { method: 'POST' });
1555
- toast('设备身份组件已安装(qodercli ' + r.installed.version + '),Qoder 国际版可以领取了');
1996
+ toast(T('设备身份组件已安装(qodercli ') + r.installed.version + T('),Qoder 国际版可以领取了'));
1556
1997
  } catch (e) { toast(e.message, false); }
1557
1998
  umidBusy = false;
1558
1999
  await loadStatus(); render(); loadLogs();
@@ -1563,40 +2004,55 @@ document.addEventListener('click', async (e) => {
1563
2004
  if (e.target.closest('#zt-open')) {
1564
2005
  try {
1565
2006
  const r = await window.creditdaddy.openZcodeClient();
1566
- if (r && r.ok) toast('已启动 ZCode 客户端');
1567
- else toast((r && r.error) || '无法打开 ZCode 客户端', false);
2007
+ if (r && r.ok) toast(T('已启动 ZCode 客户端'));
2008
+ else toast((r && r.error) || T('无法打开 ZCode 客户端'), false);
1568
2009
  } catch (err) { toast(String(err && err.message || err), false); }
1569
2010
  return;
1570
2011
  }
1571
2012
  if (e.target.closest('#btn-miraopen')) {
1572
2013
  try {
1573
2014
  const r = await window.creditdaddy.openMirasimClient();
1574
- if (r && r.ok) toast('已启动 Mirasim 客户端');
1575
- else toast((r && r.error) || '无法打开 Mirasim 客户端', false);
2015
+ if (r && r.ok) toast(T('已启动 Mirasim 客户端'));
2016
+ else toast((r && r.error) || T('无法打开 Mirasim 客户端'), false);
1576
2017
  } catch (err) { toast(String(err && err.message || err), false); }
1577
2018
  return;
1578
2019
  }
1579
2020
  if (e.target.closest('#btn-cpopen')) {
1580
2021
  try {
1581
2022
  const r = await window.creditdaddy.openCatpawClient();
1582
- if (r && r.ok) toast('已启动妙手客户端');
1583
- else toast((r && r.error) || '无法打开妙手客户端', false);
2023
+ if (r && r.ok) toast(T('已启动妙手客户端'));
2024
+ else toast((r && r.error) || T('无法打开妙手客户端'), false);
1584
2025
  } catch (err) { toast(String(err && err.message || err), false); }
1585
2026
  return;
1586
2027
  }
1587
2028
  if (e.target.closest('#tt-open')) {
1588
2029
  try {
1589
2030
  const r = await window.creditdaddy.openTraeClient();
1590
- if (r && r.ok) toast('已启动 Trae 客户端');
1591
- else toast((r && r.error) || '无法打开 Trae 客户端', false);
2031
+ if (r && r.ok) toast(T('已启动 Trae 客户端'));
2032
+ else toast((r && r.error) || T('无法打开 Trae 客户端'), false);
1592
2033
  } catch (err) { toast(String(err && err.message || err), false); }
1593
2034
  return;
1594
2035
  }
2036
+ if (e.target.closest('#tt-gw')) {
2037
+ // Trae 本地接口弹窗:开关 + 局域网 + 白名单设置
2038
+ $('tgw-on').checked = state.tGateway === true;
2039
+ $('tgw-lan').checked = state.tGatewayLan === true;
2040
+ const allow = Array.isArray(state.tGatewayAllow) ? state.tGatewayAllow : [];
2041
+ $('tgw-allow').value = (allow.length ? allow : (state.tGatewaySuggest ? [state.tGatewaySuggest] : [])).join('\n');
2042
+ const st = state.tGatewayStats;
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)')
2044
+ + (st && st.lastCallAt
2045
+ ? T('<br>最近调用 ') + esc(String(st.lastCallAt).slice(11, 19)) + T(' · 共 ') + st.calls + T(' 次') + (st.lastAccount ? T(' · 最后用号 ') + esc(st.lastAccount) : '')
2046
+ : T('<br>尚未有调用'));
2047
+ syncTgwLanFields();
2048
+ openModal('m-tgw');
2049
+ return;
2050
+ }
1595
2051
  if (e.target.closest('#mt-open')) {
1596
2052
  try {
1597
2053
  const r = await window.creditdaddy.openMiniMaxClient();
1598
- if (r && r.ok) toast('已启动 MiniMax Code 客户端');
1599
- else toast((r && r.error) || '无法打开 MiniMax Code 客户端', false);
2054
+ if (r && r.ok) toast(T('已启动 MiniMax Code 客户端'));
2055
+ else toast((r && r.error) || T('无法打开 MiniMax Code 客户端'), false);
1600
2056
  } catch (err) { toast(String(err && err.message || err), false); }
1601
2057
  return;
1602
2058
  }
@@ -1607,10 +2063,10 @@ document.addEventListener('click', async (e) => {
1607
2063
  const allow = Array.isArray(state.mGatewayAllow) ? state.mGatewayAllow : [];
1608
2064
  $('mgw-allow').value = (allow.length ? allow : (state.mGatewaySuggest ? [state.mGatewaySuggest] : [])).join('\n');
1609
2065
  const st = state.mGatewayStats;
1610
- $('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)')
1611
2067
  + (st && st.lastCallAt
1612
- ? '<br>最近调用 ' + esc(String(st.lastCallAt).slice(11, 19)) + ' · 共 ' + st.calls + ' 次' + (st.lastAccount ? ' · 最后用号 ' + esc(st.lastAccount) : '')
1613
- : '<br>尚未有调用');
2068
+ ? T('<br>最近调用 ') + esc(String(st.lastCallAt).slice(11, 19)) + T(' · 共 ') + st.calls + T(' 次') + (st.lastAccount ? T(' · 最后用号 ') + esc(st.lastAccount) : '')
2069
+ : T('<br>尚未有调用'));
1614
2070
  syncMgwLanFields();
1615
2071
  openModal('m-mgw');
1616
2072
  return;
@@ -1620,7 +2076,7 @@ document.addEventListener('click', async (e) => {
1620
2076
  $('zac-interval').value = state.zClaimIntervalMin || 2;
1621
2077
  $('zac-window').value = state.zClaimWindowMin === 0 ? 0 : (state.zClaimWindowMin || 60);
1622
2078
  $('zac-off').style.display = state.zAutoClaim === true ? '' : 'none';
1623
- $('zac-ok').textContent = state.zAutoClaim === true ? '保存' : '保存并启用';
2079
+ $('zac-ok').textContent = state.zAutoClaim === true ? T('保存') : T('保存并启用');
1624
2080
  openModal('m-zac');
1625
2081
  return;
1626
2082
  }
@@ -1631,22 +2087,14 @@ document.addEventListener('click', async (e) => {
1631
2087
  const allow = Array.isArray(state.zGatewayAllow) ? state.zGatewayAllow : [];
1632
2088
  $('zgw-allow').value = (allow.length ? allow : (state.zGatewaySuggest ? [state.zGatewaySuggest] : [])).join('\n');
1633
2089
  const st = state.zGatewayStats;
1634
- $('zgw-url').innerHTML = '接口地址 <code>POST http://127.0.0.1:' + location.port + '/gateway/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)')
1635
2091
  + (st && st.lastCallAt
1636
- ? '<br>最近调用 ' + esc(String(st.lastCallAt).slice(11, 19)) + ' · 共 ' + st.calls + ' 次' + (st.lastAccount ? ' · 最后用号 ' + esc(st.lastAccount) : '')
1637
- : '<br>尚未有调用');
2092
+ ? T('<br>最近调用 ') + esc(String(st.lastCallAt).slice(11, 19)) + T(' · 共 ') + st.calls + T(' 次') + (st.lastAccount ? T(' · 最后用号 ') + esc(st.lastAccount) : '')
2093
+ : T('<br>尚未有调用'));
1638
2094
  syncZgwLanFields();
1639
2095
  openModal('m-zgw');
1640
2096
  return;
1641
2097
  }
1642
- if (e.target.closest('#zt-net')) {
1643
- // 出口弹窗:顺序 + 代理地址一起保存;代理只回填到 placeholder(脱敏),留空即清除
1644
- (state.zProxyFirst === true ? $('znet-proxy') : $('znet-direct')).checked = true;
1645
- $('znet-url').value = '';
1646
- $('znet-url').placeholder = state.zProxyUrl || (state.zHasEnvProxy ? '跟随环境变量 HTTPS_PROXY' : 'http://192.168.31.101:7890');
1647
- openModal('m-znet');
1648
- return;
1649
- }
1650
2098
  });
1651
2099
  async function loadZNet() {
1652
2100
  try {
@@ -1685,6 +2133,18 @@ async function loadMGateway() {
1685
2133
  applyLogTabs(); // 网关开关变化影响日志标签显隐
1686
2134
  } catch (e) {}
1687
2135
  }
2136
+ async function loadTGateway() {
2137
+ try {
2138
+ const d = await api('/api/trae-gateway');
2139
+ state.tGateway = d.enabled === true;
2140
+ state.tGatewayLan = d.lan === true;
2141
+ state.tGatewayAllow = d.allow || [];
2142
+ state.tGatewaySuggest = d.suggestAllow || '';
2143
+ state.tGatewayStats = d.stats || null;
2144
+ if (state.view === 'trae') { renderStatus(); render(); }
2145
+ applyLogTabs(); // 网关开关变化影响日志标签显隐
2146
+ } catch (e) {}
2147
+ }
1688
2148
 
1689
2149
  // ── 10Router ──
1690
2150
  function quotaPct(q) {
@@ -1716,11 +2176,11 @@ function quotaLow(c) {
1716
2176
 
1717
2177
  function quotaValue(q) {
1718
2178
  const pct = quotaPct(q);
1719
- if (q.percentScale || (!(q.total > 0) && pct !== null)) return { big: fmtNum(pct) + '%', small: '剩余', pct };
1720
- if (q.total > 0) return { big: fmtNum(quotaRemaining(q)), small: '/ ' + fmtNum(q.total) + (q.unit ? ' ' + q.unit : ''), pct };
1721
- 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 };
1722
2182
  }
1723
- 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(' 重置')) : '');
1724
2184
 
1725
2185
  // 明细整行:名称 + 数值/相对重置 + 水位条(CLIProxyAPI quotaRow 版式)
1726
2186
  function trRow(q) {
@@ -1729,11 +2189,12 @@ function trRow(q) {
1729
2189
 
1730
2190
  function lineVal(q) {
1731
2191
  const pct = quotaPct(q);
1732
- // 百分比口径(限时限额额度):一律显示 %,节约空间;积分类保持实际数字
1733
- const pctScale = q.percentScale || Number.isFinite(q.remainingPercentage);
1734
- const txt = (pctScale || !(q.total > 0))
1735
- ? '剩 ' + (pct !== null ? fmtNum(pct) + '%' : fmtNum(quotaRemaining(q)))
1736
- : '剩 ' + 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) : '');
1737
2198
  return '<span>' + esc(txt) + '</span> ' + relResetHtml(q);
1738
2199
  }
1739
2200
 
@@ -1742,7 +2203,7 @@ function lineVal(q) {
1742
2203
  function trPrimaryHtml(q) {
1743
2204
  if (q.percentScale) return trRow(q);
1744
2205
  const v = quotaValue(q);
1745
- 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>'
1746
2207
  + '<span class="tr-q-val"><b>' + esc(v.big) + '</b> <small class="dim">' + esc(v.small) + '</small> ' + relResetHtml(q) + '</span></div>'
1747
2208
  + (v.pct !== null ? trMeter(v.pct) : '')
1748
2209
  + '</div>';
@@ -1751,15 +2212,15 @@ function trPrimaryHtml(q) {
1751
2212
  function trQuotaHtml(q, { dimWhenEmpty = false } = {}) {
1752
2213
  const v = quotaValue(q);
1753
2214
  const used = v.pct !== null && v.pct <= 0;
1754
- const val = used && dimWhenEmpty ? '已用完' : (q.percentScale || !(q.total > 0) ? '剩余 ' + v.big : '剩余 ' + v.big + ' ' + v.small);
1755
- 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>'
1756
2217
  + '<span class="tr-q-val">' + esc(val) + relResetHtml(q) + '</span></div>'
1757
2218
  + (!(used && dimWhenEmpty) ? trMeter(v.pct) : '')
1758
2219
  + '</div>';
1759
2220
  }
1760
2221
 
1761
2222
  // 积分包按名称系列聚合("Bonus Pack 18/19/20" → "Bonus Pack ×3"),去掉尾部序号作键
1762
- const packKey = (name) => String(name || '').replace(/[\s#(]*\d+\s*[)]?\s*$/, '').trim() || '积分包';
2223
+ const packKey = (name) => String(name || '').replace(/[\s#(]*\d+\s*[)]?\s*$/, '').trim() || T('积分包');
1763
2224
  function trPacksHtml(c, packs) {
1764
2225
  if (!packs.length) return '';
1765
2226
  const alive = packs.filter((q) => quotaRemaining(q) > 0);
@@ -1778,13 +2239,13 @@ function trPacksHtml(c, packs) {
1778
2239
  const gs = [...groups.values()].sort((a, b) => String(a.resetAt || '9999').localeCompare(String(b.resetAt || '9999')));
1779
2240
  const aliveSum = alive.reduce((n, q) => n + quotaRemaining(q), 0);
1780
2241
  const open = Boolean(state.trOpen && state.trOpen[c.id]);
1781
- const head = '<div class="tr-packs-head">积分包 <b>' + alive.length + '</b> 个可用'
1782
- + (alive.length ? ',共剩 ' + fmtNum(aliveSum) : '')
1783
- + (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>';
1784
2245
  const lines = gs.map((g) => trLine(
1785
2246
  g.name + (g.count > 1 ? ' ×' + g.count : ''),
1786
- esc('剩 ' + fmtNum(g.rem) + (g.tot > 0 ? ' / ' + fmtNum(g.tot) : ''))
1787
- + (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>') : ''),
1788
2249
  )).join('');
1789
2250
  const detail = open
1790
2251
  ? [...alive.sort((a, b) => String(a.resetAt || '9999').localeCompare(String(b.resetAt || '9999'))), ...spent]
@@ -1792,7 +2253,7 @@ function trPacksHtml(c, packs) {
1792
2253
  : '';
1793
2254
  const collapsible = alive.length > gs.length || spent.length > 0;
1794
2255
  return '<div class="tr-packs">' + head + (open ? detail : lines)
1795
- + (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>' : '')
1796
2257
  + '</div>';
1797
2258
  }
1798
2259
 
@@ -1803,11 +2264,11 @@ function trCardHtml(c) {
1803
2264
  const showPlan = c.plan && !norm(c.plan).includes(norm(c.providerName)) && !norm(c.providerName).includes(norm(c.plan));
1804
2265
  let body;
1805
2266
  if (c.error) {
1806
- 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>';
1807
2268
  } else {
1808
2269
  const { primary, windows, packs } = splitQuotas(c);
1809
2270
  if (!(primary || windows.length || packs.length)) {
1810
- 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>';
1811
2272
  } else {
1812
2273
  // 与汇总卡同款默认视图:主额度 + 每个周 / 月窗口额度一条(带进度条);积分包走聚合行,「逐包明细」展开单包
1813
2274
  body = (primary ? trPrimaryHtml(primary) : '')
@@ -1817,12 +2278,12 @@ function trCardHtml(c) {
1817
2278
  }
1818
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">'
1819
2280
  + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1820
- + (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>' : '')
1821
2282
  + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
1822
2283
  + (showPlan ? '<span class="badge">' + esc(c.plan) + '</span>' : '')
1823
- + (c.isActive ? '' : '<span class="pill pending">已停用</span>')
1824
- + (c.limitReached ? '<span class="pill fail">已达上限</span>' : '')
1825
- + (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>') : '')
1826
2287
  + '</div></div>' + body + '</div>';
1827
2288
  }
1828
2289
 
@@ -1834,7 +2295,7 @@ const trSorted = (list) => list.slice().sort((a, b) => (b.isActive - a.isActive)
1834
2295
  function trGroups(list) {
1835
2296
  const map = new Map();
1836
2297
  for (const c of trSorted(list)) {
1837
- const k = c.providerName || c.provider || '其他';
2298
+ const k = c.providerName || c.provider || T('其他');
1838
2299
  if (!map.has(k)) map.set(k, []);
1839
2300
  map.get(k).push(c);
1840
2301
  }
@@ -1877,7 +2338,9 @@ function trVendorCardHtml(g) {
1877
2338
  if (!q || isAgg(q) || isDetail(q)) continue;
1878
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: '' });
1879
2340
  const w = rowMap.get(q.name);
1880
- 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);
1881
2344
  if (isPctRow) w.pctScale = true;
1882
2345
  const pct = quotaPct(q);
1883
2346
  if (pct !== null) w.pcts.push(pct);
@@ -1902,14 +2365,14 @@ function trVendorCardHtml(g) {
1902
2365
  const alivePacks = packs.filter((q) => quotaRemaining(q) > 0);
1903
2366
  const packRem = alivePacks.reduce((n, q) => n + quotaRemaining(q), 0);
1904
2367
  const briefs = [];
1905
- if (alivePacks.length) briefs.push('积分包×' + alivePacks.length + ' 剩 ' + fmtNum(packRem));
1906
- 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('积分包已用完'));
1907
2370
  const hasQuotas = Boolean(primary || others.length || packs.length);
1908
2371
  let quotaHtml;
1909
2372
  if (errs === items.length) {
1910
- quotaHtml = '<div class="msg err">全部连接查询失败</div>';
2373
+ quotaHtml = T('<div class="msg err">全部连接查询失败</div>');
1911
2374
  } else if (!hasQuotas) {
1912
- quotaHtml = '<div class="msg">该供应商没有可汇总的额度</div>';
2375
+ quotaHtml = T('<div class="msg">该供应商没有可汇总的额度</div>');
1913
2376
  } else {
1914
2377
  // 「全部」视角不显示「最早到期」:每行自带相对重置时间,足够判断
1915
2378
  quotaHtml = (primary ? trPrimaryHtml(primary) : '')
@@ -1920,30 +2383,30 @@ function trVendorCardHtml(g) {
1920
2383
  }
1921
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">'
1922
2385
  + '<span class="badge prod tenrouter">' + esc(g.name) + '</span>'
1923
- + (TR_REGION[prov] ? '<span class="badge ' + TR_REGION[prov][1] + '">' + TR_REGION[prov][0] + '</span>' : '')
1924
- + '<span class="badge">' + items.length + ' 个账号</span>'
1925
- + (lows ? '<span class="pill limited">告急 ×' + lows + '</span>' : '')
1926
- + (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>' : '')
1927
2390
  + '</div></div>' + quotaHtml + '</div>';
1928
2391
  }
1929
2392
 
1930
2393
  function trSetupHtml() {
1931
- return '<div class="setup"><h3>接入 10Router</h3>'
1932
- + '<div class="hint">填 10Router 的地址和一个<b>虚拟 key</b>(10Router 仪表盘 → API Keys 创建的 sk-…),就能在这里:</div>'
1933
- + '<div class="hint" style="margin:6px 0 12px">· 查看 10Router 里其他供应商(Claude / Codex / Kiro / GLM …)的额度卡片(需要 10Router 1.2.1+)<br>'
1934
- + '· 把本机 ZCode / OpenCode / mirasim / 小米 MiMo / 10Router 的用量同步进 10Router 的用量统计(同 10router-sync 插件,自动每小时一次)<br>'
1935
- + '· 「10Router 本机」= 本机 10Router 实例自己记的账(如 zcode-free 经它中转的行)同步到聚合端;地址指向本机时自动跳过,避免自己导自己</div>'
1936
- + '<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>');
1937
2400
  }
1938
2401
 
1939
2402
  function renderTenrouter() {
1940
2403
  const box = $('tr-panel');
1941
2404
  const c = tr.config;
1942
- if (!c) { box.innerHTML = '<div class="empty">加载中…</div>'; return; }
2405
+ if (!c) { box.innerHTML = '<div class="empty">' + T('加载中…') + '</div>'; return; }
1943
2406
  const gwLine = state.zGateway === true
1944
- ? '<div class="gw-line"><span class="dot on pulse"></span> ZCode 体验包接口运行中'
1945
- + (state.zGatewayStats && state.zGatewayStats.lastCallAt ? ' · 最近调用 ' + esc(String(state.zGatewayStats.lastCallAt).slice(11, 19)) + ' · ' + state.zGatewayStats.calls + ' 次 · 最后用号 ' + esc(state.zGatewayStats.lastAccount || '-') : ' · 尚未有调用')
1946
- + ' · 模型 <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>')
1947
2410
  : '';
1948
2411
  if (!c.configured) { box.innerHTML = gwLine + trSetupHtml(); return; }
1949
2412
  const list = (tr.quotas && tr.quotas.connections) || [];
@@ -1970,36 +2433,36 @@ function renderTenrouter() {
1970
2433
  const activeVendor = vendor && shown.some((g) => g.name === vendor) ? vendor : '';
1971
2434
  if (groups.length) {
1972
2435
  tabStrip = '<div class="tr-tabs">'
1973
- + '<button class="btn' + (!activeVendor ? ' primary' : '') + '" data-tr="vendor" data-v="">全部</button>'
2436
+ + '<button class="btn' + (!activeVendor ? ' primary' : '') + T('" data-tr="vendor" data-v="">全部</button>')
1974
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('')
1975
2438
  + '</div>';
1976
2439
  }
1977
- if (tr.error) grid = '<div class="empty">' + esc(tr.error) + '</div>';
1978
- else if (!tr.quotas) grid = '<div class="empty">正在读取 10Router 供应商额度…</div>';
1979
- 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>');
1980
2443
  else if (activeVendor) {
1981
2444
  const g = groups.find((x) => x.name === activeVendor) || { items: [] };
1982
2445
  grid = '<div class="tr-vcards">' + sortedConnections(activeVendor, g.items).map(trCardHtml).join('') + '</div>';
1983
2446
  } else {
1984
2447
  grid = shown.length
1985
2448
  ? '<div class="tr-vcards">' + sortedVendorGroups(shown).map(trVendorCardHtml).join('') + '</div>'
1986
- : '<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>';
2449
+ : T('<div class="empty">没有可显示的供应商(可在「设置」里关掉隐藏)</div>');
1987
2450
  }
1988
2451
  const h = tr.health;
1989
- const healthStrong = !h ? '…' : h.ok ? '正常' : '异常';
2452
+ const healthStrong = !h ? '…' : h.ok ? T('正常') : T('异常');
1990
2453
  const healthDetail = !h ? '' : h.ok
1991
- ? (h.driver ? '存储驱动 ' + h.driver : '') + (h.lastDriverError ? '(驱动降级:' + h.lastDriverError + ')' : '')
2454
+ ? (h.driver ? T('存储驱动 ') + h.driver : '') + (h.lastDriverError ? T('(驱动降级:') + h.lastDriverError + T(')') : '')
1992
2455
  : (h.error || '');
1993
2456
  box.innerHTML = '<div class="toolbar"><div class="summary">'
1994
- + '<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>'
1995
- + '<div class="stat"><span>供应商连接</span><strong>' + (tr.quotas ? list.length : '-') + '</strong></div><div class="divider"></div>'
1996
- + '<div class="stat"><span>额度告急</span><strong>' + (tr.quotas ? low : '-') + '</strong></div><div class="divider"></div>'
1997
- + '<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>'
1998
2461
  + '</div><div class="actions">'
1999
- + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + (tr.loading ? '刷新中…' : '刷新额度') + '</button>'
2000
- + '<button class="btn" data-tr="sync" title="用量同步:自动开关、同步来源与手动同步(点我打开)"' + (tr.syncing || c.syncing ? ' disabled' : '') + '>'
2001
- + '<span class="dot' + (c.sync.enabled ? ' on' : '') + '"></span>' + (tr.syncing || c.syncing ? '同步中…' : '用量同步') + '</button>'
2002
- + '<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>')
2003
2466
  + '</div></div>'
2004
2467
  + tabStrip
2005
2468
  + '<div class="tr-grid">' + grid + '</div>';
@@ -2011,11 +2474,11 @@ function trDashCard() {
2011
2474
  const list = (tr.quotas && tr.quotas.connections) || [];
2012
2475
  const low = list.filter((x) => x.isActive && !x.error && quotaLow(x)).length;
2013
2476
  const ls = c.lastSync;
2014
- const rows = '<span class="lbl">供应商连接</span><span class="val">' + (tr.quotas ? list.length : tr.error ? '读取失败' : '…') + '</span>'
2015
- + '<span class="lbl">额度告急</span><span class="val' + (low ? ' warn' : '') + '">' + (tr.quotas ? low : '-') + '</span>'
2016
- + '<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>';
2017
2480
  return '<div class="prodcard" data-view="tenrouter"><div class="prod-head"><span class="prod-name"><span class="dot tenrouter"></span>10Router</span>'
2018
- + '<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>';
2019
2482
  }
2020
2483
 
2021
2484
  async function loadTrConfig() {
@@ -2047,9 +2510,9 @@ function openTrSettings() {
2047
2510
  const c = tr.config || { sync: { enabled: true, sources: [] }, sources: [] };
2048
2511
  $('tr-endpoint').value = c.endpoint || '';
2049
2512
  $('tr-key').value = '';
2050
- $('tr-key').placeholder = c.keyMasked ? '已保存 ' + c.keyMasked + '(留空保持不变)' : 'sk-…';
2513
+ $('tr-key').placeholder = c.keyMasked ? T('已保存 ') + c.keyMasked + T('(留空保持不变)') : 'sk-…';
2051
2514
  $('tr-admin-pw').value = '';
2052
- $('tr-admin-pw').placeholder = c.hasAdminPassword ? '已保存(留空保持不变)' : '可选:10Router 面板密码';
2515
+ $('tr-admin-pw').placeholder = c.hasAdminPassword ? T('已保存(留空保持不变)') : T('可选:10Router 面板密码');
2053
2516
  $('tr-hide-empty').checked = state.trHideEmpty === true;
2054
2517
  $('tr-disconnect').style.display = c.configured ? '' : 'none';
2055
2518
  $('tr-test-msg').textContent = '';
@@ -2062,16 +2525,16 @@ function openTrSync() {
2062
2525
  $('tr-auto').checked = c.configured ? c.sync.enabled : true;
2063
2526
  $('tr-sources').innerHTML = (c.sources || []).map((x) => '<label class="check"><input type="checkbox" data-src="' + x.id + '"'
2064
2527
  + ((c.configured ? c.sync.sources.includes(x.id) : true) ? ' checked' : '') + (x.supported ? '' : ' disabled') + '> ' + esc(x.label)
2065
- + '<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('');
2066
2529
  const ls = c.lastSync;
2067
- $('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('还没有同步过');
2068
2531
  $('tr-sync-now').disabled = tr.syncing || c.syncing === true;
2069
2532
  openModal('m-trsync');
2070
2533
  }
2071
2534
  async function trSyncSave(close) {
2072
2535
  try {
2073
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) } });
2074
- if (close) { closeModal('m-trsync'); toast('用量同步设置已保存'); render(); }
2537
+ if (close) { closeModal('m-trsync'); toast(T('用量同步设置已保存')); render(); }
2075
2538
  } catch (e) { toast(e.message, false); throw e; }
2076
2539
  }
2077
2540
  function trForm() {
@@ -2085,36 +2548,36 @@ function trForm() {
2085
2548
  async function trTest() {
2086
2549
  const f = trForm();
2087
2550
  $('tr-test-msg').style.color = '';
2088
- $('tr-test-msg').textContent = '测试中…';
2551
+ $('tr-test-msg').textContent = T('测试中…');
2089
2552
  try {
2090
2553
  const r = await api('/api/tenrouter/test', { json: { endpoint: f.endpoint, key: f.key } });
2091
2554
  $('tr-test-msg').style.color = r.ok ? 'var(--ok)' : 'var(--err)';
2092
2555
  $('tr-test-msg').textContent = !r.ok ? '✗ ' + r.error
2093
- : r.quotas ? '✓ 连接成功:' + r.connections + ' 个可查额度的供应商连接' : '✓ 已连接,但' + r.warning + '(用量同步可以正常使用)';
2094
- } 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); }
2095
2558
  }
2096
2559
  async function trSave() {
2097
2560
  const f = trForm();
2098
- if (!f.endpoint) return toast('请填写 10Router 地址', false);
2099
- 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);
2100
2563
  // 显示偏好只存在面板本地(localStorage),不上服务端
2101
2564
  state.trHideEmpty = $('tr-hide-empty').checked;
2102
2565
  try { localStorage.setItem('qd_tr_hideempty', state.trHideEmpty ? '1' : '0'); } catch (e) {}
2103
2566
  try {
2104
2567
  tr.config = await api('/api/tenrouter', { method: 'PUT', json: f });
2105
2568
  closeModal('m-10r');
2106
- toast('已保存 10Router 设置');
2569
+ toast(T('已保存 10Router 设置'));
2107
2570
  tr.quotas = null; tr.error = null;
2108
2571
  loadTrQuotas(true);
2109
2572
  } catch (e) { toast(e.message, false); }
2110
2573
  }
2111
2574
  async function trDisconnect() {
2112
- if (!(await uiConfirm('断开后会删除保存的 10Router 地址、key 与同步进度(10Router 里已同步的数据不受影响)。', '断开 10Router', '断开'))) return;
2575
+ if (!(await uiConfirm(T('断开后会删除保存的 10Router 地址、key 与同步进度(10Router 里已同步的数据不受影响)。'), T('断开 10Router'), T('断开')))) return;
2113
2576
  try { tr.config = await api('/api/tenrouter', { method: 'DELETE' }); tr.quotas = null; tr.error = null; closeModal('m-10r'); render(); }
2114
2577
  catch (e) { toast(e.message, false); }
2115
2578
  }
2116
2579
  async function trSyncAccounts() {
2117
- if (!(tr.config && tr.config.configured)) return toast('请先保存 10Router 设置', false);
2580
+ if (!(tr.config && tr.config.configured)) return toast(T('请先保存 10Router 设置'), false);
2118
2581
  const btn = $('tr-sync-accounts');
2119
2582
  const pw = $('tr-admin-pw').value;
2120
2583
  btn.disabled = true;
@@ -2176,6 +2639,12 @@ async function loadAccounts() {
2176
2639
  renderStatus();
2177
2640
  loadQuotas();
2178
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
+ }
2179
2648
  let quotaRun = 0;
2180
2649
  let quotaLoadedAt = 0;
2181
2650
  async function loadQuotas(ids) {
@@ -2212,10 +2681,33 @@ function applyLogTabs() {
2212
2681
  let show = true;
2213
2682
  if (t === 'zcode') show = Boolean(state.zGateway);
2214
2683
  if (t === 'minimax') show = Boolean(state.mGateway);
2684
+ if (t === 'trae') show = Boolean(state.tGateway);
2215
2685
  b.style.display = show ? '' : 'none';
2216
2686
  b.classList.toggle('active', t === state.logTab);
2217
2687
  });
2688
+ document.querySelectorAll('#log-filter button').forEach((b) => {
2689
+ b.classList.toggle('active', b.dataset.logLevel === state.logLevel);
2690
+ });
2218
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
+ });
2219
2711
  async function loadCurrentLogs() {
2220
2712
  const el = $('logs');
2221
2713
  if (!el) return;
@@ -2228,11 +2720,15 @@ async function loadCurrentLogs() {
2228
2720
  const data = await api('/api/logs?tag=' + encodeURIComponent(GW_LOG_TAG[state.logTab]));
2229
2721
  logs = data.logs || [];
2230
2722
  }
2723
+ if (state.logLevel !== 'all') {
2724
+ logs = logs.filter((l) => l.level === 'warn' || l.level === 'error');
2725
+ }
2231
2726
  const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
2232
2727
  const withTag = state.logTab === 'run';
2728
+ const ico = (lvl) => lvl === 'error' ? '✗' : lvl === 'warn' ? '⚠' : lvl === 'info' ? '·' : '';
2233
2729
  el.innerHTML = logs.length
2234
- ? logs.map((l) => '<div class="' + esc(l.level) + '">[' + esc(l.at) + '] ' + (withTag ? esc(l.tag) + ' · ' : '') + esc(l.msg) + '</div>').join('')
2235
- : '(暂无)';
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('')
2731
+ : (state.logLevel === 'all' ? T('(暂无)') : T('(暂无警告或错误)'));
2236
2732
  if (atBottom) el.scrollTop = el.scrollHeight;
2237
2733
  } catch (e) {}
2238
2734
  }
@@ -2244,15 +2740,15 @@ async function checkinOne(id) {
2244
2740
  const d = await api('/api/accounts/' + encodeURIComponent(id) + '/checkin', { method: 'POST' });
2245
2741
  const r = d.results && d.results[0];
2246
2742
  const accObj = state.accounts.find((x) => x.id === id);
2247
- const accLabel = (r && r.account) || (accObj && (accObj.name || accObj.id)) || '该账号';
2248
- 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'));
2249
2745
  } catch (e) { toast(e.message, false); }
2250
2746
  delete state.busy[id];
2251
2747
  await loadStatus(); await loadAccounts(); loadLogs();
2252
2748
  }
2253
2749
  async function checkinAll(btn, product) {
2254
2750
  const label = btn.innerHTML;
2255
- btn.disabled = true; btn.innerHTML = I.refresh + '领取中…';
2751
+ btn.disabled = true; btn.innerHTML = I.refresh + T('领取中…');
2256
2752
  try {
2257
2753
  const d = await api('/api/checkin', { json: { skipIfCheckedToday: false, product } });
2258
2754
  toast(d.summary);
@@ -2270,30 +2766,30 @@ async function onCardClick(e) {
2270
2766
  if (act === 'claim') return openClaim(id);
2271
2767
  if (act === 'switch') {
2272
2768
  const a = state.accounts.find((x) => x.id === id);
2273
- 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('客户端');
2274
2770
  const tip = a && a.product === 'zcode'
2275
- ? '\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2771
+ ? T('\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
2276
2772
  : a && a.product === 'mirasim'
2277
- ? '\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2773
+ ? T('\n需先退出 Mirasim;切换后重新打开即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
2278
2774
  : a && a.product === 'catpaw'
2279
- ? '\n需先退出妙手;切换后重新打开妙手即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
2775
+ ? T('\n需先退出妙手;切换后重新打开妙手即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。')
2280
2776
  : a && a.product === 'trae'
2281
- ? '\n需先退出 Trae;切换后重新打开 Trae 即为新账号。当前登录会先自动快照保存到 CreditDaddy,不会丢号。'
2777
+ ? T('\n需先退出 Trae;切换后重新打开 Trae 即为新账号。当前登录会先自动快照保存到 CreditDaddy,不会丢号。')
2282
2778
  : a && a.product === 'qoder'
2283
- ? '\n需先退出 Qoder;切换后重新打开 Qoder 即为新账号。运行中的 Qoder 退出时会把旧登录写回去,可选「关闭并强制切换」代为关进程。'
2284
- : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
2285
- 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;
2286
2782
  const doSwitch = (force) => api('/api/accounts/' + encodeURIComponent(id) + '/switch', { json: { force } });
2287
2783
  try {
2288
2784
  let r;
2289
2785
  try { r = await doSwitch(false); }
2290
2786
  catch (err) {
2291
2787
  if (err.code !== 'ZCODE_RUNNING' && err.code !== 'MIRASIM_RUNNING' && err.code !== 'CATPAW_RUNNING' && err.code !== 'TRAE_RUNNING' && err.code !== 'QODER_RUNNING') throw err;
2292
- if (!(await uiConfirm(err.message + '\n\n仍要强制切换吗?强制切换会先关闭客户端再写入登录,重新打开客户端即为新账号。', client + ' 正在运行', '关闭并强制切换'))) return;
2788
+ if (!(await uiConfirm(err.message + T('\n\n仍要强制切换吗?强制切换会先关闭客户端再写入登录,重新打开客户端即为新账号。'), client + T(' 正在运行'), T('关闭并强制切换')))) return;
2293
2789
  r = await doSwitch(true);
2294
2790
  }
2295
- const msg = r && r.alreadyActive ? client + ' 当前已是该账号'
2296
- : (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);
2297
2793
  toast(msg);
2298
2794
  await loadStatus(); await loadAccounts();
2299
2795
  // 桌面版切号后自动唤醒打开客户端,免去手动查找客户端快捷方式
@@ -2301,7 +2797,7 @@ async function onCardClick(e) {
2301
2797
  setTimeout(async () => {
2302
2798
  try {
2303
2799
  const re = await window.creditdaddy.openZcodeClient();
2304
- if (re && re.ok) toast('已重新启动 ZCode 客户端');
2800
+ if (re && re.ok) toast(T('已重新启动 ZCode 客户端'));
2305
2801
  } catch (e) {}
2306
2802
  }, 800);
2307
2803
  }
@@ -2309,7 +2805,7 @@ async function onCardClick(e) {
2309
2805
  setTimeout(async () => {
2310
2806
  try {
2311
2807
  const re = await window.creditdaddy.openMirasimClient();
2312
- if (re && re.ok) toast('已重新启动 Mirasim 客户端');
2808
+ if (re && re.ok) toast(T('已重新启动 Mirasim 客户端'));
2313
2809
  } catch (e) {}
2314
2810
  }, 800);
2315
2811
  }
@@ -2317,7 +2813,7 @@ async function onCardClick(e) {
2317
2813
  setTimeout(async () => {
2318
2814
  try {
2319
2815
  const re = await window.creditdaddy.openCatpawClient();
2320
- if (re && re.ok) toast('已重新启动妙手客户端');
2816
+ if (re && re.ok) toast(T('已重新启动妙手客户端'));
2321
2817
  } catch (e) {}
2322
2818
  }, 800);
2323
2819
  }
@@ -2325,8 +2821,8 @@ async function onCardClick(e) {
2325
2821
  setTimeout(async () => {
2326
2822
  try {
2327
2823
  const re = await window.creditdaddy.openTraeClient();
2328
- if (re && re.ok) toast('已重新启动 Trae 客户端');
2329
- else toast((re && re.error) || '无法打开 Trae 客户端(桌面版才能自动拉起)', false);
2824
+ if (re && re.ok) toast(T('已重新启动 Trae 客户端'));
2825
+ else toast((re && re.error) || T('无法打开 Trae 客户端(桌面版才能自动拉起)'), false);
2330
2826
  } catch (e) {}
2331
2827
  }, 800);
2332
2828
  }
@@ -2335,7 +2831,7 @@ async function onCardClick(e) {
2335
2831
  }
2336
2832
  if (act === 'rename') {
2337
2833
  const a = state.accounts.find((x) => x.id === id);
2338
- const name = await uiPrompt('修改备注名', a && a.name || '', { okText: '保存' });
2834
+ const name = await uiPrompt(T('修改备注名'), a && a.name || '', { okText: T('保存') });
2339
2835
  if (name === null) return;
2340
2836
  try { await api('/api/accounts/' + encodeURIComponent(id), { method: 'PATCH', json: { name } }); await loadAccounts(); }
2341
2837
  catch (err) { toast(err.message, false); }
@@ -2345,11 +2841,11 @@ async function onCardClick(e) {
2345
2841
  if (!armed[id]) {
2346
2842
  armed[id] = setTimeout(() => { delete armed[id]; btn.classList.remove('armed'); }, 3000);
2347
2843
  btn.classList.add('armed');
2348
- toast('再点一次确认删除');
2844
+ toast(T('再点一次确认删除'));
2349
2845
  return;
2350
2846
  }
2351
2847
  clearTimeout(armed[id]); delete armed[id];
2352
- 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(); }
2353
2849
  catch (err) { toast(err.message, false); }
2354
2850
  }
2355
2851
  }
@@ -2366,7 +2862,7 @@ function loadCaptchaSdk() {
2366
2862
  const s = document.createElement('script');
2367
2863
  s.src = CAPTCHA_SDK_URL;
2368
2864
  s.onload = () => resolve();
2369
- s.onerror = () => reject(new Error('验证码组件加载失败(网络问题?)'));
2865
+ s.onerror = () => reject(new Error(T('验证码组件加载失败(网络问题?)')));
2370
2866
  document.head.appendChild(s);
2371
2867
  });
2372
2868
  }
@@ -2379,7 +2875,7 @@ async function openClaim(id) {
2379
2875
  claimState.plans = [];
2380
2876
  claimState.captcha = null;
2381
2877
  claimState.busy = false;
2382
- $('claim-body').innerHTML = '<div class="dim">读取可领取的活动…</div>';
2878
+ $('claim-body').innerHTML = T('<div class="dim">读取可领取的活动…</div>');
2383
2879
  openModal('m-claim');
2384
2880
  try {
2385
2881
  const [plansResp, cap] = await Promise.all([
@@ -2397,17 +2893,17 @@ async function openClaim(id) {
2397
2893
 
2398
2894
  function renderClaimBody() {
2399
2895
  if (!claimState.plans.length) {
2400
- $('claim-body').innerHTML = '<div class="muted-empty">当前没有可领取的活动</div>';
2896
+ $('claim-body').innerHTML = T('<div class="muted-empty">当前没有可领取的活动</div>');
2401
2897
  return;
2402
2898
  }
2403
2899
  $('claim-body').innerHTML = claimState.plans.map((pl, i) =>
2404
2900
  '<div class="claim-plan"><div><b>' + esc(pl.name || pl.planId) + '</b>'
2405
2901
  + (pl.description ? '<div class="dim grants">' + esc(pl.description) + '</div>' : '')
2406
- + (pl.grants && pl.grants.length ? '<div class="dim grants">' + pl.grants.map(esc).join(';') + '</div>' : '')
2407
- + '</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>')
2408
2904
  ).join('')
2409
2905
  + '<div id="claim-status" class="dim" style="margin-top:8px"></div>'
2410
- + '<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>');
2411
2907
  }
2412
2908
 
2413
2909
  // 阿里云验证码:先静默验证(8s 超时),不行就让用户点按钮弹验证码。移植自 zcode-switch captcha.js。
@@ -2421,13 +2917,13 @@ function runCaptcha(cfg) {
2421
2917
  if (done || !param) return;
2422
2918
  done = true;
2423
2919
  clearTimeout(timer);
2424
- st.textContent = '人机验证已通过';
2920
+ st.textContent = T('人机验证已通过');
2425
2921
  resolve(param);
2426
2922
  };
2427
2923
  const interactive = (why) => {
2428
2924
  if (done) return;
2429
2925
  clearTimeout(timer);
2430
- st.textContent = '需要完成人机验证:点下方按钮,按提示完成验证码';
2926
+ st.textContent = T('需要完成人机验证:点下方按钮,按提示完成验证码');
2431
2927
  if (btn) { btn.hidden = false; btn.focus(); }
2432
2928
  if (why && typeof why !== 'string') st.title = JSON.stringify(why).slice(0, 160);
2433
2929
  };
@@ -2441,7 +2937,7 @@ function runCaptcha(cfg) {
2441
2937
  button: '#cap-btn',
2442
2938
  getInstance: (instance) => {
2443
2939
  if (instance && typeof instance.startTracelessVerification === 'function') {
2444
- st.textContent = '人机验证中…';
2940
+ st.textContent = T('人机验证中…');
2445
2941
  instance.startTracelessVerification();
2446
2942
  timer = setTimeout(() => interactive(), 8000);
2447
2943
  } else interactive();
@@ -2460,7 +2956,7 @@ async function doClaim(i) {
2460
2956
  const st = $('claim-status');
2461
2957
  claimState.busy = true;
2462
2958
  renderClaimBody();
2463
- $('claim-status').textContent = '准备领取…';
2959
+ $('claim-status').textContent = T('准备领取…');
2464
2960
  let captchaParam = '';
2465
2961
  let region = '';
2466
2962
  try {
@@ -2470,27 +2966,27 @@ async function doClaim(i) {
2470
2966
  region = cfg.region || '';
2471
2967
  captchaParam = await runCaptcha(cfg);
2472
2968
  } else {
2473
- $('claim-status').textContent = '验证码配置不可用,按无验证码直接尝试…';
2969
+ $('claim-status').textContent = T('验证码配置不可用,按无验证码直接尝试…');
2474
2970
  }
2475
2971
  } catch (e) {
2476
- $('claim-status').innerHTML = '<span class="msg err">' + esc('验证码失败:' + e.message) + '</span>';
2972
+ $('claim-status').innerHTML = '<span class="msg err">' + esc(T('验证码失败:') + e.message) + '</span>';
2477
2973
  claimState.busy = false;
2478
2974
  renderClaimBody();
2479
2975
  return;
2480
2976
  }
2481
- $('claim-status').textContent = '领取中…';
2977
+ $('claim-status').textContent = T('领取中…');
2482
2978
  try {
2483
2979
  const r = await api('/api/accounts/' + encodeURIComponent(claimState.accountId) + '/zcode/claim', {
2484
2980
  json: { planId: pl.planId, captchaParam, region },
2485
2981
  });
2486
- toast('领取成功:' + (r.planName || pl.name || pl.planId));
2982
+ toast(T('领取成功:') + (r.planName || pl.name || pl.planId));
2487
2983
  closeModal('m-claim');
2488
2984
  loadQuotas([claimState.accountId]);
2489
2985
  loadLogs();
2490
2986
  } catch (e) {
2491
2987
  const errBox = $('claim-status');
2492
2988
  if (errBox) {
2493
- 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>';
2494
2990
  }
2495
2991
  }
2496
2992
  claimState.busy = false;
@@ -2503,7 +2999,11 @@ $('claim-body').addEventListener('click', (e) => {
2503
2999
  });
2504
3000
 
2505
3001
  // ── 弹窗 ──
2506
- 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
+ }
2507
3007
  function closeModal(id) { $(id).classList.remove('show'); if (id === 'm-add') deviceStop(); if (id === 'm-dialog') settleDialog(null); }
2508
3008
 
2509
3009
  // 页面内对话框(替代原生 confirm / prompt:面板嵌在飞牛桌面窗口里是跨域 iframe,原生弹框可能被浏览器拦截)
@@ -2517,17 +3017,61 @@ function uiDialog({ title, message, input, okText }) {
2517
3017
  $('dlg-msg').style.display = message ? '' : 'none';
2518
3018
  $('dlg-field').style.display = input ? '' : 'none';
2519
3019
  if (input) { $('dlg-input').type = input.type || 'text'; $('dlg-input').value = input.value || ''; $('dlg-input').placeholder = input.placeholder || ''; }
2520
- $('dlg-ok').textContent = okText || '确定';
3020
+ $('dlg-ok').textContent = okText || T('确定');
2521
3021
  openModal('m-dialog');
2522
3022
  setTimeout(() => (input ? $('dlg-input') : $('dlg-ok')).focus(), 30);
2523
3023
  return new Promise((resolve) => { dialogResolve = (v) => resolve(input ? v : v !== null); });
2524
3024
  }
2525
- const uiConfirm = (message, title, okText) => uiDialog({ title: title || '请确认', message, okText });
3025
+ const uiConfirm = (message, title, okText) => uiDialog({ title: title || T('请确认'), message, okText });
2526
3026
  const uiPrompt = (title, value, opts = {}) => uiDialog({ title, message: opts.message, input: { value, type: opts.type, placeholder: opts.placeholder }, okText: opts.okText });
2527
3027
  function okDialog() { const v = $('dlg-field').style.display === 'none' ? true : $('dlg-input').value; $('m-dialog').classList.remove('show'); settleDialog(v); }
2528
3028
  $('dlg-ok').addEventListener('click', okDialog);
2529
3029
  $('dlg-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') okDialog(); });
2530
3030
 
3031
+ // ── 重启服务(桌面客户端或 CLI) ──
3032
+ async function restartCreditDaddy() {
3033
+ const mask = document.createElement('div');
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;';
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>');
3036
+ document.body.appendChild(mask);
3037
+
3038
+ try {
3039
+ if (window.creditdaddy && typeof window.creditdaddy.restartApp === 'function') {
3040
+ await window.creditdaddy.restartApp();
3041
+ } else {
3042
+ await api('/api/system/restart', { method: 'POST' });
3043
+ }
3044
+ } catch {}
3045
+
3046
+ let attempts = 0;
3047
+ const poll = setInterval(async () => {
3048
+ attempts++;
3049
+ try {
3050
+ const res = await fetch('/api/status', { cache: 'no-store' });
3051
+ if (res.ok) {
3052
+ clearInterval(poll);
3053
+ location.reload();
3054
+ }
3055
+ } catch {}
3056
+ if (attempts > 30) {
3057
+ clearInterval(poll);
3058
+ mask.innerHTML = T('<div>重启超时或端口已改变</div><button class="btn primary" onclick="location.reload()" style="margin-top:10px">手动刷新</button>');
3059
+ }
3060
+ }, 1000);
3061
+ }
3062
+
3063
+ async function maybePromptRestartForLan(oldLan, newLan) {
3064
+ if (oldLan !== newLan) {
3065
+ const ok = await uiConfirm(T('局域网开放绑定改动需重启服务生效,是否立即重启 CreditDaddy?'), T('配置已保存'), T('立即重启'));
3066
+ if (ok) {
3067
+ await restartCreditDaddy();
3068
+ return true;
3069
+ }
3070
+ }
3071
+ return false;
3072
+ }
3073
+
3074
+
2531
3075
  // ── 检查更新(桌面版):主进程推状态(update:state),面板只管渲染 ──
2532
3076
  const updBridge = window.creditdaddy && window.creditdaddy.update ? window.creditdaddy.update : null;
2533
3077
  let updState = null;
@@ -2538,55 +3082,64 @@ function renderUpdateModal(s) {
2538
3082
  updState = s;
2539
3083
  const body = $('upd-body');
2540
3084
  const acts = $('upd-actions');
2541
- const cur = '当前 v' + (s.current || '?');
2542
- const rel = '<button class="btn" id="upd-rel">打开 Releases</button>';
2543
- 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>');
2544
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;
2545
3093
  case 'unsupported':
2546
- body.innerHTML = '<div class="hint">当前环境不支持应用内更新(开发模式 / Portable / 未签名的 macOS 版),请从 GitHub Releases 手动下载。</div>';
2547
- 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>');
2548
3096
  break;
2549
3097
  case 'checking':
2550
- body.innerHTML = '<div class="hint">正在检查更新…</div>';
3098
+ body.innerHTML = T('<div class="hint">正在检查更新…</div>');
2551
3099
  acts.innerHTML = close;
2552
3100
  break;
2553
3101
  case 'latest':
2554
- body.innerHTML = '<div class="hint"><span class="dot on"></span> 已是最新版本(' + cur + ')</div>';
2555
- 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>');
2556
3104
  break;
2557
3105
  case 'available':
2558
3106
  body.innerHTML = s.via === 'updater'
2559
- ? '<div class="hint">发现新版本 <b>v' + esc(s.version) + '</b>(' + cur + ')——正在后台下载,完成后退出 CreditDaddy 时自动安装。</div>'
2560
- : '<div class="hint">发现新版本 <b>v' + esc(s.version) + '</b>(' + cur + (s.sizeMB ? ',约 ' + s.sizeMB + ' MB' : '') + '),可由本机直接下载安装。</div>';
2561
- 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;
2562
3110
  break;
2563
3111
  case 'downloading':
2564
- body.innerHTML = '<div class="hint">正在下载 v' + esc(s.version) + '</div>'
3112
+ body.innerHTML = T('<div class="hint">正在下载 v') + esc(s.version) + '</div>'
2565
3113
  + '<div class="meter" style="margin:8px 0 4px"><span style="width:' + (s.percent || 0) + '%"></span></div>'
2566
- + '<div class="hint">' + (s.percent != null ? s.percent + '%' : '') + '(' + (s.mb || '…') + ' MB)' + (s.percent == null ? ' 获取大小中…' : '') + '</div>';
2567
- 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>');
2568
3116
  break;
2569
3117
  case 'downloaded':
2570
3118
  body.innerHTML = '<div class="hint"><span class="dot on pulse"></span> <b>v' + esc(s.version) + '</b> '
2571
- + (s.via === 'updater' ? '已下载完成,重启即完成更新(退出时也会自动安装)。' : '安装包已下载完成,安装时 CreditDaddy 会先退出。') + '</div>';
2572
- 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;
2573
3121
  break;
2574
3122
  case 'error':
2575
3123
  default:
2576
- body.innerHTML = '<div class="hint" style="color:var(--err)">检查更新失败:' + esc(s.error || '未知错误') + '</div>';
2577
- 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>');
2578
3126
  break;
2579
3127
  }
2580
3128
  const relBtn = $('upd-rel');
2581
- if (relBtn) relBtn.onclick = () => updBridge.openReleases();
3129
+ if (relBtn) relBtn.onclick = () => { if (updBridge) updBridge.openReleases(); else openFullChangelog(); };
2582
3130
  const goBtn = $('upd-go');
2583
- 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('重试'); }); }
2584
3132
  const retryBtn = $('upd-retry');
2585
3133
  if (retryBtn) retryBtn.onclick = () => updBridge.check().then(renderUpdateModal).catch(() => {});
2586
3134
  acts.querySelectorAll('[data-close-uu]').forEach((b) => { b.onclick = () => closeModal('m-update'); });
2587
3135
  }
2588
3136
  async function openUpdateModal(autoCheck) {
2589
- 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
+ }
2590
3143
  openModal('m-update');
2591
3144
  try {
2592
3145
  updState = await updBridge.state();
@@ -2597,10 +3150,36 @@ async function openUpdateModal(autoCheck) {
2597
3150
  }
2598
3151
  }
2599
3152
  if (updBridge) {
2600
- 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); });
2601
3154
  updBridge.onOpen(() => openUpdateModal(true));
2602
3155
  }
2603
- $('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
+ }
2604
3183
 
2605
3184
  // ZCode 自动领取计划:保存(按新配置启用)/ 停用
2606
3185
  $('zac-ok').addEventListener('click', async () => {
@@ -2611,7 +3190,7 @@ $('zac-ok').addEventListener('click', async () => {
2611
3190
  state.zClaimWindowMin = r.claimWindowMin;
2612
3191
  renderStatus(); render();
2613
3192
  closeModal('m-zac');
2614
- toast('自动领取已开启:每 ' + r.claimIntervalMin + ' 分钟检查' + (r.claimWindowMin > 0 ? ',运行 ' + r.claimWindowMin + ' 分钟后自动关闭' : ',一直运行直到手动关闭'));
3193
+ toast(T('自动领取已开启:每 ') + r.claimIntervalMin + T(' 分钟检查') + (r.claimWindowMin > 0 ? T(',运行 ') + r.claimWindowMin + T(' 分钟后自动关闭') : T(',一直运行直到手动关闭')));
2615
3194
  } catch (err) { toast(err.message, false); }
2616
3195
  });
2617
3196
  $('zac-off').addEventListener('click', async () => {
@@ -2620,7 +3199,7 @@ $('zac-off').addEventListener('click', async () => {
2620
3199
  state.zAutoClaim = r.autoClaim === true;
2621
3200
  renderStatus(); render();
2622
3201
  closeModal('m-zac');
2623
- toast('ZCode 自动领取:已关闭');
3202
+ toast(T('ZCode 自动领取:已关闭'));
2624
3203
  } catch (err) { toast(err.message, false); }
2625
3204
  });
2626
3205
 
@@ -2633,6 +3212,7 @@ function syncZgwLanFields() {
2633
3212
  $('zgw-on').addEventListener('change', syncZgwLanFields);
2634
3213
  $('zgw-ok').addEventListener('click', async () => {
2635
3214
  const allow = $('zgw-allow').value.split('\n').map((x) => x.trim()).filter(Boolean);
3215
+ const oldLan = state.zGatewayLan;
2636
3216
  try {
2637
3217
  const r = await api('/api/zcode-gateway', { method: 'PUT', json: { enabled: $('zgw-on').checked, lan: $('zgw-lan').checked, allow } });
2638
3218
  state.zGateway = r.enabled === true;
@@ -2640,8 +3220,9 @@ $('zgw-ok').addEventListener('click', async () => {
2640
3220
  state.zGatewayAllow = r.allow || [];
2641
3221
  renderStatus(); render();
2642
3222
  closeModal('m-zgw');
2643
- toast('体验包接口:' + (r.enabled ? '已开启' : '已关闭')
2644
- + (r.enabled && r.lan ? ',局域网开放' + (r.allow && r.allow.length ? ',白名单 ' + r.allow.join('、') : ',白名单为空——白名单外需虚拟 key') + ';绑定改动重启后生效' : ''));
3223
+ if (await maybePromptRestartForLan(oldLan, state.zGatewayLan)) return;
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(';绑定改动重启后生效') : ''));
2645
3226
  } catch (err) { toast(err.message, false); }
2646
3227
  });
2647
3228
 
@@ -2654,6 +3235,7 @@ function syncMgwLanFields() {
2654
3235
  $('mgw-on').addEventListener('change', syncMgwLanFields);
2655
3236
  $('mgw-ok').addEventListener('click', async () => {
2656
3237
  const allow = $('mgw-allow').value.split('\n').map((x) => x.trim()).filter(Boolean);
3238
+ const oldLan = state.mGatewayLan;
2657
3239
  try {
2658
3240
  const r = await api('/api/minimax-gateway', { method: 'PUT', json: { enabled: $('mgw-on').checked, lan: $('mgw-lan').checked, allow } });
2659
3241
  state.mGateway = r.enabled === true;
@@ -2661,27 +3243,59 @@ $('mgw-ok').addEventListener('click', async () => {
2661
3243
  state.mGatewayAllow = r.allow || [];
2662
3244
  renderStatus(); render();
2663
3245
  closeModal('m-mgw');
2664
- toast('MiniMax 接口:' + (r.enabled ? '已开启' : '已关闭')
2665
- + (r.enabled && r.lan ? ',局域网开放' + (r.allow && r.allow.length ? ',白名单 ' + r.allow.join('、') : '') : ''));
3246
+ if (await maybePromptRestartForLan(oldLan, state.mGatewayLan)) return;
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(';绑定改动重启后生效') : ''));
3249
+ } catch (err) { toast(err.message, false); }
3250
+ });
3251
+
3252
+ // Trae 本地接口:开关 + 局域网开放 + 免密 IP 白名单一次保存
3253
+ function syncTgwLanFields() {
3254
+ const off = !$('tgw-on').checked;
3255
+ $('tgw-lan-fields').querySelectorAll('input, textarea').forEach((el) => { el.disabled = off; });
3256
+ $('tgw-lan-fields').style.opacity = off ? '.45' : '';
3257
+ }
3258
+ $('tgw-on').addEventListener('change', syncTgwLanFields);
3259
+ $('tgw-ok').addEventListener('click', async () => {
3260
+ const allow = $('tgw-allow').value.split('\n').map((x) => x.trim()).filter(Boolean);
3261
+ const oldLan = state.tGatewayLan;
3262
+ try {
3263
+ const r = await api('/api/trae-gateway', { method: 'PUT', json: { enabled: $('tgw-on').checked, lan: $('tgw-lan').checked, allow } });
3264
+ state.tGateway = r.enabled === true;
3265
+ state.tGatewayLan = r.lan === true;
3266
+ state.tGatewayAllow = r.allow || [];
3267
+ renderStatus(); render();
3268
+ closeModal('m-tgw');
3269
+ if (await maybePromptRestartForLan(oldLan, state.tGatewayLan)) return;
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(';绑定改动重启后生效') : ''));
2666
3272
  } catch (err) { toast(err.message, false); }
2667
3273
  });
2668
3274
 
2669
- // ZCode 网络出口:顺序 + 代理地址保存
3275
+ // 网络出口(全局,面板设置里):顺序 + 代理地址保存;代理只回填到 placeholder(脱敏),留空即清除
2670
3276
  $('znet-ok').addEventListener('click', async () => {
2671
3277
  try {
2672
3278
  const body = { proxyFirst: $('znet-proxy').checked, proxyUrl: $('znet-url').value.trim() };
2673
3279
  const r = await api('/api/zcode/net', { method: 'PUT', json: body });
3280
+ state.zProxyFirst = r.proxyFirst === true;
2674
3281
  await loadZNet();
2675
- renderStatus(); render();
2676
- closeModal('m-znet');
2677
- 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(',代理已保存')));
2678
3287
  } catch (err) { toast(err.message, false); }
2679
3288
  });
2680
- document.querySelectorAll('.mask').forEach((m) => {
3289
+ document.querySelectorAll('.mask, .setpop').forEach((m) => {
2681
3290
  m.addEventListener('mousedown', (e) => { if (e.target === m) closeModal(m.id); });
2682
3291
  m.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => closeModal(m.id)));
2683
3292
  });
2684
- 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
+ });
2685
3299
  document.querySelectorAll('#m-add .tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
2686
3300
  function switchTab(name) {
2687
3301
  document.querySelectorAll('#m-add .tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
@@ -2704,7 +3318,7 @@ function openAdd(product) {
2704
3318
  // 各产品支持的添加方式:MiniMax 三种都支持(浏览器登录 = 官方设备码授权,独立凭据链不与本机客户端互斥)
2705
3319
  const tabShown = { local: true, device: product !== 'mirasim' && product !== 'catpaw' && product !== 'trae', token: product !== 'zcode' && product !== 'mirasim' && product !== 'catpaw' && product !== 'trae' };
2706
3320
  document.querySelectorAll('#m-add .tab').forEach((t) => { t.style.display = tabShown[t.dataset.tab] ? '' : 'none'; });
2707
- $('local-hint').textContent = LOCAL_HINT[state.addProduct];
3321
+ $('local-hint').textContent = T(LOCAL_HINT[state.addProduct]);
2708
3322
  let first = null;
2709
3323
  for (const o of $('f-provider').options) {
2710
3324
  const ok = !product || provOf(o.value).prod === product;
@@ -2720,8 +3334,8 @@ function openAdd(product) {
2720
3334
  if (ok && !firstDev) firstDev = o.value;
2721
3335
  }
2722
3336
  if (firstDev) $('dev-provider').value = firstDev;
2723
- $('dev-hint').textContent = (hasIncognito() ? '在内置隐私窗口里登录(不带出系统浏览器已登录的账号),' : '在浏览器里登录并授权,')
2724
- + '完成后账号自动添加。' + (product === 'qoder' ? '' : 'WorkBuddy / ZCode 用浏览器登录的账号同样可以一键切换客户端。');
3337
+ $('dev-hint').textContent = (hasIncognito() ? T('在内置隐私窗口里登录(不带出系统浏览器已登录的账号),') : T('在浏览器里登录并授权,'))
3338
+ + T('完成后账号自动添加。') + (product === 'qoder' ? '' : T('WorkBuddy / ZCode 用浏览器登录的账号同样可以一键切换客户端。'));
2725
3339
  openModal('m-add');
2726
3340
  switchTab('local');
2727
3341
  localScan();
@@ -2730,46 +3344,46 @@ function openAdd(product) {
2730
3344
  // 本机导入
2731
3345
  async function localScan() {
2732
3346
  const box = $('scan-result');
2733
- $('scan-status').textContent = '扫描中(最多约 10 秒)…';
2734
- 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>');
2735
3349
  try {
2736
3350
  const r = await api('/api/local/scan', { method: 'POST' });
2737
3351
  $('scan-status').textContent = '';
2738
3352
  let html = '';
2739
3353
  const cands = r.candidates.filter((c) => !state.addProduct || (c.provider ? provOf(c.provider).prod : 'qoder') === state.addProduct);
2740
3354
  for (const c of cands) {
2741
- const title = c.kind === 'app' ? esc(c.name || c.email || c.phone || '(未命名)') : '<span style="font-family:monospace">' + esc(c.tokenMasked) + '</span>';
2742
- 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>') : '');
2743
3357
  const sub = c.kind === 'app'
2744
- ? esc(c.source) + (c.email ? ' · ' + esc(mask(c.email)) : c.phone ? ' · ' + esc(c.phone) : '') + (c.expiresAt ? ' · 有效期至 ' + esc(fmtTime(c.expiresAt)) : '')
2745
- : esc(c.source) + ' · ' + esc(String(c.file || '').split(/[\\/]/).pop()) + '(' + c.hits + ' 处)';
2746
- const btns = c.imported ? '<span class="pill ok">已导入</span>' + (c.provider ? '<button class="btn" data-cid="' + esc(c.id) + '" title="用本机凭据更新 token / 有效期 / 邮箱">同步信息</button>' : '')
2747
- : c.provider ? '<button class="btn primary" data-cid="' + esc(c.id) + '">导入</button>'
2748
- : '<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>');
2749
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>';
2750
3364
  }
2751
3365
  for (const e of r.errors || []) {
2752
- 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>';
2753
3367
  }
2754
3368
  if (!cands.length) {
2755
- const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode / mirasim / 妙手 / Trae / MiniMax';
2756
- const alt = state.addProduct === 'zcode' || state.addProduct === 'mirasim' || state.addProduct === 'catpaw' || state.addProduct === 'trae' ? '' : ',或改用「浏览器登录」「粘贴 Token」';
2757
- html += '<div class="hint">未发现已登录的 ' + where + ' 账号。请先在客户端登录' + alt + '。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录、mirasim 数据目录、妙手凭据目录、Trae 数据目录、MiniMax 数据目录'
2758
- + (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>';
2759
3373
  }
2760
3374
  box.innerHTML = html;
2761
3375
  box.querySelectorAll('button[data-cid]').forEach((b) => b.addEventListener('click', async () => {
2762
3376
  b.disabled = true;
2763
3377
  try {
2764
3378
  const d = await api('/api/local/import', { json: { candidateId: b.dataset.cid, provider: b.dataset.prov } });
2765
- toast((d.updated ? '已更新:' : '已导入:') + (d.account.name || d.account.id));
3379
+ toast((d.updated ? T('已更新:') : T('已导入:')) + (d.account.name || d.account.id));
2766
3380
  await loadAccounts();
2767
3381
  localScan();
2768
3382
  } catch (e) { toast(e.message, false); b.disabled = false; }
2769
3383
  }));
2770
3384
  } catch (e) {
2771
3385
  $('scan-status').textContent = '';
2772
- 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>';
2773
3387
  }
2774
3388
  }
2775
3389
 
@@ -2793,13 +3407,13 @@ async function deviceLogin() {
2793
3407
  const flow = await api('/api/auth/device/start', { json: { provider } });
2794
3408
  $('device-box').style.display = '';
2795
3409
  $('dev-cancel').style.display = '';
2796
- const where = hasIncognito() ? '已在隐私窗口打开登录页' : '请在浏览器打开下方链接';
2797
- $('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(' 分钟内有效)');
2798
3412
  $('dev-link').href = flow.url;
2799
3413
  $('dev-link').textContent = flow.url;
2800
3414
  openAuth(flow.url);
2801
3415
  pollDevice(flow.sessionId, 0, ++devGen);
2802
- } catch (e) { toast('发起授权失败:' + e.message, false); }
3416
+ } catch (e) { toast(T('发起授权失败:') + e.message, false); }
2803
3417
  }
2804
3418
  async function pollDevice(sessionId, n, gen) {
2805
3419
  if (gen !== devGen) return;
@@ -2807,20 +3421,24 @@ async function pollDevice(sessionId, n, gen) {
2807
3421
  const r = await api('/api/auth/device/poll', { json: { sessionId } });
2808
3422
  if (gen !== devGen) return;
2809
3423
  if (r.status === 'pending') {
2810
- if (n > 450) return deviceStop('授权超时,请重试');
2811
- $('dev-status').textContent = '等待授权中…(' + n + ')';
3424
+ if (n > 450) return deviceStop(T('授权超时,请重试'));
3425
+ $('dev-status').textContent = T('等待授权中…(') + n + T(')');
2812
3426
  devTimer = setTimeout(() => pollDevice(sessionId, n + 1, gen), 2000);
2813
3427
  } else if (r.status === 'ok') {
2814
- deviceStop('✓ 登录成功:' + (r.account.name || r.account.id) + (r.already ? '(账号已存在,已更新登录凭据)' : ''));
2815
- 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));
2816
3430
  if (r.account.id) delete state.quotas[r.account.id]; // 旧 token 的积分查询结果(可能是失败)作废,马上重查
2817
3431
  await loadStatus(); await loadAccounts();
3432
+ // 网页会话 Cookie 是登录窗「关闭」时才收割绑定的(比这里的成功提示晚好几秒)——
3433
+ // 补两轮轻量刷新,「缺网页会话」徽章随绑定落地即时消失,不必等 30s 周期轮。
3434
+ setTimeout(refreshAccountList, 12000);
3435
+ setTimeout(refreshAccountList, 30000);
2818
3436
  } else if (r.status === 'expired') {
2819
- deviceStop('会话已过期,请重新发起');
3437
+ deviceStop(T('会话已过期,请重新发起'));
2820
3438
  } else {
2821
- deviceStop('授权失败:' + (r.error || r.status));
3439
+ deviceStop(T('授权失败:') + (r.error || r.status));
2822
3440
  }
2823
- } catch (e) { if (gen === devGen) deviceStop('轮询异常:' + e.message); }
3441
+ } catch (e) { if (gen === devGen) deviceStop(T('轮询异常:') + e.message); }
2824
3442
  }
2825
3443
  function deviceStop(msg) {
2826
3444
  devGen++;
@@ -2832,11 +3450,11 @@ function deviceStop(msg) {
2832
3450
  // 粘贴 token
2833
3451
  async function addToken() {
2834
3452
  const token = $('f-token').value.trim();
2835
- if (!token) return toast('请粘贴 token', false);
3453
+ if (!token) return toast(T('请粘贴 token'), false);
2836
3454
  const b = $('btn-add-token'); b.disabled = true;
2837
3455
  try {
2838
3456
  const d = await api('/api/accounts', { json: { provider: $('f-provider').value, name: $('f-name').value.trim(), token } });
2839
- 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);
2840
3458
  $('f-token').value = ''; $('f-name').value = '';
2841
3459
  closeModal('m-add');
2842
3460
  await loadAccounts();
@@ -2844,11 +3462,59 @@ async function addToken() {
2844
3462
  b.disabled = false;
2845
3463
  }
2846
3464
 
3465
+ // Qoder 卡片徽章:网页会话(Cookie)缺失或超 7 天时点名提醒——
3466
+ // 10Router 的「套餐内 Credits」逐资源包明细依赖它(同 qoderWebSessionNote 口径)。
3467
+ // 正常状态不出徽章,只在需要「浏览器登录」补 cookie 时出橙色警示,避免噪音。
3468
+ function qoderWebBadge(a) {
3469
+ if (!String(a.provider || '').startsWith('qoder')) return '';
3470
+ const at = a.qoderWebCapturedAt;
3471
+ if (at && Date.now() - new Date(at).getTime() <= 7 * 86400000) return '';
3472
+ const stale = Boolean(at);
3473
+ const proxyHint = a.provider === 'qoder' ? T('(国际版按 IP 识别地区:需先在「面板设置 → 网络出口」配置出口代理)') : '';
3474
+ const tip = stale
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>';
3478
+ }
3479
+
3480
+ // 导出弹窗里的网页会话提示(10Router issue #44):
3481
+ // 10Router 的「套餐内 Credits」逐资源包明细依赖随账号导出的 Qoder 网页会话
3482
+ // (openapi 聚合值在部分账号上会归零)。会话缺失或明显过期 → 导出前点名提醒,
3483
+ // 否则用户在 10Router 端只会看到套餐积分凭空消失。
3484
+ function qoderWebSessionNote() {
3485
+ const sel = $('ex-provider').value;
3486
+ const list = state.accounts.filter((a) => {
3487
+ if (!String(a.provider || '').startsWith('qoder')) return false;
3488
+ if (!sel || sel === 'p:qoder') return true;
3489
+ return a.provider === sel;
3490
+ });
3491
+ if (!list.length) return '';
3492
+ const missing = [], stale = [];
3493
+ for (const a of list) {
3494
+ const at = a.qoderWebCapturedAt;
3495
+ if (!at) missing.push(a);
3496
+ else if (Date.now() - new Date(at).getTime() > 7 * 86400000) stale.push(a);
3497
+ }
3498
+ if (!missing.length && !stale.length) return '';
3499
+ const names = (arr) => arr.map((x) => x.name || x.email || x.uid || x.id).join(T('、'));
3500
+ const bits = [];
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 再导出即可补上。');
3505
+ }
3506
+ function refreshExportNote() {
3507
+ const msg = qoderWebSessionNote();
3508
+ const el = $('ex-session-note');
3509
+ el.textContent = msg;
3510
+ el.style.display = msg ? '' : 'none';
3511
+ }
3512
+
2847
3513
  // 导出
2848
3514
  async function doExport() {
2849
3515
  const password = $('ex-password').value;
2850
- if (!password.trim()) return toast('导出必须设置加密口令', false);
2851
- 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);
2852
3518
  try {
2853
3519
  const sel = $('ex-provider').value;
2854
3520
  const scope = sel.startsWith('p:') ? { product: sel.slice(2) } : { provider: sel || undefined };
@@ -2861,7 +3527,7 @@ async function doExport() {
2861
3527
  URL.revokeObjectURL(a.href);
2862
3528
  $('ex-password').value = '';
2863
3529
  closeModal('m-export');
2864
- toast('已导出加密文件(可在 10router 或本面板导入)');
3530
+ toast(T('已导出加密文件(可在 10router 或本面板导入)'));
2865
3531
  } catch (e) { toast(e.message, false); }
2866
3532
  }
2867
3533
 
@@ -2872,10 +3538,10 @@ $('file-input').addEventListener('change', async () => {
2872
3538
  const f = $('file-input').files[0];
2873
3539
  if (!f) return;
2874
3540
  let data;
2875
- 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); }
2876
3542
  pendingImport = data;
2877
3543
  if (data && data.format === '10router-oauth-secure-v1') {
2878
- $('pass-hint').textContent = '「' + f.name + '」已加密(CreditDaddy / 10router 迁移格式),请输入导出时设置的口令。';
3544
+ $('pass-hint').textContent = T('「') + f.name + T('」已加密(CreditDaddy / 10router 迁移格式),请输入导出时设置的口令。');
2879
3545
  $('pass-input').value = '';
2880
3546
  openModal('m-pass');
2881
3547
  setTimeout(() => $('pass-input').focus(), 50);
@@ -2887,7 +3553,7 @@ async function submitImport(password) {
2887
3553
  try {
2888
3554
  const d = await api('/api/import', { json: { data: pendingImport, password } });
2889
3555
  closeModal('m-pass');
2890
- 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);
2891
3557
  pendingImport = null;
2892
3558
  await loadAccounts();
2893
3559
  } catch (e) {
@@ -2898,7 +3564,7 @@ async function submitImport(password) {
2898
3564
 
2899
3565
  // ── 绑定 ──
2900
3566
  // 全局组件开关:收起 Qoder / WorkBuddy / ZCode / 10Router 的标签页与仪表盘对应卡片
2901
- $('btn-comp').innerHTML = I.tune;
3567
+ $('btn-comp').innerHTML = I.settings;
2902
3568
 
2903
3569
  // ── 卡片拖拽排序(产品面板 + 10Router):手动顺序优先级最高,落库 localStorage ──
2904
3570
  const dragState = { kind: null, id: null, vendor: null };
@@ -3015,13 +3681,13 @@ function applyComponents() {
3015
3681
  // 右上角面板设置:访问密码 + 组件显隐与排序 + 每产品账号排序(默认积分多者靠前,可手动固定)
3016
3682
  function secStatusHtml() {
3017
3683
  if (!state.sec.loaded) return '<span class="sec-status">…</span>';
3018
- 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>');
3019
3685
  }
3020
3686
  function renderSecActions() {
3021
3687
  if (!state.sec.loaded) return '';
3022
3688
  return state.sec.enabled
3023
- ? '<button class="btn" data-sec-action="change">修改密码</button><button class="btn" data-sec-action="disable">关闭</button>'
3024
- : '<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>');
3025
3691
  }
3026
3692
  async function applyPanelKey(next) {
3027
3693
  setKey(next);
@@ -3029,86 +3695,87 @@ async function applyPanelKey(next) {
3029
3695
  }
3030
3696
  async function doSecAction(action) {
3031
3697
  if (action === 'enable' || action === 'change') {
3032
- const pw = await uiPrompt(action === 'enable' ? '设置面板访问密码' : '输入新密码', '', {
3033
- type: 'password', okText: action === 'enable' ? '开启' : '保存',
3034
- placeholder: '至少 4 位,不能包含空格',
3035
- 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('设置后每次打开面板都需要输入此密码。导出账号的加密口令是独立设置,始终必填。'),
3036
3702
  });
3037
3703
  if (!pw) return;
3038
3704
  const val = String(pw).trim();
3039
- if (val.length < 4) return toast('面板访问密码至少 4 位', false);
3040
- 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);
3041
3707
  try {
3042
3708
  const r = await api('/api/settings', { method: 'PUT', json: { panelKey: val } });
3043
3709
  await applyPanelKey(val);
3044
3710
  renderCompMenu();
3045
- toast(r.panelKeyEnabled ? '已设置面板访问密码,下次打开面板需要输入' : '已保存', true);
3711
+ toast(r.panelKeyEnabled ? T('已设置面板访问密码,下次打开面板需要输入') : T('已保存'), true);
3046
3712
  } catch (e) { toast(e.message, false); }
3047
3713
  return;
3048
3714
  }
3049
3715
  if (action === 'disable') {
3050
3716
  const ok = await uiConfirm(
3051
- '关闭后打开面板不再需要密码(局域网 / 公网访问将无保护,仅建议本机使用)。账号导出仍必须设置加密口令。\n\n确定关闭面板访问密码吗?',
3052
- '关闭面板访问密码', '关闭');
3717
+ T('关闭后打开面板不再需要密码(局域网 / 公网访问将无保护,仅建议本机使用)。账号导出仍必须设置加密口令。\n\n确定关闭面板访问密码吗?'),
3718
+ T('关闭面板访问密码'), T('关闭'));
3053
3719
  if (!ok) return;
3054
3720
  try {
3055
3721
  const r = await api('/api/settings', { method: 'PUT', json: { disable: true } });
3056
3722
  await loadSettings();
3057
3723
  await applyPanelKey(r.panelKeyEnabled ? getKey() : '');
3058
3724
  renderCompMenu();
3059
- toast(r.panelKeyEnabled ? '该部署环境(fnOS / 环境变量)强制启用访问密码,无法在面板内关闭' : '已关闭面板访问密码', true);
3725
+ toast(r.panelKeyEnabled ? T('该部署环境(fnOS / 环境变量)强制启用访问密码,无法在面板内关闭') : T('已关闭面板访问密码'), true);
3060
3726
  } catch (e) { toast(e.message, false); }
3061
3727
  }
3062
3728
  }
3063
3729
  function renderCompMenu() {
3064
- const compRows = state.compOrder.map((id) => {
3730
+ // 组件顺序横向 chips:一行放下全部产品,勾选 = 显隐,拖动 = 前后移(顺序与标签栏同步)
3731
+ const compChips = state.compOrder.map((id) => {
3065
3732
  const c = COMPONENTS.find((x) => x.id === id);
3066
3733
  if (!c) return '';
3067
3734
  const vis = compVisible(id);
3068
- return '<div class="comp-row' + (vis ? '' : ' off') + '">'
3069
- + '<label class="check"><input type="checkbox" data-comp="' + id + '"' + (vis ? ' checked' : '') + '>' + esc(c.label) + '</label>'
3070
- + '<span class="comp-ops">'
3071
- + '<button class="icon-btn mini" data-move="up" data-comp="' + id + '" title="上移(仪表盘与标签栏同步)">' + I.chevUp + '</button>'
3072
- + '<button class="icon-btn mini" data-move="down" data-comp="' + id + '" title="下移(仪表盘与标签栏同步)">' + I.chevDown + '</button>'
3073
- + '</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>';
3074
3740
  }).join('');
3075
3741
  const sortSections = PRODUCTS.filter((p) => compVisible(p.id) && ofProduct(p.id).length).map((p) => {
3076
3742
  const manual = state.cardOrder[p.id];
3077
- const rows = sortedAccounts(p.id, ofProduct(p.id)).map((a) => '<div class="sort-row" data-aid="' + esc(a.id) + '">'
3078
- + '<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>'
3079
- + '<span class="comp-ops">'
3080
- + '<button class="icon-btn mini" data-acc-move="up" data-prod="' + p.id + '" data-aid="' + esc(a.id) + '" title="上移">' + I.chevUp + '</button>'
3081
- + '<button class="icon-btn mini" data-acc-move="down" data-prod="' + p.id + '" data-aid="' + esc(a.id) + '" title="下移">' + I.chevDown + '</button>'
3082
- + '</span></div>').join('');
3083
- return '<details class="sort-sec"' + (manual && manual.length ? ' open' : '') + '><summary>' + esc(p.label)
3084
- + (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>') : '')
3085
3749
  + '<span class="dim" style="font-size:11px">' + ofProduct(p.id).length + '</span></summary>'
3086
- + '<div class="sort-hint">默认按剩余积分从多到少;拖拽卡片或用上下按钮固定顺序。</div>'
3087
- + rows
3088
- + (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>') : '')
3089
3753
  + '</details>';
3090
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>';
3091
3760
  $('comp-menu').innerHTML =
3092
- '<div class="comp-menu-title">面板访问密码 ' + secStatusHtml() + '</div>'
3093
- + '<div class="hint">开启后每次打开面板都需要输入密码;密码可随时在此修改或关闭。'
3094
- + (state.sec.enabled ? '' : '账号导出不受影响(导出口令始终必填)。') + '</div>'
3095
- + '<div class="row">' + renderSecActions() + '</div>'
3096
- + '<div class="comp-menu-title">自动签到</div>'
3761
+ T('<div class="comp-menu-title">自动签到</div>')
3097
3762
  + '<div class="hint">' + autoCheckinHint() + '</div>'
3098
- + '<div class="comp-menu-title">组件顺序(仪表盘与标签栏同步)</div>' + compRows
3099
- + '<div class="comp-menu-title">账号卡片排序</div>'
3100
- + (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>'));
3101
3768
  }
3102
3769
 
3103
3770
  /** 自动签到口径说明:调度周期 + 各产品业务日刷新时点 */
3104
3771
  function autoCheckinHint() {
3105
3772
  const sch = state.status && state.status.scheduler;
3106
- 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)) : '';
3107
3774
  const zc = sch && sch.zcode;
3108
3775
  const zcText = zc && zc.enabled
3109
- ? 'ZCode 自动领取运行中(每 ' + (zc.intervalMin || 2) + ' 分钟' + (zc.autoOffAt ? ',约 ' + Math.max(1, Math.ceil((new Date(zc.autoOffAt).getTime() - Date.now()) / 60000)) + ' 分钟后自动关闭' : ',一直运行') + '),周期可在 ZCode 页「自动领取计划」里改'
3110
- : 'ZCode 活动领取默认关闭,可在 ZCode 页「自动领取计划」里开启并配置轮询周期';
3111
- 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('。');
3112
3779
  }
3113
3780
 
3114
3781
  /** 在手动排序列表里移动一个 id;列表不存在时先按当前生效顺序物化一份 */
@@ -3124,49 +3791,45 @@ function moveAccountId(prod, aid, dir) {
3124
3791
  saveCardOrder();
3125
3792
  }
3126
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
+ });
3127
3801
  $('btn-comp').addEventListener('click', async () => {
3802
+ // 已打开再点齿轮 = 关闭(普通弹出层的开合直觉)
3803
+ if ($('m-settings').classList.contains('show')) { closeModal('m-settings'); return; }
3128
3804
  renderCompMenu();
3129
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'));
3130
3809
  await loadSettings();
3131
3810
  renderCompMenu();
3132
- // 桌面版才有检查更新入口(走 preload 桥到主进程的更新状态机)
3133
- if (updBridge) {
3134
- $('upd-entry').style.display = '';
3135
- try { const s = await updBridge.state(); updState = s; $('upd-ver').textContent = '当前 v' + (s.current || '?'); } catch {}
3136
- }
3137
- });
3138
- $('comp-menu').addEventListener('change', (e) => {
3139
- const c = e.target.dataset && e.target.dataset.comp;
3140
- if (!c) return;
3141
- if (e.target.checked) state.hidden.delete(c); else state.hidden.add(c);
3142
- saveHiddenComps();
3143
- applyComponents();
3144
- 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 = '';
3145
3820
  });
3146
3821
  $('comp-menu').addEventListener('click', (e) => {
3147
- const secBtn = e.target.closest && e.target.closest('[data-sec-action]');
3148
- if (secBtn) { doSecAction(secBtn.dataset.secAction); return; }
3149
- const move = e.target.closest && e.target.closest('[data-move]');
3150
- if (move) {
3151
- e.stopPropagation();
3152
- const id = move.dataset.comp;
3153
- const i = state.compOrder.indexOf(id);
3154
- const j = move.dataset.move === 'up' ? i - 1 : i + 1;
3155
- if (i < 0 || j < 0 || j >= state.compOrder.length) return;
3156
- [state.compOrder[i], state.compOrder[j]] = [state.compOrder[j], state.compOrder[i]];
3157
- 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();
3158
3827
  applyComponents();
3159
3828
  renderCompMenu();
3160
3829
  return;
3161
3830
  }
3162
- const accMove = e.target.closest && e.target.closest('[data-acc-move]');
3163
- if (accMove) {
3164
- e.stopPropagation();
3165
- moveAccountId(accMove.dataset.prod, accMove.dataset.aid, accMove.dataset.accMove);
3166
- render();
3167
- renderCompMenu();
3168
- return;
3169
- }
3831
+ const secBtn = e.target.closest && e.target.closest('[data-sec-action]');
3832
+ if (secBtn) { doSecAction(secBtn.dataset.secAction); return; }
3170
3833
  const reset = e.target.closest && e.target.closest('[data-acc-reset]');
3171
3834
  if (reset) {
3172
3835
  e.stopPropagation();
@@ -3176,30 +3839,100 @@ $('comp-menu').addEventListener('click', (e) => {
3176
3839
  renderCompMenu();
3177
3840
  }
3178
3841
  });
3179
- 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();
3180
3911
  renderCompMenu();
3181
3912
 
3182
- $('btn-checkin-all').innerHTML = I.play + '全部领取';
3183
- for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + '添加账号';
3184
- for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + '导入'; $(id).onclick = pickImport; }
3185
- 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('导出');
3186
3917
  $('btn-checkin-all').onclick = () => checkinAll($('btn-checkin-all'));
3187
3918
  $('btn-checkin').onclick = () => checkinAll($('btn-checkin'), state.view);
3188
3919
  $('btn-add-dash').onclick = () => openAdd('');
3189
3920
  $('btn-add').onclick = () => openAdd(state.view);
3190
- $('btn-export-dash').onclick = () => { $('ex-provider').value = ''; openModal('m-export'); };
3921
+ $('btn-export-dash').onclick = () => { $('ex-provider').value = ''; refreshExportNote(); openModal('m-export'); };
3191
3922
  $('btn-export').onclick = () => {
3192
3923
  const v = state.view;
3193
3924
  // ZCode 是单一 provider,直接按 provider 选;其余产品页按产品线(p:);仪表盘 / 10Router 导出全部
3194
3925
  $('ex-provider').value = v === 'zcode' ? 'zcode' : (v === 'qoder' || v === 'workbuddy' || v === 'mirasim' || v === 'catpaw' || v === 'trae' || v === 'minimax') ? 'p:' + v : '';
3926
+ refreshExportNote();
3195
3927
  openModal('m-export');
3196
3928
  };
3929
+ $('ex-provider').addEventListener('change', refreshExportNote);
3197
3930
  $('btn-do-export').onclick = doExport;
3198
3931
  $('btn-pass-ok').onclick = () => submitImport($('pass-input').value);
3199
3932
  $('pass-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitImport($('pass-input').value); });
3200
3933
  $('btn-scan').onclick = localScan;
3201
3934
  $('btn-device').onclick = deviceLogin;
3202
- $('dev-cancel').onclick = () => deviceStop('已取消');
3935
+ $('dev-cancel').onclick = () => deviceStop(T('已取消'));
3203
3936
  $('dev-link').addEventListener('click', (e) => { e.preventDefault(); openAuth($('dev-link').href); });
3204
3937
  $('btn-add-token').onclick = addToken;
3205
3938
  $('f-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') addToken(); });
@@ -3220,6 +3953,7 @@ function applyView() {
3220
3953
  $('view-10r').classList.toggle('active', state.view === 'tenrouter');
3221
3954
  if (state.view === 'tenrouter') loadZGateway();
3222
3955
  if (state.view === 'minimax') loadMGateway();
3956
+ if (state.view === 'trae') loadTGateway();
3223
3957
  if (state.view === 'tenrouter' && tr.config && tr.config.configured && !tr.quotas && !tr.loading) loadTrQuotas();
3224
3958
  followLogTab();
3225
3959
  applyLogTabs();
@@ -3247,15 +3981,16 @@ $('region').addEventListener('click', (e) => {
3247
3981
  renderStatus();
3248
3982
  });
3249
3983
  $('f-provider').addEventListener('change', () => {
3250
- $('f-token').placeholder = $('f-provider').value === 'minimax'
3251
- ? 'MiniMax OAuth Bearer Token(mmoat_…)'
3984
+ $('f-token').placeholder = $('f-provider').value.startsWith('minimax')
3985
+ ? T('MiniMax OAuth Bearer Token(mmoat_…)')
3252
3986
  : $('f-provider').value.startsWith('workbuddy')
3253
- ? 'WorkBuddy / CodeBuddy 登录 token(eyJ…,可在 CodeBuddyExtension 的 auth/*.info 中找到)'
3254
- : 'dt-… / jt-… 或 PAT(pt-…,自动兑换)';
3987
+ ? T('WorkBuddy / CodeBuddy 登录 token(eyJ…,可在 CodeBuddyExtension 的 auth/*.info 中找到)')
3988
+ : T('dt-… / jt-… 或 PAT(pt-…,自动兑换)');
3255
3989
  });
3256
3990
  $('btn-logs').onclick = () => {
3257
3991
  const c = $('log-card').classList.toggle('collapsed');
3258
- $('btn-logs').textContent = c ? '展开' : '收起';
3992
+ try { localStorage.setItem('qd_logs_expanded', c ? '0' : '1'); } catch {}
3993
+ $('btn-logs').textContent = c ? T('展开') : T('收起');
3259
3994
  if (!c) { loadCurrentLogs(); $('logs').scrollTop = $('logs').scrollHeight; }
3260
3995
  };
3261
3996
  $('log-tabs').addEventListener('click', (e) => {
@@ -3267,6 +4002,14 @@ $('log-tabs').addEventListener('click', (e) => {
3267
4002
  loadCurrentLogs();
3268
4003
  $('logs').scrollTop = $('logs').scrollHeight;
3269
4004
  });
4005
+ $('log-filter').addEventListener('click', (e) => {
4006
+ const b = e.target.closest('button[data-log-level]');
4007
+ if (!b) return;
4008
+ state.logLevel = b.dataset.logLevel;
4009
+ applyLogTabs();
4010
+ loadCurrentLogs();
4011
+ $('logs').scrollTop = $('logs').scrollHeight;
4012
+ });
3270
4013
 
3271
4014
  // ── 仪表盘底部两卡:默认收起,点标题展开,展开状态存本地 ──
3272
4015
  const DASH_FOLD_KEY = 'qd_dash_fold';
@@ -3279,7 +4022,7 @@ function applyDashFold() {
3279
4022
  });
3280
4023
  }
3281
4024
  document.querySelectorAll('.dash-cols .fold-head').forEach((h) => {
3282
- h.title = '展开 / 收起';
4025
+ h.title = T('展开 / 收起');
3283
4026
  h.addEventListener('click', () => {
3284
4027
  const k = h.closest('.pcard').dataset.fold;
3285
4028
  state.dashFold[k] = state.dashFold[k] !== true;
@@ -3291,15 +4034,20 @@ applyDashFold();
3291
4034
 
3292
4035
  // ── 启动:先直接请求(本机模式无需密钥);只有服务端返回 401 时才询问密钥 ──
3293
4036
  initTheme();
3294
- $('btn-home').innerHTML = I.github;
3295
4037
  applyView();
4038
+ function hideBootBusy() { const b = $('boot-busy'); if (!b) return; clearTimeout(hideBootBusy._t); b.classList.add('fade'); setTimeout(() => b.remove(), 300); }
3296
4039
  (function boot() {
3297
4040
  function start() {
3298
- 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(); });
3299
4046
  loadSettings().then(renderCompMenu);
3300
4047
  loadZNet();
3301
4048
  loadZGateway();
3302
4049
  loadMGateway();
4050
+ loadTGateway();
3303
4051
  loadTrConfig().then(() => loadTrQuotas());
3304
4052
  setInterval(() => loadTrQuotas(), 5 * 60 * 1000);
3305
4053
  // 账号余额(ZCode 剩余 token 等)自动刷新:可见时每 2 分钟一轮;从托盘/后台切回时若超过 1 分钟没刷过立即补一轮
@@ -3309,12 +4057,15 @@ applyView();
3309
4057
  });
3310
4058
  loadLogs();
3311
4059
  setInterval(loadLogs, 10000);
3312
- setInterval(async () => { await loadStatus(); await loadZNet(); await loadZGateway(); await loadMGateway(); render(); }, 30000);
4060
+ setInterval(async () => { await loadStatus(); await loadZNet(); await loadZGateway(); await loadMGateway(); await loadTGateway(); await refreshAccountList(); render(); }, 30000);
4061
+ });
3313
4062
  }
3314
4063
  // 先直接请求(本机模式无需密码);需要密码时 api() 会弹框并重试
3315
- api('/api/status').then(start).catch((e) => toast(e.code === 'PANEL_KEY'
3316
- ? '未输入面板访问密码,刷新页面可重试' : '无法连接守护进程:' + e.message, false));
4064
+ api('/api/status').then(start).catch((e) => { hideBootBusy(); toast(e.code === 'PANEL_KEY'
4065
+ ? T('未输入面板访问密码,刷新页面可重试') : T('无法连接守护进程:') + e.message, false); });
3317
4066
  })();
3318
4067
  </script>
4068
+ <!-- 语言切换加载态 -->
4069
+ <div id="locale-busy" hidden><div class="lb-card"><span class="lb-spin"></span><span data-i18n="正在切换语言…">正在切换语言…</span></div></div>
3319
4070
  </body>
3320
4071
  </html>