creditdaddy 0.8.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 ADDED
@@ -0,0 +1,1785 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CreditDaddy</title>
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
+ <style>
9
+ :root {
10
+ --bg: #f5f6f8; --surface: #ffffff; --surface-2: #fafafa; --tertiary: #f0f0f0; --hover: #f5f6f8; --active: #eef0f3;
11
+ --border-subtle: #eeeeee; --border: #e5e5e5; --border-strong: #cccccc;
12
+ --text: #1f1f1f; --text-2: #5f6368; --text-3: #999999;
13
+ --btn-bg: #1f1f1f; --btn-fg: #ffffff;
14
+ --accent-rgb: 34, 197, 94; --accent: #22c55e;
15
+ --ok: #16a34a; --warn: #d97706; --err: #dc2626; --err-soft: rgba(239, 68, 68, .1); --warn-soft: rgba(245, 158, 11, .12);
16
+ --shadow: 0 10px 40px rgba(0, 0, 0, .12); --toast-shadow: 0 6px 24px rgb(0 0 0 / 14%);
17
+ --mask: rgba(0, 0, 0, .4);
18
+ }
19
+ html[data-theme="dark"] {
20
+ --bg: #0a0a0c; --surface: #131316; --surface-2: #17171b; --tertiary: #1b1b20; --hover: rgba(255,255,255,.06); --active: rgba(255,255,255,.1);
21
+ --border-subtle: #202025; --border: rgba(255,255,255,.13); --border-strong: #3a3a42;
22
+ --text: #e6e6e9; --text-2: rgba(230,230,233,.72); --text-3: rgba(230,230,233,.5);
23
+ --btn-bg: rgba(255,255,255,.92); --btn-fg: #0a0a0c;
24
+ --accent-rgb: 127, 119, 221; --accent: #7f77dd;
25
+ --ok: #2ee59d; --warn: #ffb03a; --err: #ff6b6b; --err-soft: rgba(255,107,107,.12); --warn-soft: rgba(255,176,58,.12);
26
+ --shadow: 0 10px 40px rgba(0,0,0,.5); --toast-shadow: 0 6px 24px rgb(0 0 0 / 45%);
27
+ --mask: rgba(0, 0, 0, .55);
28
+ }
29
+ * { box-sizing: border-box; margin: 0; padding: 0; }
30
+ body {
31
+ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
32
+ font-size: 13px; color: var(--text); background: var(--bg); min-height: 100vh;
33
+ -webkit-font-smoothing: antialiased; transition: background .2s;
34
+ }
35
+ button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
36
+ .wrap { max-width: 1120px; margin: 0 auto; padding: 18px 22px 40px; }
37
+ .num { font-variant-numeric: tabular-nums; }
38
+
39
+ /* header */
40
+ .head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 2px 16px; }
41
+ .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
42
+ .logo { width: 36px; height: 36px; flex-shrink: 0; display: block; }
43
+ .title { font-family: "Avenir Next", Avenir, "Segoe UI Variable Display", "Segoe UI", sans-serif; font-size: 17px; font-weight: 750; letter-spacing: -.5px; line-height: 1.15; }
44
+ .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
+
47
+ /* shell panel */
48
+ .panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); overflow: hidden; }
49
+ .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); flex-wrap: wrap; }
50
+ .summary { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
51
+ .stat { display: flex; align-items: baseline; gap: 5px; white-space: nowrap; }
52
+ .stat span { color: var(--text-3); font-weight: 500; }
53
+ .stat strong { font-weight: 700; font-variant-numeric: tabular-nums; }
54
+ .divider { width: 1px; height: 16px; background: var(--border-subtle); }
55
+ .actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
56
+
57
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 11px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: var(--text-2); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .15s; line-height: 1.2; white-space: nowrap; }
58
+ .btn:hover { background: var(--hover); border-color: var(--border-strong); color: var(--text); }
59
+ .btn.primary { background: var(--btn-bg); color: var(--btn-fg); border-color: var(--btn-bg); }
60
+ .btn.primary:hover { opacity: .88; }
61
+ .btn.danger { background: #c62828; border-color: #c62828; color: #fff; }
62
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
63
+ .btn svg, .icon-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
64
+ .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; }
65
+ .icon-btn:hover { background: var(--active); color: var(--text); }
66
+ .icon-btn.del:hover { background: #ffecec; color: #f53f3f; }
67
+ html[data-theme="dark"] .icon-btn.del:hover { background: rgba(245,63,63,.15); }
68
+ .icon-btn.del.armed { background: #f53f3f; color: #fff; }
69
+ .icon-btn.busy svg { animation: spin 1s linear infinite; }
70
+ @keyframes spin { to { transform: rotate(360deg); } }
71
+
72
+ .notice { margin: 12px 16px 0; padding: 9px 12px; border-radius: 10px; background: var(--warn-soft); color: var(--warn); font-size: 12px; line-height: 1.6; }
73
+
74
+ /* cards */
75
+ .list { display: grid; grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); gap: 4px 10px; padding: 10px; }
76
+ .card { position: relative; padding: 11px 12px 12px; border-radius: 12px; transition: background .12s; min-width: 0; }
77
+ .card:hover { background: var(--hover); }
78
+ .row1 { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 28px; margin-bottom: 7px; }
79
+ .name-group { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
80
+ .name { font-size: 14px; font-weight: 600; cursor: text; border-radius: 5px; padding: 0 2px; margin: 0 -2px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
+ .name:hover { background: var(--active); }
82
+ .badge { display: inline-flex; align-items: center; font-size: 10px; line-height: 1; padding: 3px 7px; border-radius: 999px; font-weight: 500; background: var(--tertiary); color: var(--text-2); white-space: nowrap; }
83
+ .badge.intl { color: #3b82f6; background: rgba(59,130,246,.1); }
84
+ .badge.cn { color: #d97706; background: rgba(245,158,11,.12); }
85
+ .badge.prod { font-weight: 600; }
86
+ .badge.prod.qoder { color: #6d28d9; background: rgba(124,58,237,.1); }
87
+ .badge.prod.workbuddy { color: #0f766e; background: rgba(20,184,166,.12); }
88
+ html[data-theme="dark"] .badge.prod.qoder { color: #b8a4ff; }
89
+ html[data-theme="dark"] .badge.prod.workbuddy { color: #5eead4; }
90
+ .badge.prod.zcode { color: #1d4ed8; background: rgba(37,99,235,.1); }
91
+ html[data-theme="dark"] .badge.prod.zcode { color: #93c5fd; }
92
+ .pill.cur { background: rgba(20,184,166,.12); color: #0f766e; }
93
+ html[data-theme="dark"] .pill.cur { color: #5eead4; }
94
+ .seg-ctl { display: inline-flex; gap: 2px; padding: 2px; border-radius: 9px; background: var(--hover); }
95
+ .seg-ctl button { border: none; background: transparent; color: var(--text-2); font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 7px; cursor: pointer; }
96
+ .seg-ctl button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
97
+ html[data-theme="dark"] .seg-ctl button.active { background: rgba(255,255,255,.1); }
98
+ .pill { display: inline-flex; align-items: center; height: 22px; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; border: 1px solid transparent; }
99
+ .pill.ok { background: var(--text); color: var(--surface); }
100
+ html[data-theme="dark"] .pill.ok { background: rgba(255,255,255,.085); color: var(--text); border-color: rgba(255,255,255,.12); }
101
+ .pill.pending { background: var(--tertiary); color: var(--text-3); }
102
+ .pill.limited { background: var(--warn-soft); color: var(--warn); }
103
+ .pill.fail { background: var(--err-soft); color: var(--err); }
104
+ .ops { display: flex; gap: 6px; flex-shrink: 0; }
105
+
106
+ .meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; font-size: 12px; }
107
+ .mi { display: flex; align-items: center; gap: 6px; min-width: 0; }
108
+ .mi.right { justify-content: flex-end; flex-shrink: 0; }
109
+ .lbl { color: var(--text-3); flex-shrink: 0; white-space: nowrap; }
110
+ .val { color: var(--text); font-variant-numeric: tabular-nums; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
111
+ .val.warn { color: #f53f3f; font-weight: 600; }
112
+
113
+ .credit-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; font-size: 12px; }
114
+ .credit { display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums; }
115
+ .credit b { font-weight: 700; }
116
+ .credit svg { width: 13px; height: 13px; color: rgb(var(--accent-rgb)); }
117
+ .dim { color: var(--text-3); }
118
+ .claim-plan { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 2px; border-bottom: 1px solid var(--border); }
119
+ .claim-plan:last-of-type { border-bottom: 0; }
120
+ .claim-plan .grants { font-size: 12px; margin-top: 2px; }
121
+ #cap-holder { margin-top: 10px; min-height: 0; }
122
+ #cap-btn { margin-top: 10px; }
123
+ .bar { display: flex; gap: 2px; width: 100%; height: 5px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); border-radius: 3px; overflow: hidden; }
124
+ .seg { display: block; height: 5px; min-width: 3px; background: rgba(var(--accent-rgb), var(--a, 1)); transition: filter .15s; }
125
+ .seg.used { background: color-mix(in srgb, var(--text-3) 35%, transparent); }
126
+ .seg:hover { filter: brightness(1.12); }
127
+ .msg { margin-top: 7px; font-size: 11px; color: var(--text-3); line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
128
+ .msg.err { color: var(--err); }
129
+ .empty { grid-column: 1 / -1; text-align: center; color: var(--text-3); padding: 42px 12px; font-size: 13px; line-height: 1.9; }
130
+
131
+ /* section card */
132
+ .pcard { margin-top: 14px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 12px 14px; }
133
+ .pcard-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; font-weight: 700; }
134
+ .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; }
135
+ .logs .info { color: var(--text-2); } .logs .warn { color: var(--warn); } .logs .error { color: var(--err); } .logs .debug { color: var(--text-3); }
136
+ .collapsed .logs { display: none; }
137
+
138
+ /* modal */
139
+ .mask { position: fixed; inset: 0; z-index: 50; background: var(--mask); display: none; align-items: center; justify-content: center; padding: 16px; }
140
+ .mask.show { display: flex; }
141
+ .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); }
142
+ .modal.sm { width: 360px; }
143
+ .modal-title { display: flex; align-items: center; justify-content: space-between; font-size: 14px; font-weight: 700; margin-bottom: 12px; }
144
+ .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
145
+ .close { border: none; background: none; color: var(--text-3); font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
146
+ .close:hover { background: var(--hover); color: var(--text); }
147
+ .tabs { display: flex; gap: 6px; margin-bottom: 14px; }
148
+ .tab { flex: 1; padding: 7px 4px; border: none; border-radius: 10px; background: var(--hover); color: var(--text-2); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .18s; }
149
+ .tab:hover { color: var(--text); }
150
+ .tab.active { background: var(--btn-bg); color: var(--btn-fg); }
151
+ .pane { display: none; } .pane.active { display: block; }
152
+ .field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
153
+ .field label { font-size: 12px; color: var(--text-2); font-weight: 600; }
154
+ .input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); outline: none; transition: border-color .15s; }
155
+ .input:focus { border-color: #4f86ff; }
156
+ .input.mono { font-family: ui-monospace, Consolas, monospace; }
157
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
158
+ .hint { font-size: 12px; color: var(--text-3); line-height: 1.7; }
159
+ .hint a { color: #4f86ff; word-break: break-all; }
160
+ .box { border: 1px solid var(--border-subtle); border-radius: 10px; padding: 10px 12px; margin-top: 10px; background: var(--surface-2); }
161
+ .cand { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-subtle); }
162
+ .cand:last-child { border-bottom: none; }
163
+ .cand-main { min-width: 0; }
164
+ .cand-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
165
+ .cand-sub { font-size: 11px; color: var(--text-3); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
166
+
167
+ .notice.info { background: var(--tertiary); color: var(--text-2); }
168
+
169
+ /* 顶部标签页 */
170
+ .nav { display: flex; gap: 4px; padding: 4px; margin-bottom: 14px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 12px; width: fit-content; max-width: 100%; overflow-x: auto; }
171
+ .nav button { display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; color: var(--text-2); font-size: 13px; font-weight: 600; padding: 7px 14px; border-radius: 9px; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
172
+ .nav button:hover { color: var(--text); background: var(--hover); }
173
+ .nav button.active { background: var(--btn-bg); color: var(--btn-fg); }
174
+ .nav .cnt { font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 999px; background: var(--tertiary); color: var(--text-3); font-variant-numeric: tabular-nums; }
175
+ .nav button.active .cnt { background: rgba(255,255,255,.18); color: inherit; }
176
+ html[data-theme="dark"] .nav button.active .cnt { background: rgba(0,0,0,.12); }
177
+ .view { display: none; } .view.active { display: block; }
178
+
179
+ /* 仪表盘 */
180
+ .dash-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
181
+ .dash-title { font-size: 15px; font-weight: 700; }
182
+ .kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
183
+ .kpi { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 14px 16px; min-width: 0; }
184
+ .kpi-label { color: var(--text-3); font-size: 12px; font-weight: 500; }
185
+ .kpi-value { font-size: 26px; font-weight: 700; letter-spacing: -.5px; margin-top: 4px; font-variant-numeric: tabular-nums; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
186
+ .kpi-value small { font-size: 14px; color: var(--text-3); font-weight: 600; letter-spacing: 0; }
187
+ .kpi-sub { color: var(--text-3); font-size: 11px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
188
+ .prods { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px; margin-bottom: 10px; }
189
+ .prodcard { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: border-color .15s, box-shadow .15s; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
190
+ .prodcard:hover { border-color: var(--border-strong); box-shadow: 0 2px 10px rgba(0,0,0,.05); }
191
+ .prod-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
192
+ .prod-name { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 700; }
193
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
194
+ .dot.qoder { background: #7c3aed; } .dot.workbuddy { background: #14b8a6; } .dot.zcode { background: #2563eb; }
195
+ .prod-go { color: var(--text-3); font-size: 12px; white-space: nowrap; }
196
+ .prodcard:hover .prod-go { color: var(--text); }
197
+ .prod-rows { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 7px 12px; font-size: 12px; align-items: center; }
198
+ .prod-rows .val { text-align: right; }
199
+ .meter { height: 5px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); border-radius: 3px; overflow: hidden; }
200
+ .meter span { display: block; height: 100%; background: rgb(var(--accent-rgb)); border-radius: 3px; }
201
+ .dash-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
202
+ .dash-cols .pcard { margin-top: 0; min-width: 0; }
203
+ .item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 4px; margin: 0 -4px; border-bottom: 1px solid var(--border-subtle); font-size: 12px; cursor: pointer; border-radius: 6px; }
204
+ .item:hover { background: var(--hover); }
205
+ .item:last-child { border-bottom: none; }
206
+ .item-main { min-width: 0; display: flex; align-items: center; gap: 6px; }
207
+ .item-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
208
+ .item-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; min-width: 0; }
209
+ .item-time { color: var(--text-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
210
+ .muted-empty { color: var(--text-3); font-size: 12px; padding: 16px 0 6px; text-align: center; }
211
+ @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .prods, .dash-cols { grid-template-columns: 1fr; } }
212
+
213
+ /* 10Router */
214
+ .dot.tenrouter { background: #f59e0b; }
215
+ .badge.prod.tenrouter { color: #b45309; background: rgba(245,158,11,.14); }
216
+ html[data-theme="dark"] .badge.prod.tenrouter { color: #fcd34d; }
217
+ /* 瀑布流:卡片高低差异大(积分包多的卡很长),按列填充避免大片空白 */
218
+ .tr-grid { columns: 3 320px; column-gap: 10px; padding: 12px; }
219
+ .tr-grid > .empty { column-span: all; }
220
+ .tr-card { border: 1px solid var(--border-subtle); border-radius: 12px; padding: 11px 12px 12px; background: var(--surface); min-width: 0; break-inside: avoid; margin-bottom: 10px; display: inline-block; width: 100%; }
221
+ .tr-card.off { opacity: .6; }
222
+ .tr-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
223
+ .tr-title { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
224
+ .tr-name { font-weight: 600; font-size: 13px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
225
+ .tr-q { margin-top: 7px; }
226
+ .tr-q-line { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; margin-bottom: 4px; }
227
+ .tr-q-name { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
228
+ .tr-q-val { font-variant-numeric: tabular-nums; white-space: nowrap; }
229
+ .tr-q-val.low { color: var(--err); font-weight: 600; }
230
+ .tr-meter { height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--tertiary) 80%, transparent); overflow: hidden; }
231
+ .tr-meter span { display: block; height: 100%; border-radius: 3px; background: rgb(var(--accent-rgb)); }
232
+ .tr-meter span.low { background: var(--err); }
233
+ .tr-reset { font-size: 11px; color: var(--text-3); margin-top: 3px; }
234
+ .tr-primary { margin: 2px 0 6px; }
235
+ .tr-primary .lbl { max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
236
+ .tr-primary small { white-space: nowrap; }
237
+ .tr-p-line { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 5px; font-size: 12px; }
238
+ .tr-p-val { font-variant-numeric: tabular-nums; white-space: nowrap; }
239
+ .tr-p-val b { font-size: 20px; font-weight: 700; letter-spacing: -.3px; }
240
+ .tr-p-val small { color: var(--text-3); font-size: 12px; }
241
+ .tr-p-val.low b { color: var(--err); }
242
+ .tr-meter.big { height: 7px; }
243
+ .tr-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; margin-top: 6px; }
244
+ .low-text { color: var(--err); font-weight: 600; }
245
+ .tr-q-when { color: var(--text-3); font-weight: 400; }
246
+ .tr-q.spent .tr-q-name, .tr-q.spent .tr-q-val { color: var(--text-3); }
247
+ .tr-packs { margin-top: 9px; padding-top: 8px; border-top: 1px dashed var(--border-subtle); }
248
+ .tr-packs-head { font-size: 11px; color: var(--text-3); margin-bottom: 2px; }
249
+ .tr-packs-head b { color: var(--text-2); }
250
+ .tr-more { margin-top: 7px; border: none; background: none; color: #4f86ff; font-size: 12px; cursor: pointer; padding: 0; }
251
+ .tr-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); font-size: 12px; color: var(--text-2); }
252
+ .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; font-size: 11px; background: var(--tertiary); color: var(--text-3); }
253
+ .chip.on { background: rgba(var(--accent-rgb), .14); color: var(--text); }
254
+ .setup { padding: 22px; max-width: 620px; }
255
+ .setup h3 { font-size: 15px; margin-bottom: 8px; }
256
+ .check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); margin-right: 12px; cursor: pointer; }
257
+
258
+ /* toast */
259
+ #toast { position: fixed; bottom: 24px; left: 50%; transform: translate(-50%, 8px); z-index: 60; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 9px 14px; font-size: 12px; box-shadow: var(--toast-shadow); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; max-width: 80vw; }
260
+ #toast.show { opacity: 1; transform: translate(-50%, 0); }
261
+ #toast.err { border-color: var(--err); color: var(--err); }
262
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
263
+ @media (max-width: 560px) { .list { grid-template-columns: 1fr; } .wrap { padding: 12px; } }
264
+ </style>
265
+ </head>
266
+ <body>
267
+ <div class="wrap">
268
+ <header class="head">
269
+ <div class="brand">
270
+ <img class="logo" alt="" src="data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDI0IiBoZWlnaHQ9IjEwMjQiPjxwYXRoIGQ9Ik0zNzIuNDggMTM4LjY2NjY2N0ExMjggMTI4IDAgMCAwIDI5OC42NjY2NjcgMTAxLjU0NjY2N2ExMTcuNzYgMTE3Ljc2IDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzNsNzEuMjUzMzMzIDkxLjUyLTgxLjI4IDI4LjM3MzMzM2ExMC42NjY2NjcgMTAuNjY2NjY3IDAgMCAwLTIuMzQ2NjY3IDE4Ljk4NjY2NyAyNDIuNTYgMjQyLjU2IDAgMCAwIDY5Ljc2IDMyLjg1MzMzMyAyNzMuNzA2NjY3IDI3My43MDY2NjcgMCAwIDAgNTYuOTYgOC4zMnoiIGZpbGw9IiNGRjhBMTQiPjwvcGF0aD48cGF0aCBkPSJNMjQ1Ljc2IDEzOC4wMjY2NjdhMTMwLjk4NjY2NyAxMzAuOTg2NjY3IDAgMCAxIDUyLjA1MzMzMyAyLjEzMzMzM0ExNDguMDUzMzMzIDE0OC4wNTMzMzMgMCAwIDEgMzY2LjcyIDE3MC42NjY2NjdsNS43Ni0zMi44NTMzMzRBMTI4IDEyOCAwIDAgMCAyOTguNjY2NjY3IDEwMS41NDY2NjdhMTE3Ljk3MzMzMyAxMTcuOTczMzMzIDAgMCAwLTYyLjA4IDAgMTAuODggMTAuODggMCAwIDAtNS4xMiAxNi44NTMzMzN6IiBmaWxsPSIjRkZBQTU0Ij48L3BhdGg+PHBhdGggZD0iTTIzNC42NjY2NjcgOTcwLjY2NjY2N2EyNzcuMzMzMzMzIDMyIDAgMSAwIDU1NC42NjY2NjYgMCAyNzcuMzMzMzMzIDMyIDAgMSAwLTU1NC42NjY2NjYgMFoiIGZpbGw9IiM0NTQxM0MiIG9wYWNpdHk9Ii4xNSI+PC9wYXRoPjxwYXRoIGQ9Ik02MjAuMzczMzMzIDQ2MC41ODY2NjdzMTUxLjA0LTkwLjY2NjY2NyAxODEuMzMzMzM0LTc0LjY2NjY2Ny0xMi41ODY2NjcgMTc0LjI5MzMzMy0xMDYuNjY2NjY3IDIxMy4zMzMzMzMtNzQuNjY2NjY3LTEzOC42NjY2NjctNzQuNjY2NjY3LTEzOC42NjY2NjZ6IiBmaWxsPSIjRkZFNTAwIj48L3BhdGg+PHBhdGggZD0iTTYyMC4zNzMzMzMgNTEzLjkyczE1MS4wNC05MC42NjY2NjcgMTgxLjMzMzMzNC03NC42NjY2NjdhMTQuNzIgMTQuNzIgMCAwIDEgNS4zMzMzMzMgNS45NzMzMzRjNi44MjY2NjctMjkuODY2NjY3IDUuMzMzMzMzLTUzLjU0NjY2Ny01LjMzMzMzMy01OS4zMDY2NjctMzAuMjkzMzMzLTE2LTE4MS4zMzMzMzMgNzQuNjY2NjY3LTE4MS4zMzMzMzQgNzQuNjY2NjY3YTM4Ni45ODY2NjcgMzg2Ljk4NjY2NyAwIDAgMCAwIDYxLjg2NjY2NmMtMC40MjY2NjctNS4zMzMzMzMgMC04LjUzMzMzMyAwLTguNTMzMzMzeiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik00MDMuNjI2NjY3IDQ2MC41ODY2NjdzLTE1MS4wNC05MC42NjY2NjctMTgxLjMzMzMzNC03NC42NjY2NjcgMTIuMzczMzMzIDE3NC4yOTMzMzMgMTA2LjY2NjY2NyAyMTMuMzMzMzMzIDc0LjY2NjY2Ny0xMzguNjY2NjY3IDc0LjY2NjY2Ny0xMzguNjY2NjY2eiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik0yMjIuMjkzMzMzIDQzOS4yNTMzMzNjMzAuMjkzMzMzLTE2IDE4MS4zMzMzMzMgNzQuNjY2NjY3IDE4MS4zMzMzMzQgNzQuNjY2NjY3djguNTMzMzMzYTM3My41NDY2NjcgMzczLjU0NjY2NyAwIDAgMCAwLTYxLjg2NjY2NnMtMTUxLjA0LTkwLjY2NjY2Ny0xODEuMzMzMzM0LTc0LjY2NjY2N2MtMTAuNjY2NjY3IDUuNzYtMTIuMTYgMjkuNDQtNS4zMzMzMzMgNTkuMzA2NjY3YTEzLjQ0IDEzLjQ0IDAgMCAxIDUuMzMzMzMzLTUuOTczMzM0eiIgZmlsbD0iI0ZGRjQ4QyI+PC9wYXRoPjxwYXRoIGQ9Ik0yODggNjUwLjY2NjY2N2EyNjYuNjY2NjY3IDIyNCA5MCAxIDAgNDQ4IDAgMjY2LjY2NjY2NyAyMjQgOTAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik02ODAuMTA2NjY3IDQ3NC4yNGEyMjQgMjI0IDAgMCAxLTMzNi4yMTMzMzQgMEEyMTAuNTYgMjEwLjU2IDAgMCAxIDUxMiAzODRhMjEwLjU2IDIxMC41NiAwIDAgMSAxNjguMTA2NjY3IDkwLjI0eiIgZmlsbD0iI0VCQ0IwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMjY2LjY2NjY2N20tMjI0IDBhMjI0IDIyNCAwIDEgMCA0NDggMCAyMjQgMjI0IDAgMSAwLTQ0OCAwWiIgZmlsbD0iI0ZGRTUwMCI+PC9wYXRoPjxwYXRoIGQ9Ik01MTIgMTAxLjk3MzMzM2EyMjQuMjEzMzMzIDIyNC4yMTMzMzMgMCAwIDEgMjIxLjg2NjY2NyAxOTQuMzQ2NjY3IDIyNCAyMjQgMCAxIDAtNDQzLjczMzMzNCAwQTIyMy43ODY2NjcgMjIzLjc4NjY2NyAwIDAgMSA1MTIgMTAxLjk3MzMzM3oiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48cGF0aCBkPSJNODMzLjQ5MzMzMyA1OTguODI2NjY3bC03MC40IDcwLjRhMTguMzQ2NjY3IDE4LjM0NjY2NyAwIDAgMS0yNS44MTMzMzMgMEw2NDQuMDUzMzMzIDU3NkExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAwIDYxOC42NjY2NjcgNTc2bC05My4wMTMzMzQgOTMuMjI2NjY3YTE4Ljc3MzMzMyAxOC43NzMzMzMgMCAwIDEtMjYuMDI2NjY2IDBMNDA1LjMzMzMzMyA1NzZhMTguMTMzMzMzIDE4LjEzMzMzMyAwIDAgMC0yNS44MTMzMzMgMGwtOTMuMjI2NjY3IDkzLjIyNjY2N2ExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwbC03MC40LTcwLjRhOS4xNzMzMzMgOS4xNzMzMzMgMCAwIDAtMTUuNzg2NjY3IDYuODI2NjY2QzE4OS44NjY2NjcgODQxLjgxMzMzMyAzMzQuOTMzMzMzIDk2MCA1MTIgOTYwczMyMi4xMzMzMzMtMTE4LjE4NjY2NyAzMzcuMjgtMzU0LjM0NjY2N2E5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZ6IiBmaWxsPSIjRjBGMEYwIj48L3BhdGg+PHBhdGggZD0iTTE5MC41MDY2NjcgNjY1LjgxMzMzM2w3MC40IDYxLjIyNjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAwIDI1LjgxMzMzMyAwbDkzLjIyNjY2Ny04MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMSAyNS44MTMzMzMgMGw5My4yMjY2NjcgODEuMDY2NjY3YTIxLjMzMzMzMyAyMS4zMzMzMzMgMCAwIDAgMjYuMDI2NjY2IDBsOTMuNjUzMzM0LTgxLjA2NjY2N2EyMS4zMzMzMzMgMjEuMzMzMzMzIDAgMCAxIDI2LjAyNjY2NiAwbDkzLjIyNjY2NyA4MS4wNjY2NjdhMjEuMzMzMzMzIDIxLjMzMzMzMyAwIDAgMCAyNS44MTMzMzMgMGw3MC40LTYxLjIyNjY2N2E5LjgxMzMzMyA5LjgxMzMzMyAwIDAgMSA4LjUzMzMzNC0xLjkyIDUzNi4xMDY2NjcgNTM2LjEwNjY2NyAwIDAgMCA3LjI1MzMzMy01OC4yNCA5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjctNi44MjY2NjZsLTcwLjQgNzAuNGExOC4zNDY2NjcgMTguMzQ2NjY3IDAgMCAxLTI1LjgxMzMzMyAwTDY0NC4wNTMzMzMgNTc2QTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDAgNjE4LjY2NjY2NyA1NzZsLTkzLjAxMzMzNCA5My4yMjY2NjdhMTguNzczMzMzIDE4Ljc3MzMzMyAwIDAgMS0yNi4wMjY2NjYgMEw0MDUuMzMzMzMzIDU3NmExOC4xMzMzMzMgMTguMTMzMzMzIDAgMCAwLTI1LjgxMzMzMyAwbC05My4yMjY2NjcgOTMuMjI2NjY3YTE4LjM0NjY2NyAxOC4zNDY2NjcgMCAwIDEtMjUuODEzMzMzIDBsLTcwLjQtNzAuNGE5LjE3MzMzMyA5LjE3MzMzMyAwIDAgMC0xNS43ODY2NjcgNi44MjY2NjYgNTM3LjE3MzMzMyA1MzcuMTczMzMzIDAgMCAwIDcuMDQgNTguMjQgMTAuMDI2NjY3IDEwLjAyNjY2NyAwIDAgMSA5LjE3MzMzNCAxLjkyeiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPjxwYXRoIGQ9Ik00OTguMTMzMzMzIDE4NC41MzMzMzNtLTI5LjAxMzMzMyAwYTI5LjAxMzMzMyAyOS4wMTMzMzMgMCAxIDAgNTguMDI2NjY3IDAgMjkuMDEzMzMzIDI5LjAxMzMzMyAwIDEgMC01OC4wMjY2NjcgMFoiIGZpbGw9IiM0NTQxM0MiPjwvcGF0aD48cGF0aCBkPSJNNTc2IDI5MC45ODY2NjdjMCAxMS43MzMzMzMtMTUuNzg2NjY3IDIxLjMzMzMzMy0zNC45ODY2NjcgMjEuMzMzMzMzcy0zNS4yLTkuNi0zNS4yLTIxLjMzMzMzMyAxNS43ODY2NjctMjEuMzMzMzMzIDM1LjItMjEuMzMzMzM0IDM0Ljk4NjY2NyA5LjYgMzQuOTg2NjY3IDIxLjMzMzMzNHoiIGZpbGw9IiNGRkY0OEMiPjwvcGF0aD48L3N2Zz4=">
271
+ <div>
272
+ <div class="title">CreditDaddy</div>
273
+ <div class="ver" id="ver">Qoder / WorkBuddy / ZCode 多账号管理 · 每日积分自动签到</div>
274
+ </div>
275
+ </div>
276
+ <div class="head-right">
277
+ <span id="sched" class="num"></span>
278
+ <a class="icon-btn" id="btn-home" href="https://github.com/techysy/CreditDaddy" target="_blank" rel="noopener" title="项目主页(GitHub)"></a>
279
+ <button class="icon-btn" id="btn-theme" title="切换亮色 / 暗色"></button>
280
+ </div>
281
+ </header>
282
+
283
+ <nav class="nav" id="nav">
284
+ <button data-view="dash" class="active">仪表盘</button>
285
+ <button data-view="qoder">Qoder<span class="cnt" id="cnt-qoder">0</span></button>
286
+ <button data-view="workbuddy">WorkBuddy<span class="cnt" id="cnt-workbuddy">0</span></button>
287
+ <button data-view="zcode">ZCode<span class="cnt" id="cnt-zcode">0</span></button>
288
+ <button data-view="tenrouter">10Router<span class="cnt" id="cnt-tenrouter">–</span></button>
289
+ </nav>
290
+
291
+ <!-- 仪表盘 -->
292
+ <section class="view active" id="view-dash">
293
+ <div class="dash-head">
294
+ <div class="dash-title">总览</div>
295
+ <div class="actions">
296
+ <button class="btn primary" id="btn-checkin-all"></button>
297
+ <button class="btn" id="btn-add-dash"></button>
298
+ <button class="btn" id="btn-import-dash"></button>
299
+ <button class="btn" id="btn-export-dash"></button>
300
+ </div>
301
+ </div>
302
+ <div class="kpis" id="kpis"></div>
303
+ <div class="prods" id="prods"></div>
304
+ <div class="dash-cols">
305
+ <section class="pcard"><div class="pcard-title"><span>需要处理</span><span class="hint" id="att-count"></span></div><div id="attention"></div></section>
306
+ <section class="pcard"><div class="pcard-title"><span>最近签到</span><span class="hint" id="last-tick"></span></div><div id="recent"></div></section>
307
+ </div>
308
+ </section>
309
+
310
+ <!-- 产品页(Qoder / WorkBuddy / ZCode 共用) -->
311
+ <section class="view" id="view-prod">
312
+ <div class="panel">
313
+ <div class="toolbar">
314
+ <div class="summary">
315
+ <div class="stat"><span>账号</span><strong id="s-count">0</strong></div>
316
+ <div class="divider"></div>
317
+ <div id="s-done-wrap" style="display:contents">
318
+ <div class="stat"><span>今日已签</span><strong id="s-done">0</strong></div>
319
+ <div class="divider"></div>
320
+ </div>
321
+ <div class="stat"><span id="s-credits-label">剩余积分</span><strong id="s-credits">-</strong></div>
322
+ <div class="divider" id="region-div"></div>
323
+ <div class="seg-ctl" id="region">
324
+ <button data-r="" class="active">全部</button><button data-r="intl">国际版</button><button data-r="cn">国内版</button>
325
+ </div>
326
+ </div>
327
+ <div class="actions">
328
+ <button class="btn primary" id="btn-checkin"></button>
329
+ <button class="btn" id="btn-add"></button>
330
+ <button class="btn" id="btn-import"></button>
331
+ <button class="btn" id="btn-export"></button>
332
+ </div>
333
+ </div>
334
+ <div class="notice" id="notice" style="display:none"></div>
335
+ <div class="list" id="list"><div class="empty">加载中…</div></div>
336
+ </div>
337
+ </section>
338
+
339
+ <!-- 10Router:其他供应商额度 + 用量同步 -->
340
+ <section class="view" id="view-10r">
341
+ <div class="panel" id="tr-panel"><div class="empty">加载中…</div></div>
342
+ </section>
343
+
344
+ <section class="pcard collapsed" id="log-card">
345
+ <div class="pcard-title">
346
+ <span>运行日志</span>
347
+ <button class="btn" id="btn-logs">展开</button>
348
+ </div>
349
+ <div class="logs" id="logs">(暂无)</div>
350
+ </section>
351
+ </div>
352
+
353
+ <!-- 添加账号 -->
354
+ <div class="mask" id="m-add">
355
+ <div class="modal">
356
+ <div class="modal-title"><span>添加账号</span><button class="close" data-close>✕</button></div>
357
+ <div class="tabs">
358
+ <button class="tab active" data-tab="local">本机导入</button>
359
+ <button class="tab" data-tab="device">浏览器登录</button>
360
+ <button class="tab" data-tab="token">粘贴 Token</button>
361
+ </div>
362
+
363
+ <div class="pane active" data-pane="local">
364
+ <div class="hint" id="local-hint"></div>
365
+ <div class="row" style="margin-top:10px"><button class="btn primary" id="btn-scan">重新扫描</button><span class="hint" id="scan-status"></span></div>
366
+ <div class="box" id="scan-result"><div class="hint">点击「重新扫描」</div></div>
367
+ </div>
368
+
369
+ <div class="pane" data-pane="device">
370
+ <div class="hint" id="dev-hint" style="margin-bottom:10px"></div>
371
+ <div class="field">
372
+ <label>登录到</label>
373
+ <select class="input" id="dev-provider">
374
+ <option value="qoder">Qoder 国际版</option><option value="qoder-cn">Qoder 国内版</option>
375
+ <option value="workbuddy">WorkBuddy / CodeBuddy 国内版</option><option value="workbuddy-intl">WorkBuddy / CodeBuddy 国际版</option>
376
+ <option value="zcode-bigmodel">ZCode · BigModel(智谱)</option><option value="zcode-zai">ZCode · Z.ai(国际)</option>
377
+ </select>
378
+ </div>
379
+ <div class="row"><button class="btn primary" id="btn-device">生成授权链接</button><button class="btn" id="dev-cancel" style="display:none">取消</button></div>
380
+ <div class="box" id="device-box" style="display:none">
381
+ <div class="hint" id="dev-status"></div>
382
+ <div class="hint" style="margin-top:6px"><a id="dev-link" href="#" target="_blank" rel="noopener"></a></div>
383
+ </div>
384
+ </div>
385
+
386
+ <div class="pane" data-pane="token">
387
+ <div class="field">
388
+ <label>版本</label>
389
+ <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></select>
390
+ </div>
391
+ <div class="field"><label>备注名(可选)</label><input class="input" id="f-name" placeholder="留空则自动读取昵称"></div>
392
+ <div class="field"><label>Token</label><input class="input mono" id="f-token" placeholder="dt-… / jt-… 或 PAT(pt-…,自动兑换)"></div>
393
+ <div class="modal-actions"><button class="btn primary" id="btn-add-token">添加</button></div>
394
+ </div>
395
+ </div>
396
+ </div>
397
+
398
+ <!-- 导出 -->
399
+ <div class="mask" id="m-export">
400
+ <div class="modal sm">
401
+ <div class="modal-title"><span>导出账号</span><button class="close" data-close>✕</button></div>
402
+ <div class="field">
403
+ <label>范围</label>
404
+ <select class="input" id="ex-provider"><option value="">全部账号</option><option value="p:qoder">Qoder(全部)</option><option value="qoder">仅 Qoder 国际版</option><option value="qoder-cn">仅 Qoder 国内版</option><option value="p:workbuddy">WorkBuddy(全部)</option><option value="workbuddy">仅 WorkBuddy 国内版</option><option value="workbuddy-intl">仅 WorkBuddy 国际版</option><option value="zcode">ZCode</option></select>
405
+ </div>
406
+ <div class="field">
407
+ <label>加密口令(可选)</label>
408
+ <input class="input" id="ex-password" type="password" placeholder="至少 4 位;留空则导出明文">
409
+ </div>
410
+ <div class="hint">设置口令后文件采用 10router 迁移格式加密(scrypt + AES-256-GCM),可直接在 10router 对应提供商页导入(WorkBuddy 对应 10router 的 CodeBuddy)。10router 按提供商导入,建议按单个版本导出。</div>
411
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-do-export">导出</button></div>
412
+ </div>
413
+ </div>
414
+
415
+ <!-- 口令输入 -->
416
+ <div class="mask" id="m-pass">
417
+ <div class="modal sm">
418
+ <div class="modal-title"><span>输入口令</span><button class="close" data-close>✕</button></div>
419
+ <div class="hint" id="pass-hint">该文件已加密,请输入导出时设置的口令。</div>
420
+ <div class="field" style="margin-top:10px"><input class="input" id="pass-input" type="password"></div>
421
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="btn-pass-ok">导入</button></div>
422
+ </div>
423
+ </div>
424
+
425
+ <!-- 10Router 设置 -->
426
+ <div class="mask" id="m-10r">
427
+ <div class="modal">
428
+ <div class="modal-title"><span>10Router 连接设置</span><button class="close" data-close>✕</button></div>
429
+ <div class="hint">填 10Router 的访问地址和仪表盘里创建的<b>虚拟 key(sk-…)</b>。key 只保存在 CreditDaddy 守护进程的数据目录(0600),面板里只显示脱敏值。</div>
430
+ <div class="field" style="margin-top:12px"><label>10Router 地址</label><input class="input mono" id="tr-endpoint" placeholder="http://127.0.0.1:20128 或 http://nas:20128"></div>
431
+ <div class="field"><label>虚拟 key</label><input class="input mono" id="tr-key" placeholder="sk-…" autocomplete="off"></div>
432
+ <div class="field"><label>用量同步(同 10router-sync 插件)</label>
433
+ <div><label class="check"><input type="checkbox" id="tr-auto"> 每小时自动同步本机用量到 10Router</label></div>
434
+ <div id="tr-sources" style="margin-top:6px"></div>
435
+ <div class="hint" style="margin-top:4px">ZCode 只同步官方渠道;经 10Router 中转的 mirasim 调用会跳过,避免重复计数。10Router 按行签名去重,重复同步不会产生重复数据。</div>
436
+ </div>
437
+ <div class="hint" id="tr-test-msg"></div>
438
+ <div class="modal-actions">
439
+ <button class="btn" id="tr-disconnect" style="margin-right:auto">断开</button>
440
+ <button class="btn" id="tr-test">测试连接</button>
441
+ <button class="btn primary" id="tr-save">保存</button>
442
+ </div>
443
+ </div>
444
+ </div>
445
+
446
+ <!-- ZCode 活动领取 -->
447
+ <div class="mask" id="m-claim">
448
+ <div class="modal">
449
+ <div class="modal-title"><span>活动领取</span><button class="close" data-close>✕</button></div>
450
+ <div class="hint">从 ZCode 官方活动接口领取(billing/preview → billing/claim)。领取时通常静默通过人机验证;触发风控时会弹出验证码,按提示完成即可。</div>
451
+ <div id="claim-body" style="margin-top:10px"></div>
452
+ </div>
453
+ </div>
454
+
455
+ <!-- 通用对话框 -->
456
+ <div class="mask" id="m-dialog">
457
+ <div class="modal sm">
458
+ <div class="modal-title"><span id="dlg-title"></span><button class="close" data-close>✕</button></div>
459
+ <div class="hint" id="dlg-msg" style="white-space:pre-wrap"></div>
460
+ <div class="field" id="dlg-field" style="margin-top:10px"><input class="input" id="dlg-input"></div>
461
+ <div class="modal-actions"><button class="btn" data-close>取消</button><button class="btn primary" id="dlg-ok">确定</button></div>
462
+ </div>
463
+ </div>
464
+
465
+ <input type="file" id="file-input" accept=".json,application/json" style="display:none">
466
+ <div id="toast"></div>
467
+
468
+ <script>
469
+ 'use strict';
470
+
471
+ // ── 图标(Lucide 风格,currentColor) ──
472
+ const svg = (d) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
473
+ const I = {
474
+ play: svg('<polygon points="6 4 20 12 6 20 6 4"/>'),
475
+ refresh: svg('<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/>'),
476
+ trash: svg('<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/>'),
477
+ plus: svg('<path d="M12 5v14M5 12h14"/>'),
478
+ upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/>'),
479
+ download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>'),
480
+ sun: svg('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
481
+ moon: svg('<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>'),
482
+ swap: svg('<path d="M7 7h13l-4-4"/><path d="M17 17H4l4 4"/>'),
483
+ github: svg('<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65S8.93 17.38 9 18v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>'),
484
+ 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"/>'),
485
+ 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"/>'),
486
+ };
487
+
488
+ const $ = (id) => document.getElementById(id);
489
+ const state = { accounts: [], quotas: {}, status: null, busy: {}, view: 'dash', region: '', addProduct: '' };
490
+ const VIEWS = ['dash', 'qoder', 'workbuddy', 'zcode', 'tenrouter'];
491
+ // 10Router 集成:config 来自 /api/tenrouter,quotas 来自 /api/tenrouter/quotas
492
+ const tr = { config: null, quotas: null, error: null, loading: false, syncing: false };
493
+ const PRODUCTS = [
494
+ { id: 'qoder', label: 'Qoder' },
495
+ { id: 'workbuddy', label: 'WorkBuddy' },
496
+ { id: 'zcode', label: 'ZCode' },
497
+ ];
498
+ const prodLabel = (id) => (PRODUCTS.find((x) => x.id === id) || { label: id }).label;
499
+ const PROV = {
500
+ qoder: { prod: 'qoder', prodLabel: 'Qoder', region: '国际版', cls: 'intl' },
501
+ 'qoder-cn': { prod: 'qoder', prodLabel: 'Qoder', region: '国内版', cls: 'cn' },
502
+ workbuddy: { prod: 'workbuddy', prodLabel: 'WorkBuddy', region: '国内版', cls: 'cn' },
503
+ 'workbuddy-intl': { prod: 'workbuddy', prodLabel: 'WorkBuddy', region: '国际版', cls: 'intl' },
504
+ zcode: { prod: 'zcode', prodLabel: 'ZCode', region: '', cls: '' },
505
+ };
506
+ // 支持每日签到的产品(ZCode 积分靠需验证码的活动领取,不做签到)
507
+ const CHECKIN_PRODUCTS = new Set(['qoder', 'workbuddy']);
508
+ const CLIENT_LABEL = { workbuddy: 'WorkBuddy', zcode: 'ZCode' };
509
+ const provOf = (p) => PROV[p] || { prod: 'qoder', prodLabel: p, region: '', cls: '' };
510
+ const provBadges = (p, regionOnly) => { const v = provOf(p); return (regionOnly ? '' : '<span class="badge prod ' + v.prod + '">' + esc(v.prodLabel) + '</span>') + (v.region ? '<span class="badge ' + v.cls + '">' + v.region + '</span>' : ''); };
511
+
512
+ function esc(s) {
513
+ return String(s === null || s === undefined ? '' : s)
514
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
515
+ }
516
+ function fmtNum(n) {
517
+ if (n === null || n === undefined || Number.isNaN(n)) return '-';
518
+ return Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 });
519
+ }
520
+ function fmtTime(iso) {
521
+ if (!iso) return '';
522
+ const d = new Date(iso); if (Number.isNaN(d.getTime())) return '';
523
+ const p = (n) => String(n).padStart(2, '0');
524
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
525
+ }
526
+ function mask(s) {
527
+ if (!s) return '';
528
+ if (s.includes('@')) { const [u, d] = s.split('@'); return (u.length > 2 ? u.slice(0, 2) + '***' : u + '*') + '@' + d; }
529
+ return s.length > 10 ? s.slice(0, 6) + '…' + s.slice(-4) : s;
530
+ }
531
+
532
+ let toastTimer = null;
533
+ function toast(msg, ok) {
534
+ const t = $('toast');
535
+ t.textContent = msg;
536
+ t.classList.toggle('err', ok === false);
537
+ t.classList.add('show');
538
+ clearTimeout(toastTimer);
539
+ toastTimer = setTimeout(() => t.classList.remove('show'), 4200);
540
+ }
541
+
542
+ // ── API ──
543
+ // 面板访问密码(fnOS / 设置了 CREDITDADDY_PASSWORD 时需要)。内存里的值优先:面板嵌在飞牛桌面窗口里是跨域 iframe,
544
+ // 浏览器拦截第三方存储时 localStorage 不可用,只靠它会每个请求都 401
545
+ let memKey = null;
546
+ function getKey() {
547
+ if (memKey !== null) return memKey;
548
+ try { return localStorage.getItem('qd_key') || ''; } catch (e) { return ''; }
549
+ }
550
+ function setKey(k) {
551
+ memKey = k;
552
+ try { if (k) localStorage.setItem('qd_key', k); else localStorage.removeItem('qd_key'); } catch (e) {}
553
+ }
554
+ let keyPrompt = null;
555
+ /** 弹一次密码输入框(并发的请求共用同一个);返回 true = 已输入 */
556
+ function promptKey(invalid) {
557
+ if (!keyPrompt) {
558
+ keyPrompt = uiPrompt('输入面板访问密码', '', {
559
+ type: 'password', okText: '进入',
560
+ message: (invalid ? '密码不正确,请重新输入。\n' : '')
561
+ + 'fnOS:安装时设置的面板访问密码(忘了可以在 fnOS「应用设置」里重设)。\n命令行部署:环境变量 CREDITDADDY_PASSWORD 的值。',
562
+ }).then((k) => { keyPrompt = null; if (k === null || !k.trim()) return false; setKey(k.trim()); return true; });
563
+ }
564
+ return keyPrompt;
565
+ }
566
+ async function api(p, opts = {}, attempt = 0) {
567
+ const o = Object.assign({}, opts, { headers: Object.assign({}, opts.headers, { 'x-qd-key': getKey() }) });
568
+ if (opts.json !== undefined) {
569
+ o.method = opts.method || 'POST';
570
+ o.headers['Content-Type'] = 'application/json';
571
+ o.body = JSON.stringify(opts.json);
572
+ }
573
+ delete o.json;
574
+ const res = await fetch(p, o);
575
+ const data = await res.json().catch(() => ({}));
576
+ // 密码缺失 / 不正确:弹框要密码,输入后自动重试这次请求(不再只报一句错误)
577
+ if (res.status === 401 && data.code === 'PANEL_KEY' && attempt < 3) {
578
+ const hadKey = Boolean(getKey());
579
+ setKey('');
580
+ if (await promptKey(hadKey)) return api(p, opts, attempt + 1);
581
+ }
582
+ if (!res.ok) { const e = new Error(data.error || ('HTTP ' + res.status)); e.code = data.code; e.status = res.status; e.nextAt = data.nextAt; throw e; }
583
+ return data;
584
+ }
585
+
586
+ // ── 主题 ──
587
+ function applyTheme(t) {
588
+ document.documentElement.dataset.theme = t;
589
+ $('btn-theme').innerHTML = t === 'dark' ? I.sun : I.moon;
590
+ }
591
+ function initTheme() {
592
+ let t = null;
593
+ try { t = localStorage.getItem('qd_theme'); } catch (e) {}
594
+ if (!t) t = window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
595
+ applyTheme(t);
596
+ $('btn-theme').onclick = () => {
597
+ const n = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
598
+ applyTheme(n);
599
+ try { localStorage.setItem('qd_theme', n); } catch (e) {}
600
+ };
601
+ }
602
+
603
+ // ── 状态 / 渲染 ──
604
+ function statusOf(a) {
605
+ // ZCode 没有每日签到(只有活动领取),不显示签到徽标
606
+ if (!CHECKIN_PRODUCTS.has(a.product)) return { cls: '', text: '', msg: '' };
607
+ const today = state.status && state.status.today;
608
+ const done = state.status && state.status.todayDone && state.status.todayDone[a.id] === today;
609
+ const r = a.lastResult;
610
+ const fresh = r && r.at && state.status && (Date.now() - new Date(r.at).getTime() < 26 * 3600e3);
611
+ if (r && fresh && r.status === 'limited' && done) return { cls: 'limited', text: '本机已领', msg: r.message };
612
+ if (done) {
613
+ const amt = r && r.status === 'checked-in' && fresh && r.amount ? ' +' + fmtNum(r.amount) : '';
614
+ const streak = r && fresh && r.streakDays ? ' · 连签 ' + r.streakDays + ' 天' : '';
615
+ return { cls: 'ok', text: '今日已签' + amt + streak, msg: r && r.message && !['checked-in', 'already'].includes(r.status) ? r.message : '' };
616
+ }
617
+ if (r && fresh && r.status === 'failed') return { cls: 'fail', text: '签到失败', msg: r.message, err: true };
618
+ if (r && fresh && r.status === 'no-activity') return { cls: 'pending', text: '待领取', msg: r.message };
619
+ return { cls: 'pending', text: '未签到', msg: '' };
620
+ }
621
+
622
+ function expiryCell(a) {
623
+ if (!a.expiresAt) return { label: a.isPat ? 'PAT 长期有效' : '—', warn: false };
624
+ const ms = new Date(a.expiresAt).getTime() - Date.now();
625
+ if (ms <= 0) return { label: '已过期', warn: true };
626
+ return { label: fmtTime(a.expiresAt), warn: ms < 7 * 86400e3 };
627
+ }
628
+
629
+ function creditHtml(a) {
630
+ const q = state.quotas[a.id];
631
+ if (!q) return '<div class="credit-line"><span class="dim">读取积分中…</span></div><div class="bar"></div>';
632
+ 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>';
633
+ if (q.empty) return '<div class="credit-line"><span class="dim">无有效套餐(仅免费额度)</span></div><div class="bar"></div>';
634
+ const unit = q.unit ? ' ' + q.unit : '';
635
+ if (q.unit) {
636
+ const items = q.parts.map((x) => {
637
+ const base = esc(x.name) + ' ' + fmtNum(x.remaining) + '/' + fmtNum(x.total) + (x.unit ? ' ' + esc(x.unit) : '');
638
+ if (x.pending && x.startsAt) return base + '(' + fmtTime(x.startsAt).slice(5, 16) + ' 生效)';
639
+ return base + (x.expiresAt ? '(' + fmtTime(x.expiresAt).slice(5, 16) + (x.recurring ? ' 重置)' : ' 到期)') : '');
640
+ }).join(' · ');
641
+ const pct = q.total > 0 ? Math.min(1, q.remaining / q.total) : 0;
642
+ return '<div class="credit-line"><span class="credit"><span class="lbl">剩余</span><b>' + fmtNum(q.remaining) + '</b>' + esc(unit) + '</span>'
643
+ + '<span class="credit dim" title="' + items + '">' + (q.plan ? esc(q.plan) + ' · ' : '') + items + '</span></div>'
644
+ + '<div class="bar"><span class="seg" style="flex:' + pct.toFixed(4) + ' 1 0"></span><span class="seg used" style="flex:' + (1 - pct).toFixed(4) + ' 1 0"></span></div>';
645
+ }
646
+ const total = q.total || 0;
647
+ let bar = '';
648
+ if (total > 0) {
649
+ q.parts.filter((x) => x.remaining > 0).forEach((x, i) => {
650
+ bar += '<span class="seg" style="flex:' + (x.remaining / total).toFixed(4) + ' 1 0;--a:' + (x.recurring ? 1 : Math.max(.45, .85 - i * .05)) + '" title="'
651
+ + 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>';
652
+ });
653
+ if (q.used > 0) bar += '<span class="seg used" style="flex:' + (q.used / total).toFixed(4) + ' 1 0" title="已使用 ' + fmtNum(q.used) + '"></span>';
654
+ }
655
+ const soon = q.parts.filter((x) => x.remaining > 0 && !x.recurring && x.expiresAt).sort((x, y) => x.expiresAt.localeCompare(y.expiresAt))[0];
656
+ return '<div class="credit-line">'
657
+ + '<span class="credit"><span class="lbl">剩余</span>' + I.credit + '<b>' + fmtNum(q.remaining) + '</b></span>'
658
+ + '<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>'
659
+ + '</div><div class="bar">' + bar + '</div>';
660
+ }
661
+
662
+ function cardHtml(a) {
663
+ const st = statusOf(a);
664
+ const ex = expiryCell(a);
665
+ const ident = a.email || a.phone || (a.uid ? 'UID ' + a.uid.slice(0, 8) : a.tokenMasked);
666
+ const isCurrent = isClientCurrent(a);
667
+ const canCheckin = CHECKIN_PRODUCTS.has(a.product);
668
+ return '<div class="card" data-id="' + esc(a.id) + '">'
669
+ + '<div class="row1"><div class="name-group">'
670
+ + '<span class="name" data-act="rename" title="点击修改备注名">' + esc(a.name || '(未命名)') + '</span>'
671
+ + provBadges(a.provider, true)
672
+ + (isCurrent ? '<span class="pill cur" title="' + esc(CLIENT_LABEL[a.product] || '') + ' 客户端当前登录的账号">客户端当前</span>' : '')
673
+ + (a.isPat ? '<span class="badge">PAT</span>' : '')
674
+ + (a.verified === false ? '<span class="pill fail">token 无效</span>' : '')
675
+ + (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>' : '')
676
+ + '</div><div class="ops">'
677
+ + (a.product === 'zcode' ? '<button class="icon-btn" data-act="claim" title="活动领取(billing/preview → claim,可能需过一次验证码)">' + I.gift + '</button>' : '')
678
+ + (canCheckin ? '<button class="icon-btn' + (state.busy[a.id] ? ' busy' : '') + '" data-act="checkin" title="立即签到">' + (state.busy[a.id] ? I.refresh : I.play) + '</button>' : '')
679
+ + (CLIENT_LABEL[a.product] && a.canSwitch && !isCurrent && st.cls !== 'fail' ? '<button class="icon-btn" data-act="switch" title="切换 ' + CLIENT_LABEL[a.product] + ' 客户端到此账号">' + I.swap + '</button>' : '')
680
+ + '<button class="icon-btn" data-act="quota" title="刷新积分">' + I.refresh + '</button>'
681
+ + '<button class="icon-btn del" data-act="del" title="删除(点两次确认)">' + I.trash + '</button>'
682
+ + '</div></div>'
683
+ + '<div class="meta">'
684
+ + '<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>'
685
+ + '<div class="mi right"><span class="lbl">有效期至</span><span class="val' + (ex.warn ? ' warn' : '') + '">' + esc(ex.label) + '</span></div>'
686
+ + '</div>'
687
+ + creditHtml(a)
688
+ + (st.msg && !(state.quotas[a.id] && state.quotas[a.id].error && state.quotas[a.id].error.includes(st.msg.slice(-24)))
689
+ ? '<div class="msg' + (st.err ? ' err' : '') + '" title="' + esc(st.msg) + '">' + esc(st.msg) + '</div>' : '')
690
+ + '</div>';
691
+ }
692
+
693
+ const isDone = (a) => Boolean(state.status && state.status.todayDone && state.status.todayDone[a.id] === state.status.today);
694
+ const ofProduct = (prod) => state.accounts.filter((a) => a.product === prod);
695
+ const currentUidOf = (prod) => state.status && (prod === 'workbuddy' ? state.status.workbuddyCurrentUid : prod === 'zcode' ? state.status.zcodeCurrentUid : null);
696
+ // 客户端当前登录判定:WorkBuddy 按 uid;ZCode 按 uid 或 email(早期导入的账号可能只存了其中一个)
697
+ function isClientCurrent(a) {
698
+ const s = state.status;
699
+ if (!s) return false;
700
+ if (a.product === 'workbuddy') return Boolean(a.uid && s.workbuddyCurrentUid && s.workbuddyCurrentUid === a.uid);
701
+ if (a.product === 'zcode') {
702
+ const id = s.zcodeCurrentIdentity;
703
+ return Boolean(id && ((a.uid && id.uid != null && String(id.uid) === String(a.uid)) || (a.email && id.email && id.email === a.email)));
704
+ }
705
+ return false;
706
+ }
707
+ // 积分合计:只统计「积分」口径(ZCode 是 token 额度,单位不同,不相加)
708
+ function creditSum(list) {
709
+ let sum = 0, known = 0, loading = 0;
710
+ for (const a of list) {
711
+ const q = state.quotas[a.id];
712
+ if (!q) { loading++; continue; }
713
+ if (q.error) continue;
714
+ known++;
715
+ if (!q.unit) sum += q.remaining || 0;
716
+ }
717
+ return { sum, known, loading };
718
+ }
719
+
720
+ function render() {
721
+ for (const p of PRODUCTS) $('cnt-' + p.id).textContent = ofProduct(p.id).length;
722
+ $('cnt-tenrouter').textContent = tr.quotas ? tr.quotas.connections.length : tr.config && tr.config.configured ? '…' : '–';
723
+ if (state.view === 'dash') renderDash();
724
+ else if (state.view === 'tenrouter') renderTenrouter();
725
+ else renderProd();
726
+ }
727
+
728
+ const EMPTY_TIP = {
729
+ qoder: '还没有 Qoder 账号。<br>点「添加账号」可一键读取本机 Qoder / Qoder CN 客户端已登录的账号,或用浏览器登录。',
730
+ workbuddy: '还没有 WorkBuddy 账号。<br>点「添加账号」可读取本机 WorkBuddy 客户端(当前 + 历史会话),或用浏览器登录 WorkBuddy / CodeBuddy 账号。',
731
+ zcode: '还没有 ZCode 账号。<br>点「添加账号」可读取本机 ZCode 客户端已登录的账号,或用浏览器登录(BigModel / Z.ai),多个账号可一键切换。',
732
+ };
733
+
734
+ function renderProd() {
735
+ const prod = state.view;
736
+ const all = ofProduct(prod);
737
+ const acc = all.filter((a) => !state.region || provOf(a.provider).cls === state.region);
738
+ const list = $('list');
739
+ if (!acc.length) list.innerHTML = '<div class="empty">' + (all.length ? '该地区下没有账号。' : EMPTY_TIP[prod]) + '</div>';
740
+ else list.innerHTML = acc.map(cardHtml).join('');
741
+ const canCheckin = CHECKIN_PRODUCTS.has(prod);
742
+ $('s-count').textContent = acc.length;
743
+ $('s-done-wrap').style.display = canCheckin ? 'contents' : 'none';
744
+ $('s-done').textContent = acc.filter(isDone).length + ' / ' + acc.length;
745
+ const c = creditSum(acc);
746
+ $('s-credits-label').textContent = prod === 'zcode' ? '有套餐' : '剩余积分';
747
+ $('s-credits').textContent = prod === 'zcode'
748
+ ? acc.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length + ' / ' + acc.length
749
+ : c.known ? fmtNum(c.sum) : '-';
750
+ const hasRegions = prod !== 'zcode';
751
+ $('region').style.display = hasRegions ? '' : 'none';
752
+ $('region-div').style.display = hasRegions ? '' : 'none';
753
+ document.querySelectorAll('#region button').forEach((x) => x.classList.toggle('active', x.dataset.r === state.region));
754
+ $('btn-checkin').style.display = canCheckin ? '' : 'none';
755
+ if (!$('btn-checkin').disabled) $('btn-checkin').innerHTML = I.play + '签到全部 ' + prodLabel(prod);
756
+ }
757
+
758
+ function kpi(label, value, sub) {
759
+ return '<div class="kpi"><div class="kpi-label">' + label + '</div><div class="kpi-value">' + value + '</div><div class="kpi-sub" title="' + esc(sub.replace(/<[^>]+>/g, '')) + '">' + sub + '</div></div>';
760
+ }
761
+ const meter = (done, total) => '<div class="meter"><span style="width:' + (total ? Math.round(done / total * 100) : 0) + '%"></span></div>';
762
+
763
+ function renderDash() {
764
+ const s = state.status || {};
765
+ const sch = s.scheduler || {};
766
+ const signable = state.accounts.filter((a) => CHECKIN_PRODUCTS.has(a.product));
767
+ const done = signable.filter(isDone).length;
768
+ const failed = signable.filter((a) => statusOf(a).cls === 'fail').length;
769
+ const credit = creditSum(state.accounts.filter((a) => a.product !== 'zcode'));
770
+ const byProd = PRODUCTS.map((p) => p.label + ' ' + ofProduct(p.id).length).join(' · ');
771
+ const creditByProd = ['qoder', 'workbuddy'].map((id) => { const c = creditSum(ofProduct(id)); return prodLabel(id) + ' ' + (c.known ? fmtNum(c.sum) : '-'); }).join(' · ');
772
+ $('kpis').innerHTML =
773
+ kpi('账号', state.accounts.length, byProd)
774
+ + kpi('今日已签', done + ' <small>/ ' + signable.length + '</small>',
775
+ !signable.length ? '暂无可签到的账号' : done === signable.length ? '今天全部签完' : '待签 ' + (signable.length - done) + (failed ? ' · 失败 ' + failed : ''))
776
+ + kpi('剩余积分', credit.known ? fmtNum(credit.sum) : '-', credit.loading ? '读取中(' + credit.loading + ' 个账号)…' : creditByProd)
777
+ + kpi('下次自动签到', sch.ticking ? '签到中…' : sch.nextTickAt ? fmtTime(sch.nextTickAt).slice(11) : '-',
778
+ sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) + ' · ' + esc(sch.lastTick.summary || '') : '每 ~2 小时一轮');
779
+
780
+ $('prods').innerHTML = PRODUCTS.map((p) => {
781
+ const list = ofProduct(p.id);
782
+ let rows = '';
783
+ if (!list.length) {
784
+ rows = '<span class="lbl">账号</span><span class="val dim">还没有,点击进入添加</span>';
785
+ } else if (CHECKIN_PRODUCTS.has(p.id)) {
786
+ const d = list.filter(isDone).length;
787
+ const c = creditSum(list);
788
+ rows = '<span class="lbl">今日签到</span><span class="val">' + d + ' / ' + list.length + '</span>'
789
+ + '<span style="grid-column:1 / -1">' + meter(d, list.length) + '</span>'
790
+ + '<span class="lbl">剩余积分</span><span class="val">' + (c.known ? fmtNum(c.sum) : '-') + '</span>';
791
+ } else {
792
+ const planned = list.filter((a) => { const q = state.quotas[a.id]; return q && !q.error && !q.empty; }).length;
793
+ rows = '<span class="lbl">每日签到</span><span class="val dim">无(活动领取需验证码)</span>'
794
+ + '<span class="lbl">有效套餐</span><span class="val">' + planned + ' / ' + list.length + '</span>';
795
+ }
796
+ if (p.id === 'qoder' && list.some((a) => a.provider === 'qoder')) {
797
+ rows += '<span class="lbl">国际版风控身份</span><span class="val' + (s.riskIdentity ? '' : ' warn') + '">' + (s.riskIdentity ? (s.riskSource === 'cli' ? '已就绪(设备身份组件)' : '已就绪') : umidOffer(s) ? '需安装设备身份组件' : '未检测到客户端') + '</span>';
798
+ }
799
+ if (CLIENT_LABEL[p.id]) {
800
+ const cur = list.find((a) => a.product === p.id && isClientCurrent(a));
801
+ const zid = p.id === 'zcode' ? s.zcodeCurrentIdentity : null;
802
+ const live = cur ? esc(cur.name || cur.email || cur.uid)
803
+ : zid && (zid.email || zid.uid || zid.username) ? '未导入的账号(' + esc(zid.email || zid.username || zid.uid) + ')'
804
+ : (p.id === 'workbuddy' && s.workbuddyCurrentUid ? '未导入的账号' : '—');
805
+ rows += '<span class="lbl">客户端当前</span><span class="val">' + live + '</span>';
806
+ }
807
+ return '<div class="prodcard" data-view="' + p.id + '"><div class="prod-head"><span class="prod-name"><span class="dot ' + p.id + '"></span>' + p.label
808
+ + '<span class="badge">' + list.length + ' 个账号</span></span><span class="prod-go">查看 →</span></div><div class="prod-rows">' + rows + '</div></div>';
809
+ }).join('') + trDashCard();
810
+
811
+ // 需要处理:token 失效 / 即将过期、签到失败、国际版缺风控身份、10Router 额度告急 / 查询失败
812
+ const att = [];
813
+ if (!s.riskIdentity && state.accounts.some((a) => a.provider === 'qoder')) {
814
+ att.push({ view: 'qoder', name: 'Qoder 国际版', badges: '', pill: 'limited', text: umidOffer(s) ? '安装设备身份组件后才能领取' : '未检测到 Qoder 客户端,国际版无法领取' });
815
+ }
816
+ for (const a of state.accounts) {
817
+ const ex = expiryCell(a);
818
+ const st = statusOf(a);
819
+ const base = { view: a.product, name: a.name || a.email || a.id, badges: provBadges(a.provider) };
820
+ if (a.verified === false) att.push(Object.assign({ pill: 'fail', text: 'token 无效' }, base));
821
+ else if (ex.label === '已过期') att.push(Object.assign({ pill: 'fail', text: 'token 已过期' }, base));
822
+ else if (ex.warn) att.push(Object.assign({ pill: 'limited', text: ex.label.slice(5, 10) + ' 过期' }, base));
823
+ if (st.cls === 'fail') att.push(Object.assign({ pill: 'fail', text: '签到失败', title: st.msg }, base));
824
+ }
825
+ for (const c of (tr.quotas && tr.quotas.connections) || []) {
826
+ const base = { view: 'tenrouter', name: c.name || c.email || c.providerName, badges: '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>' };
827
+ if (c.error) att.push(Object.assign({ pill: 'fail', text: '额度查询失败', title: c.error }, base));
828
+ else if (c.isActive && quotaLow(c)) att.push(Object.assign({ pill: 'limited', text: '额度告急' }, base));
829
+ }
830
+ $('att-count').textContent = att.length ? att.length + ' 项' : '';
831
+ $('attention').innerHTML = att.length
832
+ ? att.slice(0, 8).map((x) => '<div class="item" data-view="' + x.view + '" title="' + esc(x.title || '') + '"><span class="item-main"><span class="item-name">' + esc(x.name) + '</span>' + x.badges + '</span>'
833
+ + '<span class="item-side"><span class="pill ' + x.pill + '">' + esc(x.text) + '</span></span></div>').join('')
834
+ + (att.length > 8 ? '<div class="muted-empty">还有 ' + (att.length - 8) + ' 项</div>' : '')
835
+ : '<div class="muted-empty">' + (state.accounts.length ? '✓ 一切正常' : '还没有账号') + '</div>';
836
+
837
+ // 最近签到结果
838
+ const RES = { 'checked-in': ['ok', '领取成功'], already: ['ok', '已领过'], limited: ['limited', '本机已领'], 'no-activity': ['pending', '无可领活动'], failed: ['fail', '失败'] };
839
+ const recent = state.accounts.filter((a) => a.lastResult && a.lastResult.at).sort((x, y) => String(y.lastResult.at).localeCompare(String(x.lastResult.at))).slice(0, 8);
840
+ $('last-tick').textContent = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at).slice(5) : '';
841
+ $('recent').innerHTML = recent.length
842
+ ? recent.map((a) => {
843
+ const r = a.lastResult;
844
+ const [cls, txt] = RES[r.status] || ['pending', r.status];
845
+ const label = r.status === 'checked-in' && r.amount ? txt + ' +' + fmtNum(r.amount) : txt;
846
+ 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>'
847
+ + '<span class="item-side"><span class="pill ' + cls + '">' + esc(label) + '</span><span class="item-time">' + fmtTime(r.at).slice(5) + '</span></span></div>';
848
+ }).join('')
849
+ : '<div class="muted-empty">还没有签到记录</div>';
850
+ }
851
+
852
+ function renderStatus() {
853
+ const s = state.status;
854
+ if (!s) return;
855
+ $('ver').textContent = 'v' + s.version + ' · 数据目录 ' + s.dataDir;
856
+ document.title = 'CreditDaddy v' + s.version;
857
+ if (s.homepage) $('btn-home').href = s.homepage;
858
+ const sch = s.scheduler || {};
859
+ $('sched').textContent = sch.ticking ? '正在签到…'
860
+ : sch.nextTickAt ? '下次自动签到 ' + fmtTime(sch.nextTickAt).slice(11) : '';
861
+ $('sched').title = sch.lastTick ? '上一轮 ' + fmtTime(sch.lastTick.at) + ':' + sch.lastTick.summary : '';
862
+ // 产品相关提示只在对应标签页出现
863
+ const hasIntl = state.view === 'qoder' && state.region !== 'cn' && state.accounts.some((a) => a.provider === 'qoder');
864
+ const n = $('notice');
865
+ n.classList.toggle('info', state.view === 'zcode' || (hasIntl && Boolean(s.riskIdentity)));
866
+ if (state.view === 'zcode') {
867
+ n.style.display = '';
868
+ const pf = state.zProxyFirst === true;
869
+ n.innerHTML = 'ZCode 没有每日签到,积分靠「活动领取」:点卡片上的礼物图标领取当期活动(可能需过一次验证码,通常静默通过)。这里也用于多账号管理、额度查看与一键切换(切换前需先退出 ZCode)。'
870
+ + ' <button class="btn" id="btn-znet" style="margin-left:6px" title="ZCode 上游接口的访问顺序:直连失败 / 被拦时会自动回退另一条。代理取 HTTPS_PROXY 环境变量">出口:' + (pf ? '代理优先' : '直连优先') + '</button>';
871
+ } else if (hasIntl && !s.riskIdentity && umidOffer(s)) {
872
+ n.style.display = '';
873
+ const busy = s.umid.installing || umidBusy;
874
+ n.innerHTML = 'Qoder 国际版只向带设备风控身份的请求下发「每天领 100 Credits」。这台 Linux / NAS 上没有 Qoder 客户端,'
875
+ + '可以安装「设备身份组件」:从 npm 官方包 @qoder-ai/qodercli 提取其内置的 UMID 程序(约 30MB 下载,校验 npm 完整性),装好后国际版即可签到(本机每天限领一个账号)。'
876
+ + ' <button class="btn primary" id="btn-umid" style="margin-left:6px"' + (busy ? ' disabled' : '') + '>' + (busy ? '安装中…' : '安装设备身份组件') + '</button>';
877
+ } else if (hasIntl && !s.riskIdentity) {
878
+ n.style.display = '';
879
+ n.textContent = 'Qoder 国际版签到需要本机安装 Qoder 客户端:Qoder 只向带设备风控身份(由客户端生成)的请求下发「每天领 100 Credits」。当前未检测到客户端,国际版账号将无法领取。';
880
+ } else if (hasIntl) {
881
+ n.style.display = '';
882
+ n.textContent = '提示:Qoder 国际版每台设备每天只能有一个账号领取每日 100 Credits(服务端按设备限领),其余国际版账号会显示「本机已领」。'
883
+ + (s.riskSource === 'cli' && s.umid && s.umid.installed ? '(风控身份来自设备身份组件 qodercli ' + s.umid.installed.version + ')' : '');
884
+ } else {
885
+ n.style.display = 'none';
886
+ }
887
+ }
888
+
889
+ // Qoder 设备身份组件(Linux / fnOS)
890
+ const umidOffer = (s) => Boolean(s && s.umid && s.umid.supported && !s.umid.installed);
891
+ let umidBusy = false;
892
+ async function installUmid() {
893
+ umidBusy = true; renderStatus();
894
+ try {
895
+ const r = await api('/api/qoder/umid/install', { method: 'POST' });
896
+ toast('设备身份组件已安装(qodercli ' + r.installed.version + '),Qoder 国际版可以签到了');
897
+ } catch (e) { toast(e.message, false); }
898
+ umidBusy = false;
899
+ await loadStatus(); render(); loadLogs();
900
+ }
901
+ $('notice').addEventListener('click', (e) => { if (e.target.closest('#btn-umid')) installUmid(); });
902
+ $('notice').addEventListener('click', async (e) => {
903
+ if (!e.target.closest('#btn-znet')) return;
904
+ try {
905
+ const r = await api('/api/zcode/net', { method: 'PUT', json: { proxyFirst: state.zProxyFirst !== true } });
906
+ state.zProxyFirst = r.proxyFirst;
907
+ renderStatus();
908
+ toast('ZCode 出口:' + (r.proxyFirst ? '代理优先(直连兜底)' : '直连优先(代理兜底)'));
909
+ } catch (err) { toast(err.message, false); }
910
+ });
911
+ async function loadZNet() {
912
+ try { state.zProxyFirst = (await api('/api/zcode/net')).proxyFirst === true; if (state.view === 'zcode') renderStatus(); } catch (e) {}
913
+ }
914
+
915
+ // ── 10Router ──
916
+ function quotaPct(q) {
917
+ if (Number.isFinite(q.remainingPercentage)) return Math.max(0, Math.min(100, q.remainingPercentage));
918
+ if (q.total > 0) return Math.max(0, Math.min(100, ((q.remaining ?? (q.total - (q.used || 0))) / q.total) * 100));
919
+ return null;
920
+ }
921
+ const LOW_PCT = 10;
922
+ const quotaRemaining = (q) => q.remaining ?? Math.max(0, (q.total || 0) - (q.used || 0));
923
+ // 没有任何信息的行(总量 0、没用过、也没有百分比),如 Qoder 的「Plan Credits 已用 0」
924
+ const quotaEmpty = (q) => !(q.total > 0) && !(q.used > 0) && !Number.isFinite(q.remainingPercentage);
925
+ // 积分包:一次性赠送、会到期的(recurring === false),与主额度 / 周期额度分开展示
926
+ const isPack = (q) => q.recurring === false;
927
+
928
+ /** 把一个连接的额度拆成:主额度(最上面的汇总数) / 周期额度 / 积分包 */
929
+ function splitQuotas(c) {
930
+ const list = (c.quotas || []).filter((q) => !quotaEmpty(q));
931
+ const primary = list.find((q) => q.total > 0 || Number.isFinite(q.remainingPercentage)) || null;
932
+ const rest = list.filter((q) => q !== primary);
933
+ return { primary, windows: rest.filter((q) => !isPack(q)), packs: rest.filter(isPack) };
934
+ }
935
+
936
+ /** 额度告急:只看主额度与按时间窗口计量的额度(如 Claude 5h / 7d),用完的赠送积分包不算 */
937
+ function quotaLow(c) {
938
+ const { primary, windows } = splitQuotas(c);
939
+ const watch = [primary, ...windows.filter((q) => q.percentScale)].filter(Boolean);
940
+ return watch.some((q) => { const v = quotaPct(q); return v !== null && v < LOW_PCT; });
941
+ }
942
+
943
+ function quotaValue(q) {
944
+ const pct = quotaPct(q);
945
+ if (q.percentScale || (!(q.total > 0) && pct !== null)) return { big: fmtNum(pct) + '%', small: '剩余', pct };
946
+ if (q.total > 0) return { big: fmtNum(quotaRemaining(q)), small: '/ ' + fmtNum(q.total) + (q.unit ? ' ' + q.unit : ''), pct };
947
+ return { big: fmtNum(q.used || 0), small: '已用', pct };
948
+ }
949
+ const resetLabel = (q) => (q.resetAt ? fmtTime(q.resetAt).slice(5) + (q.recurring === false ? ' 到期' : ' 重置') : '');
950
+
951
+ // 紧凑行(周期额度 / 聚合积分包):名称在左、数值在右,不带进度条
952
+ function trLine(name, valHtml, title) {
953
+ return '<div class="tr-line"><span class="tr-q-name"' + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(name) + '</span><span class="tr-q-val">' + valHtml + '</span></div>';
954
+ }
955
+ function lineVal(q) {
956
+ const pct = quotaPct(q);
957
+ const low = pct !== null && pct < LOW_PCT && q.percentScale;
958
+ const txt = (q.percentScale || !(q.total > 0))
959
+ ? '剩 ' + (pct !== null ? fmtNum(pct) + '%' : fmtNum(quotaRemaining(q)))
960
+ : '剩 ' + fmtNum(quotaRemaining(q)) + ' / ' + fmtNum(q.total || 0) + (q.unit ? ' ' + q.unit : '');
961
+ return '<span' + (low ? ' class="low-text"' : '') + '>' + esc(txt) + '</span>' + (q.resetAt ? '<span class="tr-q-when"> · ' + esc(resetLabel(q)) + '</span>' : '');
962
+ }
963
+
964
+ // 主额度行:与账号主卡同款 credit-line + bar
965
+ function trPrimaryHtml(q) {
966
+ const v = quotaValue(q);
967
+ const low = v.pct !== null && v.pct < LOW_PCT;
968
+ const pct01 = v.pct !== null ? Math.max(0, Math.min(1, v.pct / 100)) : null;
969
+ return '<div class="tr-primary"><div class="credit-line"><span class="credit"><span class="lbl">' + esc(q.name) + '</span><b' + (low ? ' style="color:var(--err)"' : '') + '>' + esc(v.big) + '</b> <small class="dim">' + esc(v.small) + '</small></span>'
970
+ + '<span class="credit dim">' + (q.resetAt ? esc(resetLabel(q)) : '') + '</span></div>'
971
+ + (pct01 !== null ? '<div class="bar"><span class="seg" style="flex:' + pct01.toFixed(4) + ' 1 0"></span><span class="seg used" style="flex:' + (1 - pct01).toFixed(4) + ' 1 0"></span></div>' : '')
972
+ + '</div>';
973
+ }
974
+
975
+ function trQuotaHtml(q, { dimWhenEmpty = false } = {}) {
976
+ const v = quotaValue(q);
977
+ const used = v.pct !== null && v.pct <= 0;
978
+ const low = !used && v.pct !== null && v.pct < LOW_PCT && q.percentScale;
979
+ const val = used && dimWhenEmpty ? '已用完' : (q.percentScale || !(q.total > 0) ? '剩余 ' + v.big : '剩余 ' + v.big + ' ' + v.small);
980
+ 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>'
981
+ + '<span class="tr-q-val' + (low ? ' low' : '') + '">' + esc(val) + (q.resetAt ? '<span class="tr-q-when"> · ' + esc(resetLabel(q)) + '</span>' : '') + '</span></div>'
982
+ + (v.pct !== null && !(used && dimWhenEmpty) ? '<div class="tr-meter"><span class="' + (low ? 'low' : '') + '" style="width:' + v.pct.toFixed(1) + '%"></span></div>' : '')
983
+ + '</div>';
984
+ }
985
+
986
+ // 积分包按名称系列聚合("Bonus Pack 18/19/20" → "Bonus Pack ×3"),去掉尾部序号作键
987
+ const packKey = (name) => String(name || '').replace(/[\s#(]*\d+\s*[)]?\s*$/, '').trim() || '积分包';
988
+ function trPacksHtml(c, packs) {
989
+ if (!packs.length) return '';
990
+ const alive = packs.filter((q) => quotaRemaining(q) > 0);
991
+ const spent = packs.filter((q) => quotaRemaining(q) <= 0);
992
+ const groups = new Map();
993
+ for (const q of alive) {
994
+ const k = packKey(q.name);
995
+ if (!groups.has(k)) groups.set(k, { name: k, count: 0, rem: 0, tot: 0, resetAt: null, singles: [] });
996
+ const g = groups.get(k);
997
+ g.count++;
998
+ g.rem += quotaRemaining(q);
999
+ g.tot += q.total || 0;
1000
+ if (q.resetAt && (!g.resetAt || String(q.resetAt) < String(g.resetAt))) g.resetAt = q.resetAt;
1001
+ g.singles.push(q);
1002
+ }
1003
+ const gs = [...groups.values()].sort((a, b) => String(a.resetAt || '9999').localeCompare(String(b.resetAt || '9999')));
1004
+ const aliveSum = alive.reduce((n, q) => n + quotaRemaining(q), 0);
1005
+ const open = Boolean(state.trOpen && state.trOpen[c.id]);
1006
+ const head = '<div class="tr-packs-head">积分包 <b>' + alive.length + '</b> 个可用'
1007
+ + (alive.length ? ',共剩 ' + fmtNum(aliveSum) : '')
1008
+ + (spent.length ? ' · ' + spent.length + ' 个已用完' : '') + '</div>';
1009
+ const lines = gs.map((g) => trLine(
1010
+ g.name + (g.count > 1 ? ' ×' + g.count : ''),
1011
+ esc('剩 ' + fmtNum(g.rem) + (g.tot > 0 ? ' / ' + fmtNum(g.tot) : ''))
1012
+ + (g.resetAt ? '<span class="tr-q-when"> · 最早 ' + esc(fmtTime(g.resetAt).slice(5, 10)) + ' 到期</span>' : ''),
1013
+ )).join('');
1014
+ const detail = open
1015
+ ? [...alive.sort((a, b) => String(a.resetAt || '9999').localeCompare(String(b.resetAt || '9999'))), ...spent]
1016
+ .map((q) => trQuotaHtml(q, { dimWhenEmpty: true })).join('')
1017
+ : '';
1018
+ const collapsible = alive.length > gs.length || spent.length > 0;
1019
+ return '<div class="tr-packs">' + head + (open ? detail : lines)
1020
+ + (collapsible ? '<button class="tr-more" data-tr="toggle" data-id="' + esc(c.id) + '">' + (open ? '收起' : '逐包明细') + '</button>' : '')
1021
+ + '</div>';
1022
+ }
1023
+
1024
+ function trCardHtml(c) {
1025
+ const who = c.name || c.email || c.id.slice(0, 8);
1026
+ // 套餐名与供应商名重复时不再单独显示(如 StepFun CN · StepFun CN)
1027
+ const norm = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9一-龥]/g, '');
1028
+ const showPlan = c.plan && !norm(c.plan).includes(norm(c.providerName)) && !norm(c.providerName).includes(norm(c.plan));
1029
+ let body;
1030
+ if (c.error) {
1031
+ body = '<div class="msg err" title="' + esc(c.error) + '">查询失败:' + esc(c.error.slice(0, 80)) + '</div>';
1032
+ } else {
1033
+ const { primary, windows, packs } = splitQuotas(c);
1034
+ if (!(primary || windows.length || packs.length)) {
1035
+ body = '<div class="msg">' + esc(/no separate quota|billed at standard/i.test(c.message || '') ? '按量计费,无额度可显示' : (c.message || '该供应商未返回额度数据')) + '</div>';
1036
+ } else {
1037
+ const resets = [primary, ...windows, ...packs].filter(Boolean).map((q) => q.resetAt).filter(Boolean).sort();
1038
+ const quotaCount = (primary ? 1 : 0) + windows.length + packs.length;
1039
+ body = '<div class="meta">'
1040
+ + '<div class="mi"><span class="lbl">额度</span><span class="val">' + quotaCount + ' 项</span></div>'
1041
+ + '<div class="mi right"><span class="lbl">最早到期</span><span class="val' + (resets[0] && new Date(resets[0]) - Date.now() < 7 * 86400e3 ? ' warn' : '') + '">' + (resets[0] ? fmtTime(resets[0]).slice(5, 10) : '—') + '</span></div>'
1042
+ + '</div>'
1043
+ + (primary ? trPrimaryHtml(primary) : '')
1044
+ + windows.map((q) => trLine(q.name, lineVal(q))).join('')
1045
+ + trPacksHtml(c, packs);
1046
+ }
1047
+ }
1048
+ return '<div class="tr-card' + (c.isActive ? '' : ' off') + '"><div class="row1"><div class="name-group">'
1049
+ + '<span class="badge prod tenrouter">' + esc(c.providerName) + '</span>'
1050
+ + '<span class="tr-name" title="' + esc([c.name, c.email].filter(Boolean).join(' · ')) + '">' + esc(who) + '</span>'
1051
+ + (showPlan ? '<span class="badge">' + esc(c.plan) + '</span>' : '')
1052
+ + (c.isActive ? '' : '<span class="pill pending">已停用</span>')
1053
+ + (c.limitReached ? '<span class="pill fail">已达上限</span>' : '')
1054
+ + (c.isActive && !c.error && quotaLow(c) ? '<span class="pill limited">告急</span>' : '')
1055
+ + '</div></div>' + body + '</div>';
1056
+ }
1057
+
1058
+ // 启用的在前,再按供应商、账号名排序
1059
+ const trSorted = (list) => list.slice().sort((a, b) => (b.isActive - a.isActive)
1060
+ || String(a.providerName).localeCompare(String(b.providerName)) || String(a.name || a.email || '').localeCompare(String(b.name || b.email || '')));
1061
+
1062
+ function trSetupHtml() {
1063
+ return '<div class="setup"><h3>接入 10Router</h3>'
1064
+ + '<div class="hint">填 10Router 的地址和一个<b>虚拟 key</b>(10Router 仪表盘 → API Keys 创建的 sk-…),就能在这里:</div>'
1065
+ + '<div class="hint" style="margin:6px 0 12px">· 查看 10Router 里其他供应商(Claude / Codex / Kiro / GLM …)的额度卡片(需要 10Router 1.2.1+)<br>'
1066
+ + '· 把本机 ZCode / OpenCode / mirasim / 小米 MiMo 的用量同步进 10Router 的用量统计(同 10router-sync 插件,自动每小时一次)</div>'
1067
+ + '<button class="btn primary" data-tr="settings">' + I.plus + '配置 10Router</button></div>';
1068
+ }
1069
+
1070
+ function renderTenrouter() {
1071
+ const box = $('tr-panel');
1072
+ const c = tr.config;
1073
+ if (!c) { box.innerHTML = '<div class="empty">加载中…</div>'; return; }
1074
+ if (!c.configured) { box.innerHTML = trSetupHtml(); return; }
1075
+ const list = (tr.quotas && tr.quotas.connections) || [];
1076
+ const low = list.filter((x) => x.isActive && !x.error && quotaLow(x)).length;
1077
+ const failed = list.filter((x) => x.error).length;
1078
+ const ls = c.lastSync;
1079
+ const srcChips = (c.sources || []).map((x) => {
1080
+ const on = x.found && c.sync.sources.includes(x.id);
1081
+ return '<span class="chip' + (on ? ' on' : '') + '" title="' + (x.found ? (x.supported ? '已检测到' : '需要 Node 22.5+') : '本机未检测到') + '">' + esc(x.label) + (x.found ? '' : ' ·无') + '</span>';
1082
+ }).join('');
1083
+ let grid;
1084
+ if (tr.error) grid = '<div class="empty">' + esc(tr.error) + '</div>';
1085
+ else if (!tr.quotas) grid = '<div class="empty">正在读取 10Router 供应商额度…</div>';
1086
+ else if (!list.length) grid = '<div class="empty">10Router 里还没有可查额度的供应商连接(OAuth 或支持额度查询的 API Key)。</div>';
1087
+ else grid = trSorted(list).map(trCardHtml).join('');
1088
+ const h = tr.health;
1089
+ const healthStrong = !h ? '…' : h.ok ? '正常' : '异常';
1090
+ const healthDetail = !h ? '' : h.ok
1091
+ ? (h.driver ? '存储驱动 ' + h.driver : '') + (h.lastDriverError ? '(驱动降级:' + h.lastDriverError + ')' : '')
1092
+ : (h.error || '');
1093
+ box.innerHTML = '<div class="toolbar"><div class="summary">'
1094
+ + '<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>'
1095
+ + '<div class="stat"><span>供应商连接</span><strong>' + (tr.quotas ? list.length : '-') + '</strong></div><div class="divider"></div>'
1096
+ + '<div class="stat"><span>额度告急</span><strong>' + (tr.quotas ? low : '-') + '</strong></div><div class="divider"></div>'
1097
+ + '<div class="stat"><span>查询失败</span><strong>' + (tr.quotas ? failed : '-') + '</strong></div>'
1098
+ + '</div><div class="actions">'
1099
+ + '<button class="btn" data-tr="refresh"' + (tr.loading ? ' disabled' : '') + '>' + I.refresh + (tr.loading ? '刷新中…' : '刷新额度') + '</button>'
1100
+ + '<button class="btn" data-tr="sync"' + (tr.syncing || c.syncing ? ' disabled' : '') + '>' + I.upload + (tr.syncing || c.syncing ? '同步中…' : '同步用量') + '</button>'
1101
+ + '<button class="btn" data-tr="settings">设置</button>'
1102
+ + '</div></div>'
1103
+ + '<div class="tr-bar"><span title="' + esc(c.endpoint) + '">' + esc(c.endpoint) + ' · ' + esc(c.keyMasked) + '</span>'
1104
+ + '<span>用量同步:' + (c.sync.enabled ? '每小时自动' : '手动') + '</span>' + srcChips
1105
+ + '<span style="margin-left:auto" title="' + esc(ls ? ls.summary : '') + '">' + (ls ? '上次同步 ' + esc(fmtTime(ls.at).slice(5)) + ' · ' + esc(ls.summary.slice(0, 40)) : '还没有同步过') + '</span>'
1106
+ + (tr.quotas ? '<span>额度更新于 ' + esc(fmtTime(tr.quotas.generatedAt).slice(11)) + '</span>' : '')
1107
+ + '</div>'
1108
+ + '<div class="tr-grid">' + grid + '</div>';
1109
+ }
1110
+
1111
+ function trDashCard() {
1112
+ const c = tr.config;
1113
+ if (!c || !c.configured) return '';
1114
+ const list = (tr.quotas && tr.quotas.connections) || [];
1115
+ const low = list.filter((x) => x.isActive && !x.error && quotaLow(x)).length;
1116
+ const ls = c.lastSync;
1117
+ const rows = '<span class="lbl">供应商连接</span><span class="val">' + (tr.quotas ? list.length : tr.error ? '读取失败' : '…') + '</span>'
1118
+ + '<span class="lbl">额度告急</span><span class="val' + (low ? ' warn' : '') + '">' + (tr.quotas ? low : '-') + '</span>'
1119
+ + '<span class="lbl">用量同步</span><span class="val">' + (ls ? esc(fmtTime(ls.at).slice(5)) : c.sync.enabled ? '等待首次同步' : '手动') + '</span>';
1120
+ return '<div class="prodcard" data-view="tenrouter"><div class="prod-head"><span class="prod-name"><span class="dot tenrouter"></span>10Router</span>'
1121
+ + '<span class="prod-go">查看 →</span></div><div class="prod-rows">' + rows + '</div></div>';
1122
+ }
1123
+
1124
+ async function loadTrConfig() {
1125
+ try { tr.config = await api('/api/tenrouter'); } catch (e) { tr.config = null; }
1126
+ render();
1127
+ }
1128
+ async function loadTrHealth() {
1129
+ if (!tr.config || !tr.config.configured) { tr.health = null; render(); return; }
1130
+ try { tr.health = await api('/api/tenrouter/health'); } catch (e) { tr.health = { ok: false, error: e.message }; }
1131
+ render();
1132
+ }
1133
+ async function loadTrQuotas(force) {
1134
+ if (!tr.config || !tr.config.configured) return;
1135
+ loadTrHealth();
1136
+ tr.loading = true; render();
1137
+ try { tr.quotas = await api('/api/tenrouter/quotas' + (force ? '?force=1' : '')); tr.error = null; }
1138
+ catch (e) { tr.error = e.message; if (e.code === 'TOO_OLD') tr.quotas = null; }
1139
+ tr.loading = false; render();
1140
+ }
1141
+ async function trSync() {
1142
+ tr.syncing = true; render();
1143
+ try { const r = await api('/api/tenrouter/sync', { method: 'POST' }); toast(r.summary, !r.results.some((x) => x.status === 'failed')); }
1144
+ catch (e) { toast(e.message, false); }
1145
+ tr.syncing = false;
1146
+ await loadTrConfig(); loadLogs();
1147
+ }
1148
+
1149
+ function openTrSettings() {
1150
+ const c = tr.config || { sync: { enabled: true, sources: [] }, sources: [] };
1151
+ $('tr-endpoint').value = c.endpoint || '';
1152
+ $('tr-key').value = '';
1153
+ $('tr-key').placeholder = c.keyMasked ? '已保存 ' + c.keyMasked + '(留空保持不变)' : 'sk-…';
1154
+ $('tr-auto').checked = c.configured ? c.sync.enabled : true;
1155
+ $('tr-sources').innerHTML = (c.sources || []).map((x) => '<label class="check"><input type="checkbox" data-src="' + x.id + '"'
1156
+ + ((c.configured ? c.sync.sources.includes(x.id) : true) ? ' checked' : '') + (x.supported ? '' : ' disabled') + '> ' + esc(x.label)
1157
+ + '<span class="dim">' + (x.found ? '' : '(本机未检测到)') + (x.supported ? '' : '(需要 Node 22.5+)') + '</span></label>').join('');
1158
+ $('tr-disconnect').style.display = c.configured ? '' : 'none';
1159
+ $('tr-test-msg').textContent = '';
1160
+ openModal('m-10r');
1161
+ setTimeout(() => $(c.configured ? 'tr-key' : 'tr-endpoint').focus(), 30);
1162
+ }
1163
+ function trForm() {
1164
+ return {
1165
+ endpoint: $('tr-endpoint').value.trim(),
1166
+ key: $('tr-key').value.trim(),
1167
+ syncEnabled: $('tr-auto').checked,
1168
+ sources: [...document.querySelectorAll('#tr-sources input[data-src]:checked')].map((x) => x.dataset.src),
1169
+ };
1170
+ }
1171
+ async function trTest() {
1172
+ const f = trForm();
1173
+ $('tr-test-msg').style.color = '';
1174
+ $('tr-test-msg').textContent = '测试中…';
1175
+ try {
1176
+ const r = await api('/api/tenrouter/test', { json: { endpoint: f.endpoint, key: f.key } });
1177
+ $('tr-test-msg').style.color = r.ok ? 'var(--ok)' : 'var(--err)';
1178
+ $('tr-test-msg').textContent = !r.ok ? '✗ ' + r.error
1179
+ : r.quotas ? '✓ 连接成功:' + r.connections + ' 个可查额度的供应商连接' : '✓ 已连接,但' + r.warning + '(用量同步可以正常使用)';
1180
+ } catch (e) { $('tr-test-msg').style.color = 'var(--err)'; $('tr-test-msg').textContent = '✗ ' + e.message; }
1181
+ }
1182
+ async function trSave() {
1183
+ const f = trForm();
1184
+ if (!f.endpoint) return toast('请填写 10Router 地址', false);
1185
+ if (!f.key && !(tr.config && tr.config.keyMasked)) return toast('请填写虚拟 key', false);
1186
+ try {
1187
+ tr.config = await api('/api/tenrouter', { method: 'PUT', json: f });
1188
+ closeModal('m-10r');
1189
+ toast('已保存 10Router 设置');
1190
+ tr.quotas = null; tr.error = null;
1191
+ loadTrQuotas(true);
1192
+ } catch (e) { toast(e.message, false); }
1193
+ }
1194
+ async function trDisconnect() {
1195
+ if (!(await uiConfirm('断开后会删除保存的 10Router 地址、key 与同步进度(10Router 里已同步的数据不受影响)。', '断开 10Router', '断开'))) return;
1196
+ try { tr.config = await api('/api/tenrouter', { method: 'DELETE' }); tr.quotas = null; tr.error = null; closeModal('m-10r'); render(); }
1197
+ catch (e) { toast(e.message, false); }
1198
+ }
1199
+ $('tr-panel').addEventListener('click', (e) => {
1200
+ const b = e.target.closest('[data-tr]');
1201
+ if (!b) return;
1202
+ if (b.dataset.tr === 'toggle') { state.trOpen = Object.assign({}, state.trOpen, { [b.dataset.id]: !(state.trOpen && state.trOpen[b.dataset.id]) }); render(); return; }
1203
+ if (b.dataset.tr === 'settings') openTrSettings();
1204
+ else if (b.dataset.tr === 'refresh') loadTrQuotas(true);
1205
+ else if (b.dataset.tr === 'sync') trSync();
1206
+ });
1207
+ $('tr-test').onclick = trTest;
1208
+ $('tr-save').onclick = trSave;
1209
+ $('tr-disconnect').onclick = trDisconnect;
1210
+
1211
+ // ── 数据加载 ──
1212
+ async function loadStatus() {
1213
+ try { state.status = await api('/api/status'); renderStatus(); } catch (e) {}
1214
+ }
1215
+ async function loadAccounts() {
1216
+ const data = await api('/api/accounts');
1217
+ state.accounts = data.accounts;
1218
+ render();
1219
+ renderStatus();
1220
+ loadQuotas();
1221
+ }
1222
+ let quotaRun = 0;
1223
+ async function loadQuotas(ids) {
1224
+ const run = ++quotaRun;
1225
+ const queue = (ids || state.accounts.map((a) => a.id)).slice();
1226
+ if (ids) for (const id of ids) delete state.quotas[id];
1227
+ const worker = async () => {
1228
+ while (queue.length && run === quotaRun) {
1229
+ const id = queue.shift();
1230
+ try { state.quotas[id] = (await api('/api/accounts/' + encodeURIComponent(id) + '/quota')).quota; }
1231
+ catch (e) { state.quotas[id] = { error: e.message }; }
1232
+ render();
1233
+ }
1234
+ };
1235
+ await Promise.all([worker(), worker(), worker()]);
1236
+ }
1237
+ async function loadLogs() {
1238
+ try {
1239
+ const data = await api('/api/logs');
1240
+ const el = $('logs');
1241
+ const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
1242
+ el.innerHTML = data.logs.length ? data.logs.map((l) => '<div class="' + esc(l.level) + '">[' + esc(l.at) + '] ' + esc(l.tag) + ' · ' + esc(l.msg) + '</div>').join('') : '(暂无)';
1243
+ if (atBottom) el.scrollTop = el.scrollHeight;
1244
+ } catch (e) {}
1245
+ }
1246
+
1247
+ // ── 账号操作 ──
1248
+ async function checkinOne(id) {
1249
+ state.busy[id] = true; render();
1250
+ try {
1251
+ const d = await api('/api/accounts/' + encodeURIComponent(id) + '/checkin', { method: 'POST' });
1252
+ const r = d.results && d.results[0];
1253
+ toast(r ? (r.account + ':' + (r.status === 'checked-in' ? '领取成功 +' + r.claimedAmount + ' Credits' : (r.error || r.message || r.status))) : d.summary, !(r && r.status === 'failed'));
1254
+ } catch (e) { toast(e.message, false); }
1255
+ delete state.busy[id];
1256
+ await loadStatus(); await loadAccounts(); loadLogs();
1257
+ }
1258
+ async function checkinAll(btn, product) {
1259
+ const label = btn.innerHTML;
1260
+ btn.disabled = true; btn.innerHTML = I.refresh + '签到中…';
1261
+ try {
1262
+ const d = await api('/api/checkin', { json: { skipIfCheckedToday: false, product } });
1263
+ toast(d.summary);
1264
+ } catch (e) { toast(e.message, false); }
1265
+ btn.disabled = false; btn.innerHTML = label;
1266
+ await loadStatus(); await loadAccounts(); loadLogs();
1267
+ }
1268
+ async function onCardClick(e) {
1269
+ const btn = e.target.closest('[data-act]');
1270
+ if (!btn) return;
1271
+ const id = btn.closest('.card').dataset.id;
1272
+ const act = btn.dataset.act;
1273
+ if (act === 'checkin') return checkinOne(id);
1274
+ if (act === 'quota') { loadQuotas([id]); return; }
1275
+ if (act === 'claim') return openClaim(id);
1276
+ if (act === 'switch') {
1277
+ const a = state.accounts.find((x) => x.id === id);
1278
+ const client = CLIENT_LABEL[a && a.product] || '客户端';
1279
+ const tip = a && a.product === 'zcode'
1280
+ ? '\n需先退出 ZCode;切换后重新打开 ZCode 即为新账号。当前登录会先保存到 CreditDaddy,不会丢号。'
1281
+ : '\n客户端会自动应用新会话;当前登录账号的最新 token 会先保存到 CreditDaddy。';
1282
+ if (!(await uiConfirm('把 ' + client + ' 切换到「' + (a && a.name || id) + '」?' + tip, '切换账号', '切换'))) return;
1283
+ const doSwitch = (force) => api('/api/accounts/' + encodeURIComponent(id) + '/switch', { json: { force } });
1284
+ try {
1285
+ let r;
1286
+ try { r = await doSwitch(false); }
1287
+ catch (err) {
1288
+ if (err.code !== 'ZCODE_RUNNING') throw err;
1289
+ if (!(await uiConfirm(err.message + '\n\n仍要强制切换吗?强制切换会先关闭 ZCode 客户端再写入登录(未保存的对话内容可能丢失),重新打开 ZCode 即为新账号。', 'ZCode 正在运行', '关闭并强制切换'))) return;
1290
+ r = await doSwitch(true);
1291
+ }
1292
+ toast(r && r.alreadyActive ? client + ' 当前已是该账号' : (r && r.closedClient ? '已关闭 ZCode,' : '') + client + ' 已切换到 ' + (a && a.name || id));
1293
+ await loadStatus(); await loadAccounts();
1294
+ } catch (err) { toast(err.message, false); }
1295
+ return;
1296
+ }
1297
+ if (act === 'rename') {
1298
+ const a = state.accounts.find((x) => x.id === id);
1299
+ const name = await uiPrompt('修改备注名', a && a.name || '', { okText: '保存' });
1300
+ if (name === null) return;
1301
+ try { await api('/api/accounts/' + encodeURIComponent(id), { method: 'PATCH', json: { name } }); await loadAccounts(); }
1302
+ catch (err) { toast(err.message, false); }
1303
+ return;
1304
+ }
1305
+ if (act === 'del') {
1306
+ if (!armed[id]) {
1307
+ armed[id] = setTimeout(() => { delete armed[id]; btn.classList.remove('armed'); }, 3000);
1308
+ btn.classList.add('armed');
1309
+ toast('再点一次确认删除');
1310
+ return;
1311
+ }
1312
+ clearTimeout(armed[id]); delete armed[id];
1313
+ try { await api('/api/accounts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('已删除'); delete state.quotas[id]; await loadAccounts(); }
1314
+ catch (err) { toast(err.message, false); }
1315
+ }
1316
+ }
1317
+
1318
+ // ── ZCode 活动领取 ──
1319
+ const CAPTCHA_SDK_URL = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js';
1320
+ const claimState = { accountId: null, plans: [], captcha: null, busy: false };
1321
+ let captchaSdkPromise = null;
1322
+
1323
+ function loadCaptchaSdk() {
1324
+ if (typeof window.initAliyunCaptcha === 'function') return Promise.resolve();
1325
+ if (!captchaSdkPromise) {
1326
+ captchaSdkPromise = new Promise((resolve, reject) => {
1327
+ const s = document.createElement('script');
1328
+ s.src = CAPTCHA_SDK_URL;
1329
+ s.onload = () => resolve();
1330
+ s.onerror = () => reject(new Error('验证码组件加载失败(网络问题?)'));
1331
+ document.head.appendChild(s);
1332
+ });
1333
+ }
1334
+ return captchaSdkPromise;
1335
+ }
1336
+
1337
+ async function openClaim(id) {
1338
+ const a = state.accounts.find((x) => x.id === id);
1339
+ claimState.accountId = id;
1340
+ claimState.plans = [];
1341
+ claimState.captcha = null;
1342
+ claimState.busy = false;
1343
+ $('claim-body').innerHTML = '<div class="dim">读取可领取的活动…</div>';
1344
+ openModal('m-claim');
1345
+ try {
1346
+ const [plansResp, cap] = await Promise.all([
1347
+ api('/api/accounts/' + encodeURIComponent(id) + '/zcode/plans'),
1348
+ api('/api/zcode/captcha-config').catch(() => ({ enabled: false })),
1349
+ ]);
1350
+ if (claimState.accountId !== id) return; // 弹窗已被别的账号占用
1351
+ claimState.plans = plansResp.plans || [];
1352
+ claimState.captcha = cap;
1353
+ renderClaimBody();
1354
+ } catch (e) {
1355
+ $('claim-body').innerHTML = '<div class="msg err">' + esc(e.message) + '</div>';
1356
+ }
1357
+ }
1358
+
1359
+ function renderClaimBody() {
1360
+ if (!claimState.plans.length) {
1361
+ $('claim-body').innerHTML = '<div class="muted-empty">当前没有可领取的活动</div>';
1362
+ return;
1363
+ }
1364
+ $('claim-body').innerHTML = claimState.plans.map((pl, i) =>
1365
+ '<div class="claim-plan"><div><b>' + esc(pl.name || pl.planId) + '</b>'
1366
+ + (pl.description ? '<div class="dim grants">' + esc(pl.description) + '</div>' : '')
1367
+ + (pl.grants && pl.grants.length ? '<div class="dim grants">' + pl.grants.map(esc).join(';') + '</div>' : '')
1368
+ + '</div><button class="btn primary" data-claim="' + i + '"' + (claimState.busy ? ' disabled' : '') + '>领取</button></div>'
1369
+ ).join('')
1370
+ + '<div id="claim-status" class="dim" style="margin-top:8px"></div>'
1371
+ + '<div id="cap-holder"></div><button class="btn" id="cap-btn" hidden>打开验证码</button>';
1372
+ }
1373
+
1374
+ // 阿里云验证码:先静默验证(8s 超时),不行就让用户点按钮弹验证码。移植自 zcode-switch captcha.js。
1375
+ function runCaptcha(cfg) {
1376
+ return new Promise((resolve, reject) => {
1377
+ window.AliyunCaptchaConfig = { region: cfg.region, prefix: cfg.prefix };
1378
+ const st = $('claim-status'), btn = $('cap-btn');
1379
+ let done = false;
1380
+ let timer = 0;
1381
+ const finish = (param) => {
1382
+ if (done || !param) return;
1383
+ done = true;
1384
+ clearTimeout(timer);
1385
+ st.textContent = '人机验证已通过';
1386
+ resolve(param);
1387
+ };
1388
+ const interactive = (why) => {
1389
+ if (done) return;
1390
+ clearTimeout(timer);
1391
+ st.textContent = '需要完成人机验证:点下方按钮,按提示完成验证码';
1392
+ if (btn) { btn.hidden = false; btn.focus(); }
1393
+ if (why && typeof why !== 'string') st.title = JSON.stringify(why).slice(0, 160);
1394
+ };
1395
+ try {
1396
+ window.initAliyunCaptcha({
1397
+ SceneId: cfg.sceneId,
1398
+ mode: 'popup',
1399
+ language: 'zh-CN',
1400
+ showErrorTip: false,
1401
+ element: '#cap-holder',
1402
+ button: '#cap-btn',
1403
+ getInstance: (instance) => {
1404
+ if (instance && typeof instance.startTracelessVerification === 'function') {
1405
+ st.textContent = '人机验证中…';
1406
+ instance.startTracelessVerification();
1407
+ timer = setTimeout(() => interactive(), 8000);
1408
+ } else interactive();
1409
+ },
1410
+ success: (param) => finish(typeof param === 'string' ? param : param && param.captchaVerifyParam),
1411
+ fail: (p) => interactive(p),
1412
+ onError: (p) => interactive(p),
1413
+ });
1414
+ } catch (e) { reject(e); }
1415
+ });
1416
+ }
1417
+
1418
+ async function doClaim(i) {
1419
+ const pl = claimState.plans[i];
1420
+ if (!pl || claimState.busy) return;
1421
+ const st = $('claim-status');
1422
+ claimState.busy = true;
1423
+ renderClaimBody();
1424
+ $('claim-status').textContent = '准备领取…';
1425
+ let captchaParam = '';
1426
+ let region = '';
1427
+ try {
1428
+ const cfg = claimState.captcha;
1429
+ if (cfg && cfg.enabled && cfg.sceneId) {
1430
+ await loadCaptchaSdk();
1431
+ region = cfg.region || '';
1432
+ captchaParam = await runCaptcha(cfg);
1433
+ } else {
1434
+ $('claim-status').textContent = '验证码配置不可用,按无验证码直接尝试…';
1435
+ }
1436
+ } catch (e) {
1437
+ $('claim-status').innerHTML = '<span class="msg err">' + esc('验证码失败:' + e.message) + '</span>';
1438
+ claimState.busy = false;
1439
+ renderClaimBody();
1440
+ return;
1441
+ }
1442
+ $('claim-status').textContent = '领取中…';
1443
+ try {
1444
+ const r = await api('/api/accounts/' + encodeURIComponent(claimState.accountId) + '/zcode/claim', {
1445
+ json: { planId: pl.planId, captchaParam, region },
1446
+ });
1447
+ toast('领取成功:' + (r.planName || pl.name || pl.planId));
1448
+ closeModal('m-claim');
1449
+ loadQuotas([claimState.accountId]);
1450
+ loadLogs();
1451
+ } catch (e) {
1452
+ const errBox = $('claim-status');
1453
+ if (errBox) {
1454
+ errBox.innerHTML = '<span class="msg err">' + esc(e.message) + (e.nextAt ? '(' + fmtTime(e.nextAt).slice(5, 16) + ' 后可再试)' : '') + '</span>';
1455
+ }
1456
+ }
1457
+ claimState.busy = false;
1458
+ if ($('m-claim').classList.contains('show') && $('claim-status')) renderClaimBody();
1459
+ }
1460
+
1461
+ $('claim-body').addEventListener('click', (e) => {
1462
+ const b = e.target.closest('[data-claim]');
1463
+ if (b) doClaim(Number(b.dataset.claim));
1464
+ });
1465
+
1466
+ // ── 弹窗 ──
1467
+ function openModal(id) { $(id).classList.add('show'); }
1468
+ function closeModal(id) { $(id).classList.remove('show'); if (id === 'm-add') deviceStop(); if (id === 'm-dialog') settleDialog(null); }
1469
+
1470
+ // 页面内对话框(替代原生 confirm / prompt:面板嵌在飞牛桌面窗口里是跨域 iframe,原生弹框可能被浏览器拦截)
1471
+ // 返回 Promise:确认 → true / 输入的文本;取消 → false / null
1472
+ let dialogResolve = null;
1473
+ function settleDialog(v) { const r = dialogResolve; dialogResolve = null; if (r) r(v); }
1474
+ function uiDialog({ title, message, input, okText }) {
1475
+ settleDialog(null);
1476
+ $('dlg-title').textContent = title || '';
1477
+ $('dlg-msg').textContent = message || '';
1478
+ $('dlg-msg').style.display = message ? '' : 'none';
1479
+ $('dlg-field').style.display = input ? '' : 'none';
1480
+ if (input) { $('dlg-input').type = input.type || 'text'; $('dlg-input').value = input.value || ''; $('dlg-input').placeholder = input.placeholder || ''; }
1481
+ $('dlg-ok').textContent = okText || '确定';
1482
+ openModal('m-dialog');
1483
+ setTimeout(() => (input ? $('dlg-input') : $('dlg-ok')).focus(), 30);
1484
+ return new Promise((resolve) => { dialogResolve = (v) => resolve(input ? v : v !== null); });
1485
+ }
1486
+ const uiConfirm = (message, title, okText) => uiDialog({ title: title || '请确认', message, okText });
1487
+ const uiPrompt = (title, value, opts = {}) => uiDialog({ title, message: opts.message, input: { value, type: opts.type, placeholder: opts.placeholder }, okText: opts.okText });
1488
+ function okDialog() { const v = $('dlg-field').style.display === 'none' ? true : $('dlg-input').value; $('m-dialog').classList.remove('show'); settleDialog(v); }
1489
+ $('dlg-ok').addEventListener('click', okDialog);
1490
+ $('dlg-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') okDialog(); });
1491
+ document.querySelectorAll('.mask').forEach((m) => {
1492
+ m.addEventListener('mousedown', (e) => { if (e.target === m) closeModal(m.id); });
1493
+ m.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => closeModal(m.id)));
1494
+ });
1495
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') document.querySelectorAll('.mask.show').forEach((m) => closeModal(m.id)); });
1496
+ document.querySelectorAll('#m-add .tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
1497
+ function switchTab(name) {
1498
+ document.querySelectorAll('#m-add .tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
1499
+ document.querySelectorAll('#m-add .pane').forEach((p) => p.classList.toggle('active', p.dataset.pane === name));
1500
+ }
1501
+
1502
+ // 添加账号:在产品页打开时只显示该产品可用的方式
1503
+ const LOCAL_HINT = {
1504
+ '': '读取本机 Qoder 客户端(本地解密)、WorkBuddy 客户端(当前 + 历史会话)、ZCode 客户端(本地解密)已登录的账号,token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
1505
+ qoder: '读取本机 Qoder / Qoder CN 客户端已登录的账号(本地解密 auth.v1.dat),token 不出本机;同时扫描旧版 Qoder IDE / CLI 文件。',
1506
+ workbuddy: '读取本机 WorkBuddy 客户端的当前会话与历史会话(CodeBuddyExtension 凭据目录),token 不出本机。',
1507
+ zcode: '读取本机 ZCode 客户端当前登录的账号(本地解密 ~/.zcode/v2/credentials.json),token 不出本机。',
1508
+ };
1509
+ function openAdd(product) {
1510
+ state.addProduct = product || '';
1511
+ const tabShown = { local: true, device: true, token: product !== 'zcode' };
1512
+ document.querySelectorAll('#m-add .tab').forEach((t) => { t.style.display = tabShown[t.dataset.tab] ? '' : 'none'; });
1513
+ $('local-hint').textContent = LOCAL_HINT[state.addProduct];
1514
+ let first = null;
1515
+ for (const o of $('f-provider').options) {
1516
+ const ok = !product || provOf(o.value).prod === product;
1517
+ o.hidden = !ok;
1518
+ if (ok && !first) first = o.value;
1519
+ }
1520
+ if (first) { $('f-provider').value = first; $('f-provider').dispatchEvent(new Event('change')); }
1521
+ // 浏览器登录的选项值以产品名开头(qoder / workbuddy / zcode-…)
1522
+ let firstDev = null;
1523
+ for (const o of $('dev-provider').options) {
1524
+ const ok = !product || o.value.split('-')[0] === product;
1525
+ o.hidden = !ok;
1526
+ if (ok && !firstDev) firstDev = o.value;
1527
+ }
1528
+ if (firstDev) $('dev-provider').value = firstDev;
1529
+ $('dev-hint').textContent = (hasIncognito() ? '在内置隐私窗口里登录(不带出系统浏览器已登录的账号),' : '在浏览器里登录并授权,')
1530
+ + '完成后账号自动添加。' + (product === 'qoder' ? '' : 'WorkBuddy / ZCode 用浏览器登录的账号同样可以一键切换客户端。');
1531
+ openModal('m-add');
1532
+ switchTab('local');
1533
+ localScan();
1534
+ }
1535
+
1536
+ // 本机导入
1537
+ async function localScan() {
1538
+ const box = $('scan-result');
1539
+ $('scan-status').textContent = '扫描中(最多约 10 秒)…';
1540
+ box.innerHTML = '<div class="hint">正在读取本机 Qoder 客户端凭据…</div>';
1541
+ try {
1542
+ const r = await api('/api/local/scan', { method: 'POST' });
1543
+ $('scan-status').textContent = '';
1544
+ let html = '';
1545
+ const cands = r.candidates.filter((c) => !state.addProduct || (c.provider ? provOf(c.provider).prod : 'qoder') === state.addProduct);
1546
+ for (const c of cands) {
1547
+ const title = c.kind === 'app' ? esc(c.name || c.email || c.phone || '(未命名)') : '<span style="font-family:monospace">' + esc(c.tokenMasked) + '</span>';
1548
+ const prov = (c.provider ? provBadges(c.provider) : '<span class="badge">' + esc(c.kind) + '</span>') + (c.current ? '<span class="pill cur">当前登录</span>' : '');
1549
+ const sub = c.kind === 'app'
1550
+ ? esc(c.source) + (c.email ? ' · ' + esc(mask(c.email)) : c.phone ? ' · ' + esc(c.phone) : '') + (c.expiresAt ? ' · 有效期至 ' + esc(fmtTime(c.expiresAt)) : '')
1551
+ : esc(c.source) + ' · ' + esc(String(c.file || '').split(/[\\/]/).pop()) + '(' + c.hits + ' 处)';
1552
+ const btns = c.imported ? '<span class="pill ok">已导入</span>' + (c.provider ? '<button class="btn" data-cid="' + esc(c.id) + '" title="用本机凭据更新 token / 有效期 / 邮箱">同步信息</button>' : '')
1553
+ : c.provider ? '<button class="btn primary" data-cid="' + esc(c.id) + '">导入</button>'
1554
+ : '<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>';
1555
+ 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>';
1556
+ }
1557
+ for (const e of r.errors || []) html += '<div class="hint" style="color:var(--err)">读取失败:' + esc(e.file) + ':' + esc(e.error) + '</div>';
1558
+ if (!cands.length) {
1559
+ const where = state.addProduct ? prodLabel(state.addProduct) : 'Qoder / WorkBuddy / ZCode';
1560
+ const alt = state.addProduct === 'zcode' ? ',或改用「浏览器登录」' : ',或改用「浏览器登录」「粘贴 Token」';
1561
+ html += '<div class="hint">未发现已登录的 ' + where + ' 账号。请先在客户端登录' + alt + '。<br>已检查:Qoder / Qoder CN 客户端数据目录、WorkBuddy 凭据目录、ZCode 数据目录'
1562
+ + (r.scannedDirs && r.scannedDirs.length ? '、' + r.scannedDirs.map(esc).join('、') : '') + '</div>';
1563
+ }
1564
+ box.innerHTML = html;
1565
+ box.querySelectorAll('button[data-cid]').forEach((b) => b.addEventListener('click', async () => {
1566
+ b.disabled = true;
1567
+ try {
1568
+ const d = await api('/api/local/import', { json: { candidateId: b.dataset.cid, provider: b.dataset.prov } });
1569
+ toast((d.updated ? '已更新:' : '已导入:') + (d.account.name || d.account.id));
1570
+ await loadAccounts();
1571
+ localScan();
1572
+ } catch (e) { toast(e.message, false); b.disabled = false; }
1573
+ }));
1574
+ } catch (e) {
1575
+ $('scan-status').textContent = '';
1576
+ box.innerHTML = '<div class="hint" style="color:var(--err)">扫描失败:' + esc(e.message) + '</div>';
1577
+ }
1578
+ }
1579
+
1580
+ // 网络授权登录
1581
+ let devTimer = null;
1582
+ let devGen = 0; // 每次发起/取消授权递增,丢弃过期会话的在途轮询结果
1583
+ // 打开授权/登录页:桌面版用内置隐私(无痕)窗口,避免带出系统浏览器里已登录的账号;否则退回系统浏览器
1584
+ async function openAuth(url) {
1585
+ if (window.creditdaddy && window.creditdaddy.openAuthWindow) {
1586
+ const r = await window.creditdaddy.openAuthWindow(url).catch(() => ({ ok: false }));
1587
+ if (r && r.ok) return true;
1588
+ }
1589
+ window.open(url, '_blank');
1590
+ return false;
1591
+ }
1592
+ const hasIncognito = () => Boolean(window.creditdaddy && window.creditdaddy.openAuthWindow);
1593
+
1594
+ async function deviceLogin() {
1595
+ const provider = $('dev-provider').value;
1596
+ try {
1597
+ const flow = await api('/api/auth/device/start', { json: { provider } });
1598
+ $('device-box').style.display = '';
1599
+ $('dev-cancel').style.display = '';
1600
+ const where = hasIncognito() ? '已在隐私窗口打开登录页' : '请在浏览器打开下方链接';
1601
+ $('dev-status').textContent = where + ',登录并授权(' + Math.floor(flow.expiresIn / 60) + ' 分钟内有效)';
1602
+ $('dev-link').href = flow.url;
1603
+ $('dev-link').textContent = flow.url;
1604
+ openAuth(flow.url);
1605
+ pollDevice(flow.sessionId, 0, ++devGen);
1606
+ } catch (e) { toast('发起授权失败:' + e.message, false); }
1607
+ }
1608
+ async function pollDevice(sessionId, n, gen) {
1609
+ if (gen !== devGen) return;
1610
+ try {
1611
+ const r = await api('/api/auth/device/poll', { json: { sessionId } });
1612
+ if (gen !== devGen) return;
1613
+ if (r.status === 'pending') {
1614
+ if (n > 450) return deviceStop('授权超时,请重试');
1615
+ $('dev-status').textContent = '等待授权中…(' + n + ')';
1616
+ devTimer = setTimeout(() => pollDevice(sessionId, n + 1, gen), 2000);
1617
+ } else if (r.status === 'ok') {
1618
+ deviceStop('✓ 登录成功:' + (r.account.name || r.account.id) + (r.already ? '(账号已存在,已更新登录凭据)' : ''));
1619
+ toast(r.already ? '已更新「' + (r.account.name || r.account.id) + '」的登录凭据' : '已添加:' + (r.account.name || r.account.id));
1620
+ if (r.account.id) delete state.quotas[r.account.id]; // 旧 token 的积分查询结果(可能是失败)作废,马上重查
1621
+ await loadStatus(); await loadAccounts();
1622
+ } else if (r.status === 'expired') {
1623
+ deviceStop('会话已过期,请重新发起');
1624
+ } else {
1625
+ deviceStop('授权失败:' + (r.error || r.status));
1626
+ }
1627
+ } catch (e) { if (gen === devGen) deviceStop('轮询异常:' + e.message); }
1628
+ }
1629
+ function deviceStop(msg) {
1630
+ devGen++;
1631
+ if (devTimer) { clearTimeout(devTimer); devTimer = null; }
1632
+ $('dev-cancel').style.display = 'none';
1633
+ if (msg) $('dev-status').textContent = msg;
1634
+ }
1635
+
1636
+ // 粘贴 token
1637
+ async function addToken() {
1638
+ const token = $('f-token').value.trim();
1639
+ if (!token) return toast('请粘贴 token', false);
1640
+ const b = $('btn-add-token'); b.disabled = true;
1641
+ try {
1642
+ const d = await api('/api/accounts', { json: { provider: $('f-provider').value, name: $('f-name').value.trim(), token } });
1643
+ toast('已添加:' + (d.account.name || d.account.id) + (d.account.verified === false ? '(token 校验失败,请检查)' : ''), d.account.verified !== false);
1644
+ $('f-token').value = ''; $('f-name').value = '';
1645
+ closeModal('m-add');
1646
+ await loadAccounts();
1647
+ } catch (e) { toast(e.message, false); }
1648
+ b.disabled = false;
1649
+ }
1650
+
1651
+ // 导出
1652
+ async function doExport() {
1653
+ const password = $('ex-password').value;
1654
+ if (password && password.length < 4) return toast('口令至少 4 位', false);
1655
+ try {
1656
+ const sel = $('ex-provider').value;
1657
+ const scope = sel.startsWith('p:') ? { product: sel.slice(2) } : { provider: sel || undefined };
1658
+ const payload = await api('/api/export', { json: Object.assign({ password: password || undefined }, scope) });
1659
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
1660
+ const a = document.createElement('a');
1661
+ a.href = URL.createObjectURL(blob);
1662
+ a.download = 'creditdaddy-' + (sel.replace('p:', '') || 'all') + '-' + new Date().toISOString().slice(0, 10) + (password ? '.secure' : '') + '.json';
1663
+ a.click();
1664
+ URL.revokeObjectURL(a.href);
1665
+ $('ex-password').value = '';
1666
+ closeModal('m-export');
1667
+ toast(password ? '已导出加密文件(可导入 10router)' : '已导出明文文件,内含 token,请妥善保管');
1668
+ } catch (e) { toast(e.message, false); }
1669
+ }
1670
+
1671
+ // 导入
1672
+ let pendingImport = null;
1673
+ function pickImport() { $('file-input').value = ''; $('file-input').click(); }
1674
+ $('file-input').addEventListener('change', async () => {
1675
+ const f = $('file-input').files[0];
1676
+ if (!f) return;
1677
+ let data;
1678
+ try { data = JSON.parse(await f.text()); } catch (e) { return toast('文件不是合法 JSON', false); }
1679
+ pendingImport = data;
1680
+ if (data && data.format === '10router-oauth-secure-v1') {
1681
+ $('pass-hint').textContent = '「' + f.name + '」已加密(CreditDaddy / 10router 迁移格式),请输入导出时设置的口令。';
1682
+ $('pass-input').value = '';
1683
+ openModal('m-pass');
1684
+ setTimeout(() => $('pass-input').focus(), 50);
1685
+ } else {
1686
+ submitImport();
1687
+ }
1688
+ });
1689
+ async function submitImport(password) {
1690
+ try {
1691
+ const d = await api('/api/import', { json: { data: pendingImport, password } });
1692
+ closeModal('m-pass');
1693
+ toast('导入完成(' + (d.source === '10router' ? '10router' : 'CreditDaddy') + '):新增 ' + d.added + ',续期 ' + d.updated + ',跳过 ' + d.skipped);
1694
+ pendingImport = null;
1695
+ await loadAccounts();
1696
+ } catch (e) {
1697
+ if (e.code === 'WRONG_PASSWORD' || e.code === 'NEED_PASSWORD') { $('pass-hint').textContent = e.message; $('pass-input').select(); openModal('m-pass'); }
1698
+ else toast(e.message, false);
1699
+ }
1700
+ }
1701
+
1702
+ // ── 绑定 ──
1703
+ $('btn-checkin-all').innerHTML = I.play + '全部签到';
1704
+ for (const id of ['btn-add', 'btn-add-dash']) $(id).innerHTML = I.plus + '添加账号';
1705
+ for (const id of ['btn-import', 'btn-import-dash']) { $(id).innerHTML = I.upload + '导入'; $(id).onclick = pickImport; }
1706
+ for (const id of ['btn-export', 'btn-export-dash']) $(id).innerHTML = I.download + '导出';
1707
+ $('btn-checkin-all').onclick = () => checkinAll($('btn-checkin-all'));
1708
+ $('btn-checkin').onclick = () => checkinAll($('btn-checkin'), state.view);
1709
+ $('btn-add-dash').onclick = () => openAdd('');
1710
+ $('btn-add').onclick = () => openAdd(state.view);
1711
+ $('btn-export-dash').onclick = () => { $('ex-provider').value = ''; openModal('m-export'); };
1712
+ $('btn-export').onclick = () => { $('ex-provider').value = state.view === 'zcode' ? 'zcode' : 'p:' + state.view; openModal('m-export'); };
1713
+ $('btn-do-export').onclick = doExport;
1714
+ $('btn-pass-ok').onclick = () => submitImport($('pass-input').value);
1715
+ $('pass-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitImport($('pass-input').value); });
1716
+ $('btn-scan').onclick = localScan;
1717
+ $('btn-device').onclick = deviceLogin;
1718
+ $('dev-cancel').onclick = () => deviceStop('已取消');
1719
+ $('dev-link').addEventListener('click', (e) => { e.preventDefault(); openAuth($('dev-link').href); });
1720
+ $('btn-add-token').onclick = addToken;
1721
+ $('f-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') addToken(); });
1722
+ $('list').addEventListener('click', onCardClick);
1723
+ // 标签页:用 URL hash 记住(刷新 / 前进后退保持)
1724
+ function applyView() {
1725
+ const v = location.hash.slice(1);
1726
+ state.view = VIEWS.includes(v) ? v : 'dash';
1727
+ state.region = '';
1728
+ document.querySelectorAll('#nav button').forEach((x) => x.classList.toggle('active', x.dataset.view === state.view));
1729
+ $('view-dash').classList.toggle('active', state.view === 'dash');
1730
+ $('view-prod').classList.toggle('active', state.view !== 'dash' && state.view !== 'tenrouter');
1731
+ $('view-10r').classList.toggle('active', state.view === 'tenrouter');
1732
+ if (state.view === 'tenrouter' && tr.config && tr.config.configured && !tr.quotas && !tr.loading) loadTrQuotas();
1733
+ render();
1734
+ renderStatus();
1735
+ }
1736
+ function goView(v) {
1737
+ if (v === state.view) return;
1738
+ if (v === 'dash') history.pushState(null, '', location.pathname + location.search);
1739
+ else history.pushState(null, '', '#' + v);
1740
+ applyView();
1741
+ }
1742
+ window.addEventListener('popstate', applyView);
1743
+ $('nav').addEventListener('click', (e) => { const b = e.target.closest('button[data-view]'); if (b) goView(b.dataset.view); });
1744
+ $('view-dash').addEventListener('click', (e) => {
1745
+ const t = e.target.closest('[data-view]');
1746
+ if (t) { goView(t.dataset.view); window.scrollTo(0, 0); }
1747
+ });
1748
+ $('region').addEventListener('click', (e) => {
1749
+ const b = e.target.closest('button[data-r]');
1750
+ if (!b) return;
1751
+ state.region = b.dataset.r;
1752
+ render();
1753
+ renderStatus();
1754
+ });('change', () => {
1755
+ $('f-token').placeholder = $('f-provider').value.startsWith('workbuddy')
1756
+ ? 'WorkBuddy / CodeBuddy 登录 token(eyJ…,可在 CodeBuddyExtension 的 auth/*.info 中找到)'
1757
+ : 'dt-… / jt-… 或 PAT(pt-…,自动兑换)';
1758
+ });
1759
+ $('btn-logs').onclick = () => {
1760
+ const c = $('log-card').classList.toggle('collapsed');
1761
+ $('btn-logs').textContent = c ? '展开' : '收起';
1762
+ if (!c) { loadLogs(); $('logs').scrollTop = $('logs').scrollHeight; }
1763
+ };
1764
+
1765
+ // ── 启动:先直接请求(本机模式无需密钥);只有服务端返回 401 时才询问密钥 ──
1766
+ initTheme();
1767
+ $('btn-home').innerHTML = I.github;
1768
+ applyView();
1769
+ (function boot() {
1770
+ function start() {
1771
+ loadStatus().then(loadAccounts).catch((e) => toast(e.message, false));
1772
+ loadZNet();
1773
+ loadTrConfig().then(() => loadTrQuotas());
1774
+ setInterval(() => loadTrQuotas(), 5 * 60 * 1000);
1775
+ loadLogs();
1776
+ setInterval(loadLogs, 10000);
1777
+ setInterval(async () => { await loadStatus(); render(); }, 30000);
1778
+ }
1779
+ // 先直接请求(本机模式无需密码);需要密码时 api() 会弹框并重试
1780
+ api('/api/status').then(start).catch((e) => toast(e.code === 'PANEL_KEY'
1781
+ ? '未输入面板访问密码,刷新页面可重试' : '无法连接守护进程:' + e.message, false));
1782
+ })();
1783
+ </script>
1784
+ </body>
1785
+ </html>