cicy-desktop 2.1.342 → 2.1.358

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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/scripts/build-homepage.cjs +16 -1
  3. package/src/app-updater.js +30 -0
  4. package/src/backends/homepage-preload.js +3 -1
  5. package/src/backends/homepage-react/assets/index-Buyk0RlW.js +655 -0
  6. package/src/backends/homepage-react/assets/index-CVk0uc2g.css +1 -0
  7. package/src/backends/homepage-react/index.html +5 -3
  8. package/src/backends/homepage-react/panel/facebook-matrix.html +305 -87
  9. package/src/backends/homepage-react/panel/ipcheck.html +112 -0
  10. package/src/backends/homepage-react/panel/matrix.html +595 -0
  11. package/src/backends/homepage-react/panel/telegram-matrix.html +96 -22
  12. package/src/backends/homepage-react/panel/tiktok-matrix.html +599 -0
  13. package/src/backends/hub-client.js +108 -2
  14. package/src/backends/sidecar-ipc.js +33 -6
  15. package/src/main.js +67 -11
  16. package/src/sidecar/host-mihomo.js +47 -2
  17. package/src/tabbrowser/facebook-identity.js +12 -1
  18. package/src/tabbrowser/facebook-matrix.html +305 -87
  19. package/src/tabbrowser/fb-login.js +115 -0
  20. package/src/tabbrowser/newtab-protocol.js +6 -1
  21. package/src/tabbrowser/panel-cells.js +14 -1
  22. package/src/tabbrowser/telegram-matrix.html +96 -22
  23. package/src/tabbrowser/telegram-web-preferences.js +5 -1
  24. package/src/tabbrowser/tiktok-matrix.html +305 -87
  25. package/src/tools/tab-browser-tools.js +21 -100
  26. package/src/tray.js +22 -0
  27. package/test/bootstrap-failure-reporting.test.js +1 -1
  28. package/test/facebook-identity.test.js +3 -1
  29. package/test/facebook-matrix.test.js +2 -1
  30. package/test/telegram-matrix.test.js +2 -2
  31. package/test/tiktok-matrix.test.js +2 -1
  32. package/tools/arm-fblogin.js +32 -0
  33. package/tools/wsd-ws +114 -0
  34. package/workers/render/index.html +3 -1
  35. package/workers/render/package.json +2 -3
  36. package/workers/render/public/panel/facebook-matrix.html +305 -87
  37. package/workers/render/public/panel/ipcheck.html +112 -0
  38. package/workers/render/public/panel/matrix.html +595 -0
  39. package/workers/render/public/panel/telegram-matrix.html +96 -22
  40. package/workers/render/public/panel/tiktok-matrix.html +599 -0
  41. package/workers/render/src/App.css +4 -0
  42. package/workers/render/src/App.jsx +917 -52
  43. package/workers/render/stamp-panel-version.mjs +16 -0
  44. package/workers/render/worker.js +1 -1
  45. package/src/backends/homepage-react/assets/index-C6Zrdgc0.css +0 -1
  46. package/src/backends/homepage-react/assets/index-OG6JB73F.js +0 -411
  47. package/src/tabbrowser/cicy-code-tab-restore.js +0 -10
  48. package/test/cicy-code-tab-restore.test.js +0 -21
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="pv" content="1a7d94e93d9c">
6
7
  <title>Facebook 矩阵</title>
7
8
  <style>
8
9
  :root { color-scheme: dark; --bg:#0A0A0A; --cell:#141416; --head:#1a1a1c; --line:rgba(255,255,255,.08); --fg:#e6e6e9; --muted:#8b8b92; --accent:#3b82f6; --hover:rgba(255,255,255,.06); font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
@@ -40,26 +41,27 @@
40
41
  .line2 .tg b { color: var(--fg); font-weight: 600; }
41
42
  .line2 .tg.none { color: #6b6b72; }
42
43
  /* 编辑态就地变成可输入,尺寸/位置与只读态完全一致,不发生位移 */
43
- .line2 .ip { flex: none; max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 6px; border-radius: 999px; background: var(--head); color: #b4b4bb; font: 11px ui-monospace, monospace; line-height: 16px; cursor: pointer; }
44
- .line2 .ip.none { color: #6b6b72; font-family: inherit; }
45
- .line2 .ip.busy { color: #f0b429; }
46
- .line2 .ip.bad { color: #ff8a8a; }
47
- .line2 .ip:hover { color: #fff; }
48
- .cfg { display: none; margin: 8px 0 2px 16px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--head); }
49
- .row.cfg-open .cfg { display: block; }
50
- .row.cfg-open .gear { color: var(--accent); }
51
- .cfg .f { display: flex; align-items: center; gap: 6px; }
52
- .cfg .f + .f { margin-top: 6px; }
53
- .cfg label { flex: none; width: 34px; font-size: 11px; color: var(--muted); }
54
- .cfg input { flex: 1; min-width: 0; height: 26px; border: 1px solid var(--line); border-radius: 6px; padding: 0 8px; background: #111113; color: var(--fg); font: 11.5px ui-monospace, monospace; outline: 0; }
55
- .cfg input:focus { border-color: var(--accent); }
56
- .cfg .hint { font-size: 11px; color: var(--muted); padding-left: 40px; }
57
- .cfg .hint b { color: #b4b4bb; font-weight: 500; }
58
- .cfg .danger { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
59
- .cfg .danger .sp { flex: 1; }
60
- .cfg .confirm { display: none; align-items: center; gap: 6px; margin-top: 8px; padding: 6px 8px; border-radius: 6px; background: rgba(255,92,92,.12); color: #ffb3b3; font-size: 11.5px; }
61
- .cfg .confirm.on { display: flex; }
62
- .cfg .confirm .sp { flex: 1; }
44
+ .line2 .ip { flex: none; max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0 6px; border-radius: 999px; background: var(--head); color: #b4b4bb; font: 11px ui-monospace, monospace; line-height: 16px; }
45
+ .line2 .ip.none { display: none; }
46
+ .line2 .phone { flex: none; color: #b4b4bb; font: 11px ui-monospace, monospace; white-space: nowrap; }
47
+ .line2 .phone.none { display: none; }
48
+ button.code { background: #1f6feb; padding: 3px 8px; font-size: 11px; }
49
+ button.code.on { background: #c0142e; }
50
+ .ghead .phone { color: #b4b4bb; font: 11px ui-monospace, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
51
+ /* 代理设置弹窗 */
52
+ .modal { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.55); }
53
+ .modal[hidden] { display: none; }
54
+ .modal-card { width: 380px; max-width: 92vw; background: var(--cell); border: 1px solid var(--line); border-radius: 14px; padding: 18px; box-shadow: 0 24px 70px rgba(0,0,0,.5); }
55
+ .modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; font-size: 15px; font-weight: 650; }
56
+ .modal-head .icon { background: transparent; color: #b4b4bb; padding: 2px 6px; font-size: 14px; border-radius: 6px; }
57
+ .modal-head .icon:hover { background: var(--hover); color: #fff; }
58
+ .modal-label { display: block; font-size: 12px; color: var(--muted); margin: 4px 0 6px; }
59
+ .modal-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
60
+ .modal-row input { flex: 1; min-width: 0; height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 10px; background: #111113; color: var(--fg); font: 12px ui-monospace, monospace; outline: 0; }
61
+ .modal-row input:focus { border-color: var(--accent); }
62
+ .modal-ip { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #b4b4bb; }
63
+ .modal-err { color: #ff8a8a; font-size: 12px; min-height: 16px; margin-bottom: 4px; word-break: break-all; }
64
+ .modal-danger { margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
63
65
  button.del { background: #b3261e; }
64
66
  .row-err { padding: 4px 0 0 16px; color: #ff8a8a; font-size: 11px; display: none; }
65
67
  .row-err.on { display: block; }
@@ -73,7 +75,7 @@
73
75
  #status { color: var(--muted); white-space: nowrap; }
74
76
  .actions { display: flex; gap: 6px; flex: none; }
75
77
  .actions button { padding: 5px 9px; font-size: 12px; }
76
- #phone { position: relative; width: min(410px, calc(100% - 20px)); height: min(760px, calc(100vh - 92px)); min-height: 420px; padding: 10px; overflow: hidden; border: 1px solid var(--line); border-radius: 28px; background: var(--cell); box-shadow: 0 24px 70px rgba(0,0,0,.45); }
78
+ #phone { position: relative; width: min(410px, calc(100% - 20px)); flex: 1 1 auto; min-height: 420px; padding: 10px; overflow: hidden; border: 1px solid var(--line); border-radius: 28px; background: var(--cell); box-shadow: 0 24px 70px rgba(0,0,0,.45); }
77
79
  #phone-preview { width: 100%; height: 100%; overflow: hidden; border-radius: 19px; background: #fff; }
78
80
  .overlay { position: absolute; inset: 10px; border-radius: 19px; display: none; place-items: center; text-align: center; padding: 28px; color: var(--fg); background: var(--head); z-index: 2; }
79
81
  .overlay.on { display: grid; }
@@ -81,26 +83,56 @@
81
83
  .overlay p { margin: 0 0 14px; font-size: 13px; line-height: 1.6; color: var(--muted); word-break: break-all; }
82
84
  .overlay code { color: var(--fg); font: 12px ui-monospace, monospace; }
83
85
  .overlay .btns { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
86
+ /* 批量登录:未登录的会话一次全部铺开(最多 10 格),每格一个独立 BrowserView */
87
+ #grid-title { display: none; width: 100%; margin-bottom: 6px; align-items: center; justify-content: space-between; gap: 8px; color: #b4b4bb; font-size: 12px; }
88
+ #grid-title b { color: var(--fg); font-size: 13px; }
89
+ #grid { display: none; width: 100%; flex: 1 1 auto; min-height: 0; gap: 8px; flex-direction: row; flex-wrap: wrap; align-content: flex-start; overflow-x: hidden; overflow-y: auto; }
90
+ /* 批量模式 = 整页只有 webview:侧栏整个隐藏,顶部一条细工具栏,其余全是格子 */
91
+ body.batch { grid-template-columns: 1fr; }
92
+ body.batch aside { display: none; }
93
+ main.batch { padding: 8px 10px 10px; }
94
+ main.batch #preview-title, main.batch #phone { display: none; }
95
+ main.batch #grid-title { display: flex; }
96
+ main.batch #grid { display: flex; }
97
+ .gcell { display: flex; flex-direction: column; flex: none; width: 360px; height: 50vh; min-height: 0; border: 1px solid var(--line); border-radius: 14px; background: var(--cell); overflow: hidden; }
98
+ .gcell.done { border-color: rgba(63,185,80,.6); }
99
+ .gcell.failed { border-color: rgba(255,92,92,.6); }
100
+ .ghead { flex: none; height: 30px; padding: 0 8px; display: flex; align-items: center; gap: 6px; font-size: 12px; border-bottom: 1px solid var(--line); background: var(--head); min-width: 0; }
101
+ .ghead .name { font-size: 12px; }
102
+ .ghead .st { margin-left: auto; }
103
+ .gcell.done .ghead .st { color: #3fb950; font-weight: 600; }
104
+ .gbody { flex: 1 1 auto; min-height: 0; width: 100%; background: #fff; }
105
+ #grid-empty { grid-column: 1 / -1; display: grid; place-items: center; color: var(--muted); }
84
106
  .spinner { width: 28px; height: 28px; border: 3px solid rgba(255,255,255,.12); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 12px; }
85
107
  @keyframes spin { to { transform: rotate(360deg); } }
108
+ /* Facebook 矩阵:接码/手机号是 Telegram 专属,这里隐藏 */
109
+ button.code, .line2 .phone, .ghead .phone, #cfg-login, #open-code { display: none !important; }
110
+ /* 机器 id 徽章:机器多了一眼认出这是哪台。列表/批量两种模式都显示。 */
111
+ .mid{display:inline-block;margin-right:8px;padding:2px 9px;border-radius:7px;font:700 13px ui-monospace,monospace;
112
+ background:#1f6feb;color:#fff;letter-spacing:.5px;vertical-align:middle;box-shadow:0 1px 4px rgba(0,0,0,.4)}
86
113
  </style>
87
114
  </head>
88
115
  <body>
89
116
  <aside>
90
117
  <header>
91
- <h1>Facebook 矩阵 <small id="count"></small></h1>
92
- <button id="add-profile" title="新建一个独立会话(persist:sandbox-N)">+ 添加 Profile</button>
118
+ <h1><span id="mid-list" class="mid" hidden></span>Facebook 矩阵 <small id="count"></small></h1>
119
+ <div class="actions"><button class="ghost" id="batch-login" title="平铺所有 Profile 的会话到网格(最多 10 个)">批量网格</button><button id="add-profile" title="新建一个独立会话(persist:sandbox-N)">+ 添加 Profile</button></div>
93
120
  </header>
94
121
  <div class="list">
95
122
  <div id="rows"></div>
96
123
  <div id="empty">还没有 Profile<br>点击右上角「添加 Profile」创建第一个 Facebook 会话</div>
97
124
  </div>
98
- <div class="hint">点击行切换预览;点代理地址可修改(留空 = 直连)。已打开过的会话会在后台保持登录。</div>
125
+ <div class="hint">点击行切换预览;点 ⚙ 设置代理 / 测 IP / 删除。已打开过的会话会在后台保持登录。</div>
99
126
  </aside>
100
127
  <main>
128
+ <div id="grid-title">
129
+ <div class="left"><span id="mid-batch" class="mid" hidden></span><b>批量登录</b><span id="grid-count"></span></div>
130
+ <div class="actions"><button class="ghost" id="grid-reload" title="重新加载所有格子">全部重载</button><button class="ghost" id="grid-exit">退出批量</button></div>
131
+ </div>
132
+ <div id="grid"></div>
101
133
  <div id="preview-title">
102
134
  <div class="left"><span class="dot" id="sel-dot"></span><span id="selected-name">未选择 Profile</span><span id="status"></span></div>
103
- <div class="actions"><button class="ghost" id="reload" title="重新加载 Facebook">重新加载</button></div>
135
+ <div class="actions"><button class="code" id="open-code" title="在格子上叠加/关闭接码 webview(同代理出口)">接码</button><button class="ghost" id="probe-ip" title="经该 Profile 的代理探测出口 IP / 地区">测 IP</button><button class="ghost" id="reload" title="重新加载 Facebook">重新加载</button></div>
104
136
  </div>
105
137
  <div id="phone">
106
138
  <div id="phone-preview"></div>
@@ -115,6 +147,22 @@
115
147
  </div>
116
148
  </div>
117
149
  </main>
150
+ <div id="cfg-modal" class="modal" hidden>
151
+ <div class="modal-card">
152
+ <div class="modal-head"><span id="cfg-title">Profile 设置</span><button class="icon" id="cfg-x" title="关闭">✕</button></div>
153
+ <div id="cfg-login">
154
+ <label class="modal-label">手机号</label>
155
+ <div class="modal-row"><input id="cfg-phone" placeholder="+8801700000000" spellcheck="false"></div>
156
+ <label class="modal-label">接码 URL</label>
157
+ <div class="modal-row"><input id="cfg-code" placeholder="https://…/getcode?id=…" spellcheck="false"><button class="ghost" id="cfg-code-open" title="在该 Profile 的 tab 里打开">打开</button></div>
158
+ </div>
159
+ <label class="modal-label">代理地址</label>
160
+ <div class="modal-row"><input id="cfg-proxy" placeholder="留空 = 直连,例如 http://127.0.0.1:20001" spellcheck="false"><button id="cfg-save">保存</button></div>
161
+ <div class="modal-row"><button class="ghost" id="cfg-test">测 IP</button><span class="modal-ip" id="cfg-ip"></span></div>
162
+ <div class="modal-err" id="cfg-err"></div>
163
+ <div class="modal-danger"><button class="ghost del" id="cfg-del">删除此 Profile</button></div>
164
+ </div>
165
+ </div>
118
166
  <script>
119
167
  const SITE_URL = 'https://www.facebook.com/';
120
168
  const rowsEl = document.getElementById('rows');
@@ -130,38 +178,80 @@
130
178
  let profiles = [];
131
179
  let selected = null;
132
180
  const openedProfiles = new Set(); // 打开过的会话保留在后台(登录态不丢),只有选中的贴到预览区
181
+ const codeOpen = new Set(); // 已叠开接码 overlay 的 accountIdx(同 partition 的接码 webview 覆盖在格子上)
133
182
  const cellState = new Map(); // accountIdx -> { loading, failed, error, url, title }
134
183
  const rows = new Map(); // accountIdx -> row element
184
+ const BATCH_MAX = 10; // 一次最多铺开多少个登录格
185
+ const mainEl = document.querySelector('main');
186
+ const gridEl = document.getElementById('grid');
187
+ const gridCount = document.getElementById('grid-count');
188
+ let batchMode = false;
189
+ const batch = new Set(); // 批量模式下铺开的 accountIdx
190
+ const gcells = new Map(); // accountIdx -> { el, body }
135
191
 
136
192
  const esc = (value) => String(value || '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
193
+ // 机器 id:app 打开面板时把本机 id 拼进 URL(?team=xs-3007)。两处徽章都填上。
194
+ (function(){ try {
195
+ const t = new URLSearchParams(location.search).get('team');
196
+ if (t) for (const id of ['mid-list','mid-batch']) {
197
+ const el = document.getElementById(id); if (el) { el.textContent = t; el.hidden = false; el.title = '本机: ' + t; }
198
+ }
199
+ if (t) document.title = t + ' · Facebook 矩阵';
200
+ } catch (e) {} })();
137
201
  const cellId = (idx) => `facebook-preview-${idx}`;
138
202
  const stateOf = (idx) => cellState.get(Number(idx)) || {};
139
203
  const dotClass = (st) => st.loading ? 'loading' : st.failed ? 'failed' : (st.url ? 'ok' : '');
140
204
  const stateText = (st, opened) => st.loading ? '加载中…' : st.failed ? '连接失败' : st.url ? '已加载' : (opened ? '' : '未打开');
141
205
  const fmtAgo = (iso) => { const t = Date.parse(iso || ''); if (!t) return ''; const m = Math.round((Date.now() - t) / 60000); return m < 1 ? '刚刚' : m < 60 ? `${m} 分钟前` : m < 1440 ? `${Math.round(m / 60)} 小时前` : `${Math.round(m / 1440)} 天前`; };
142
- function renderIp(p, mode) {
206
+ // 列表只显示一个简短地区(取最具体的一段:优先省/州,否则国家),完整信息在 tooltip。
207
+ const shortArea = (area) => { const seg = String(area || '').split('·').map((x) => x.trim()).filter(Boolean); return seg[1] || seg[0] || ''; };
208
+ function renderIp(p) {
143
209
  const row = rows.get(p.accountIdx); if (!row) return;
144
- const el = row.querySelector('.ip');
145
- el.className = 'ip';
146
- if (mode === 'busy') { el.classList.add('busy'); el.textContent = '测 IP…'; el.title = ''; return; }
147
- if (mode && mode.error) { el.classList.add('bad'); el.textContent = 'IP 探测失败'; el.title = mode.error + '(点击重试)'; return; }
210
+ const el = row.querySelector('.ip'); el.className = 'ip';
148
211
  const info = p.ipInfo;
149
212
  if (info && info.ip) {
150
- el.textContent = info.area ? `${info.area} · ${info.ip}` : info.ip;
151
- el.title = `出口 IP ${info.ip}${info.area ? ',' + info.area : ''},${fmtAgo(info.probedAt) || '未知时间'}探测(点击重测)`;
152
- } else { el.classList.add('none'); el.textContent = '测 IP'; el.title = '经该 Profile 的代理探测出口 IP / 地区'; }
213
+ const a = shortArea(info.area);
214
+ el.textContent = a || info.ip;
215
+ el.title = `出口 IP ${info.ip}${info.area ? ',' + info.area : ''},${fmtAgo(info.probedAt) || '未知时间'}探测`;
216
+ } else { el.classList.add('none'); el.textContent = ''; el.title = ''; }
153
217
  }
218
+ // 经该 Profile 的代理探测出口 IP;成功后更新它的列表地区。返回 {ok, info|error}。
154
219
  async function probeIp(p) {
155
- renderIp(p, 'busy');
156
220
  try {
157
221
  const r = await window.panelAPI.probeIp(p.accountIdx);
158
- if (r && r.ipInfo) { p.ipInfo = r.ipInfo; renderIp(p); }
159
- else renderIp(p, { error: (r && r.error) || '探测失败' });
160
- } catch (e) { renderIp(p, { error: e.message || String(e) }); }
222
+ if (r && r.ipInfo) { p.ipInfo = r.ipInfo; renderIp(p); return { ok: true, info: r.ipInfo }; }
223
+ return { ok: false, error: (r && r.error) || '探测失败' };
224
+ } catch (e) { return { ok: false, error: e.message || String(e) }; }
161
225
  }
162
226
  // 身份:优先用本次会话探到的(cellState.identity),否则用 profile 里存的(上次登录)。
163
227
  const identityOf = (p) => stateOf(p.accountIdx).identity || p.facebook || null;
228
+ const phoneOf = (p) => (p.login && p.login.phone) || '';
229
+ const codeUrlOf = (p) => (p.login && p.login.codeUrl) || '';
230
+ function renderPhone(p) {
231
+ const row = rows.get(p.accountIdx); if (!row) return;
232
+ const el = row.querySelector('.phone'); const ph = phoneOf(p);
233
+ el.classList.toggle('none', !ph); el.textContent = ph; el.title = codeUrlOf(p) ? `接码:${codeUrlOf(p)}` : '';
234
+ const g = gcells.get(p.accountIdx); if (g) { g.el.querySelector('.phone').textContent = ph; g.el.querySelector('.code').disabled = !codeUrlOf(p); }
235
+ }
236
+ // 接码 = 在当前格子的 Telegram webview 上方叠一个同尺寸、同 partition(persist:sandbox-idx,
237
+ // 走该 profile 的代理出口)、更高 z 层的接码 webview。再点一次关闭。与 tg-autologin 自动化里
238
+ // 的 overlay 完全一致的呈现,只是重载/关闭放在格子 header(BrowserView 之上无法放页面按钮)。
239
+ function openCode(p) {
240
+ if (!codeUrlOf(p)) { flash(`#${p.accountIdx} 还没有接码 URL,点 ⚙ 填写`, 4000); return; }
241
+ if (!openedProfiles.has(p.accountIdx)) { openedProfiles.add(p.accountIdx); cellState.set(p.accountIdx, { loading: true }); }
242
+ if (codeOpen.has(p.accountIdx)) { codeOpen.delete(p.accountIdx); flash(`#${p.accountIdx} 已关闭接码`); }
243
+ else { codeOpen.add(p.accountIdx); flash(`#${p.accountIdx} 接码已叠加在格子上`); }
244
+ updateCodeButtons(p.accountIdx);
245
+ syncPreview();
246
+ }
247
+ function updateCodeButtons(idx) {
248
+ const on = codeOpen.has(idx);
249
+ const g = gcells.get(idx);
250
+ if (g) { const b = g.el.querySelector('.code'); if (b) { b.classList.toggle('on', on); b.textContent = on ? '关闭接码' : '接码'; } }
251
+ if (selected && selected.accountIdx === idx) { const b = document.getElementById('open-code'); if (b) { b.classList.toggle('on', on); b.textContent = on ? '关闭接码' : '接码'; } }
252
+ }
164
253
  function renderIdentity(p) {
254
+ renderPhone(p);
165
255
  const row = rows.get(p.accountIdx); if (!row) return;
166
256
  const el = row.querySelector('.tg');
167
257
  const it = identityOf(p);
@@ -185,14 +275,22 @@
185
275
  function syncPreview() {
186
276
  if (!window.panelAPI) return;
187
277
  const rect = preview.getBoundingClientRect();
188
- const cells = [...openedProfiles].map((profileId) => ({
189
- id: cellId(profileId),
190
- url: SITE_URL,
191
- profile: profileId,
192
- visible: !!selected && profileId === selected.accountIdx,
193
- rect: selected && profileId === selected.accountIdx ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
194
- }));
195
- try { window.panelAPI.sync(cells); } catch (e) {}
278
+ const box = (r) => ({ x: r.x, y: r.y, w: r.width, h: r.height });
279
+ const cells = []; const overlays = [];
280
+ for (const profileId of openedProfiles) {
281
+ let visible = false, r = null;
282
+ if (batchMode) {
283
+ const g = gcells.get(profileId);
284
+ if (g && batch.has(profileId)) { visible = true; r = box(g.body.getBoundingClientRect()); }
285
+ } else if (selected && profileId === selected.accountIdx) { visible = true; r = box(rect); }
286
+ cells.push({ id: cellId(profileId), url: SITE_URL, profile: profileId, visible, rect: r });
287
+ // 接码叠加层:同 rect、同 profile(=同 partition/代理),排在 tg 格子之后 → attach 更晚 → 叠最上层
288
+ if (visible && codeOpen.has(profileId)) {
289
+ const p = profiles.find((x) => x.accountIdx === profileId); const cu = p && codeUrlOf(p);
290
+ if (cu) overlays.push({ id: 'code-' + profileId, url: cu, profile: profileId, visible: true, rect: r });
291
+ }
292
+ }
293
+ try { window.panelAPI.sync(cells.concat(overlays)); } catch (e) {}
196
294
  }
197
295
 
198
296
  function describeError(p, st) {
@@ -210,6 +308,7 @@
210
308
  if (selected && st.failed) errDetail.innerHTML = describeError(selected, st);
211
309
  selDot.className = `dot ${selected ? dotClass(st) : ''}`;
212
310
  if (selected && !statusEl.dataset.sticky) statusEl.textContent = stateText(st, true);
311
+ if (selected) updateCodeButtons(selected.accountIdx);
213
312
  }
214
313
 
215
314
  function renderRowState(idx) {
@@ -218,59 +317,78 @@
218
317
  row.classList.toggle('failed', !!st.failed && !st.loading);
219
318
  row.querySelector('.dot').className = `dot ${dotClass(st)}`;
220
319
  row.querySelector('.st').textContent = stateText(st, openedProfiles.has(idx));
320
+ renderGridCell(idx);
321
+ }
322
+
323
+ // ── 批量登录:把未登录的 Profile 一次全部铺开 ──
324
+ const hasIdentity = (p) => { const it = identityOf(p); return !!(it && (it.username || it.displayName || it.phone)); };
325
+ const gridCols = (n) => n <= 1 ? 1 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 8 ? 4 : 5;
326
+ function buildGridCell(p) {
327
+ const el = document.createElement('div');
328
+ el.className = 'gcell'; el.dataset.id = String(p.accountIdx);
329
+ el.innerHTML = `<div class="ghead"><span class="dot"></span><span class="name">${esc(p.name)}</span><span class="chip">#${p.accountIdx}</span><span class="phone">${esc(phoneOf(p))}</span><span class="st"></span><button class="code" title="在格子上叠加/关闭接码 webview(同代理出口)">接码</button><button class="icon reload" title="重新加载">⟳</button></div><div class="gbody"></div>`;
330
+ el.querySelector('.reload').onclick = () => reloadProfile(p.accountIdx);
331
+ el.querySelector('.code').onclick = () => openCode(p);
332
+ el.querySelector('.code').disabled = !codeUrlOf(p);
333
+ el.querySelector('.ghead').onclick = (e) => { if (!e.target.closest('button')) { exitBatch(); selectProfile(p.accountIdx); } };
334
+ return { el, body: el.querySelector('.gbody') };
335
+ }
336
+ function renderGridCell(idx) {
337
+ const g = gcells.get(idx); if (!g) return;
338
+ const p = profiles.find((x) => x.accountIdx === idx); const st = stateOf(idx);
339
+ const done = !!p && hasIdentity(p);
340
+ g.el.classList.toggle('done', done);
341
+ g.el.classList.toggle('failed', !!st.failed && !st.loading);
342
+ g.el.querySelector('.dot').className = `dot ${dotClass(st)}`;
343
+ g.el.querySelector('.st').textContent = done ? '✓ 已登录' : stateText(st, true);
344
+ updateCodeButtons(idx);
345
+ if (batchMode) { const n = [...batch].filter((i) => { const q = profiles.find((x) => x.accountIdx === i); return q && hasIdentity(q); }).length; gridCount.textContent = `${batch.size} 个会话 · ${n} 已登录`; }
346
+ }
347
+ function enterBatch() {
348
+ // 默认网格 = 平铺全部 profile 的会话(不再只挑未登录的);仍受 BATCH_MAX 上限保护。
349
+ const todo = profiles.slice(0, BATCH_MAX);
350
+ if (!todo.length) { flash('没有 Profile'); return; }
351
+ batch.clear(); gridEl.innerHTML = ''; gcells.clear();
352
+ for (const p of todo) {
353
+ batch.add(p.accountIdx);
354
+ const g = buildGridCell(p); gcells.set(p.accountIdx, g); gridEl.appendChild(g.el);
355
+ if (!openedProfiles.has(p.accountIdx)) { openedProfiles.add(p.accountIdx); cellState.set(p.accountIdx, { loading: true }); }
356
+ renderRowState(p.accountIdx);
357
+ }
358
+ gridEl.style.gridTemplateColumns = `repeat(${gridCols(todo.length)}, minmax(0, 1fr))`;
359
+ batchMode = true; mainEl.classList.add('batch'); document.body.classList.add('batch'); document.getElementById('batch-login').textContent = '退出批量';
360
+ render();
361
+ syncPreview(); // 后台 tab 里 rAF 不会触发,先直接同步一次把格子建出来
362
+ requestAnimationFrame(syncPreview);
363
+ setTimeout(pullStates, 300);
221
364
  }
365
+ function exitBatch() {
366
+ if (!batchMode) return;
367
+ batchMode = false; mainEl.classList.remove('batch'); document.body.classList.remove('batch'); document.getElementById('batch-login').textContent = '批量网格';
368
+ batch.clear(); gcells.clear(); gridEl.innerHTML = '';
369
+ render(); renderPreviewState(); syncPreview(); // 会话留在后台,登录态不丢
370
+ }
371
+ document.getElementById('batch-login').onclick = () => (batchMode ? exitBatch() : enterBatch());
372
+ document.getElementById('grid-exit').onclick = exitBatch;
373
+ document.getElementById('grid-reload').onclick = () => { for (const idx of batch) reloadProfile(idx); };
374
+ new ResizeObserver(() => requestAnimationFrame(syncPreview)).observe(gridEl);
222
375
 
223
376
  function buildRow(p) {
224
377
  const row = document.createElement('div');
225
378
  row.className = 'row'; row.dataset.id = String(p.accountIdx); row.title = `persist:sandbox-${p.accountIdx}`;
226
379
  row.innerHTML = `
227
380
  <div class="line1"><span class="dot"></span><span class="name">${esc(p.name)}</span><span class="chip">#${p.accountIdx}</span><span class="st"></span><button class="icon gear" title="代理设置">⚙</button><button class="icon reload" title="重新加载">⟳</button></div>
228
- <div class="line2"><span class="tg none">—</span><span class="ip none" title="经该 Profile 的代理探测出口 IP / 地区">测 IP</span></div>
229
- <div class="cfg">
230
- <div class="f"><label>代理</label><input data-role="proxy" placeholder="留空 = 直连,例如 http://127.0.0.1:20001" spellcheck="false"><button class="sm save">保存</button><button class="sm ghost test">测 IP</button></div>
231
- <div class="hint">保存后已打开的会话会重新加载;<b>测 IP</b> 经该代理访问 IP 服务,结果显示在上方。</div>
232
- <div class="danger"><span>删除此 Profile 会一并清除它的 Facebook 登录态和缓存。</span><span class="sp"></span><button class="sm ghost rm">删除</button></div>
233
- <div class="confirm"><span>确认删除 <b>${esc(p.name)} #${p.accountIdx}</b>?不可恢复。</span><span class="sp"></span><button class="sm del rm-yes">确认删除</button><button class="sm ghost rm-no">取消</button></div>
234
- </div>
235
- <div class="row-err"></div>`;
236
- const errEl = row.querySelector('.row-err');
237
- // 备注功能已按需求移除(列表只保留 名称 / @用户名 / 状态 / IP)
238
- // 代理设置抽屉(⚙):不常用,默认收起,列表保持干净。
239
- const proxyInput = row.querySelector('[data-role="proxy"]');
240
- const saveBtn = row.querySelector('.cfg .save');
241
- const toggleCfg = (e) => { e.stopPropagation(); const open = !row.classList.contains('cfg-open'); row.classList.toggle('cfg-open', open); if (open) { proxyInput.value = p.proxy || ''; proxyInput.focus(); } };
242
- const saveProxy = async () => {
243
- saveBtn.disabled = true; errEl.classList.remove('on');
244
- try {
245
- const updated = await window.panelAPI.setProfileProxy(p.accountIdx, proxyInput.value.trim());
246
- p.proxy = updated.proxy; p.ipInfo = null; renderIp(p);
247
- if (openedProfiles.has(p.accountIdx)) { reloadProfile(p.accountIdx); flash(`#${p.accountIdx} 代理已应用,重新加载中`); }
248
- else flash(`#${p.accountIdx} 代理已保存,打开时生效`);
249
- row.classList.remove('cfg-open');
250
- } catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); }
251
- finally { saveBtn.disabled = false; }
252
- };
253
- row.querySelector('.gear').onclick = toggleCfg;
254
- row.querySelector('.cfg').onclick = (e) => e.stopPropagation();
255
- saveBtn.onclick = saveProxy;
256
- proxyInput.onkeydown = (e) => { e.stopPropagation(); if (e.key === 'Enter') saveProxy(); if (e.key === 'Escape') row.classList.remove('cfg-open'); };
257
- row.querySelector('.cfg .test').onclick = () => probeIp(p);
258
- row.querySelector('.ip').onclick = (e) => { e.stopPropagation(); probeIp(p); };
259
- const confirmEl = row.querySelector('.cfg .confirm');
260
- row.querySelector('.rm').onclick = () => { confirmEl.classList.add('on'); row.querySelector('.rm-yes').focus(); };
261
- row.querySelector('.rm-no').onclick = () => confirmEl.classList.remove('on');
262
- row.querySelector('.rm-yes').onclick = async () => {
263
- const btn = row.querySelector('.rm-yes'); btn.disabled = true;
264
- try { await removeProfile(p); }
265
- catch (e) { errEl.textContent = e.message || String(e); errEl.classList.add('on'); btn.disabled = false; }
266
- };
267
- row._openCfg = () => { if (!row.classList.contains('cfg-open')) toggleCfg(new Event('click')); row.scrollIntoView({ block: 'nearest' }); };
268
- row.onclick = () => selectProfile(p.accountIdx);
381
+ <div class="line2"><span class="tg none">—</span><span class="phone none"></span><span class="ip none"></span></div>`;
382
+ // ⚙ 打开设置弹窗(代理 / 测 IP / 删除),不再 inline。
383
+ row.querySelector('.gear').onclick = (e) => { e.stopPropagation(); openCfgModal(p); };
384
+ row.onclick = () => { exitBatch(); selectProfile(p.accountIdx); };
269
385
  row.querySelector('.reload').onclick = (e) => { e.stopPropagation(); reloadProfile(p.accountIdx); };
270
386
  return row;
271
387
  }
272
388
 
273
389
  function reloadProfile(idx) {
390
+ // 接码 overlay 开着时,重载指向接码 webview(用户看到的最上层就是它)
391
+ if (codeOpen.has(idx)) { try { window.panelAPI.reload('code-' + idx); } catch (e) {} return; }
274
392
  const fresh = !openedProfiles.has(idx);
275
393
  openedProfiles.add(idx);
276
394
  cellState.set(idx, { loading: true }); renderRowState(idx);
@@ -317,6 +435,7 @@
317
435
  cellState.set(idx, next);
318
436
  renderRowState(idx);
319
437
  { const p = profiles.find((x) => x.accountIdx === idx); if (p) renderIdentity(p); }
438
+ renderGridCell(idx);
320
439
  if (selected && selected.accountIdx === idx) renderPreviewState();
321
440
  }
322
441
  async function pullStates({ adopt = false } = {}) {
@@ -366,16 +485,115 @@
366
485
  finally { addButton.disabled = false; }
367
486
  };
368
487
  document.getElementById('reload').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
488
+ document.getElementById('open-code').onclick = () => { if (selected) openCode(selected); };
369
489
  document.getElementById('err-retry').onclick = () => { if (selected) reloadProfile(selected.accountIdx); };
370
- document.getElementById('err-cfg').onclick = () => { const row = selected && rows.get(selected.accountIdx); if (row) row._openCfg(); };
490
+ document.getElementById('err-cfg').onclick = () => { if (selected) openCfgModal(selected); };
491
+ document.getElementById('probe-ip').onclick = async () => {
492
+ if (!selected) { flash('先选择一个 Profile'); return; }
493
+ const b = document.getElementById('probe-ip'); const o = b.textContent; b.disabled = true; b.textContent = '测 IP…';
494
+ const r = await probeIp(selected);
495
+ b.disabled = false; b.textContent = o;
496
+ flash(r.ok ? `#${selected.accountIdx} 出口:${shortArea(selected.ipInfo.area) || selected.ipInfo.ip}` : `IP 探测失败:${r.error}`, 6000);
497
+ };
498
+ // ── 设置弹窗(代理 / 测 IP / 删除) ──
499
+ const cfgModal = document.getElementById('cfg-modal');
500
+ const cfgProxy = document.getElementById('cfg-proxy');
501
+ const cfgErr = document.getElementById('cfg-err');
502
+ const cfgIp = document.getElementById('cfg-ip');
503
+ const cfgDel = document.getElementById('cfg-del');
504
+ let cfgP = null, delArmed = false;
505
+ const cfgPhone = document.getElementById('cfg-phone');
506
+ const cfgCode = document.getElementById('cfg-code');
507
+ document.getElementById('cfg-code-open').onclick = () => { if (cfgP) openCode({ ...cfgP, login: { phone: cfgPhone.value, codeUrl: cfgCode.value.trim() } }); };
508
+ const cfgIpText = (p) => p && p.ipInfo && p.ipInfo.ip ? (p.ipInfo.area ? `${p.ipInfo.area} · ${p.ipInfo.ip}` : p.ipInfo.ip) : '未探测';
509
+ function openCfgModal(p) {
510
+ if (!p) return; cfgP = p; delArmed = false;
511
+ document.getElementById('cfg-title').textContent = `${p.name} · #${p.accountIdx} 设置`;
512
+ cfgProxy.value = p.proxy || ''; cfgErr.textContent = ''; cfgIp.textContent = cfgIpText(p);
513
+ cfgPhone.value = phoneOf(p); cfgCode.value = codeUrlOf(p);
514
+ cfgDel.textContent = '删除此 Profile'; cfgDel.disabled = false;
515
+ cfgModal.hidden = false; cfgProxy.focus();
516
+ // BrowserView 预览永远浮在页面 DOM 之上,会盖住弹窗 —— 打开弹窗时把 cells 脱离(复用拖拽逻辑),关闭时贴回。
517
+ try { window.panelAPI.dragging(true); } catch (e) {}
518
+ }
519
+ function closeCfgModal() {
520
+ cfgModal.hidden = true; cfgP = null; delArmed = false;
521
+ try { window.panelAPI.dragging(false); } catch (e) {}
522
+ syncPreview();
523
+ }
524
+ cfgModal.onclick = (e) => { if (e.target === cfgModal) closeCfgModal(); };
525
+ document.getElementById('cfg-x').onclick = closeCfgModal;
526
+ document.getElementById('cfg-save').onclick = async () => {
527
+ if (!cfgP) return; const b = document.getElementById('cfg-save'); b.disabled = true; cfgErr.textContent = '';
528
+ try {
529
+ const lg = await window.panelAPI.setProfileLogin(cfgP.accountIdx, cfgPhone.value.trim(), cfgCode.value.trim());
530
+ cfgP.login = lg.login; renderPhone(cfgP);
531
+ const u = await window.panelAPI.setProfileProxy(cfgP.accountIdx, cfgProxy.value.trim());
532
+ cfgP.proxy = u.proxy; cfgP.ipInfo = null; renderIp(cfgP);
533
+ if (openedProfiles.has(cfgP.accountIdx)) { reloadProfile(cfgP.accountIdx); flash(`#${cfgP.accountIdx} 代理已应用,重新加载中`); }
534
+ else flash(`#${cfgP.accountIdx} 代理已保存,打开时生效`);
535
+ closeCfgModal();
536
+ } catch (e) { cfgErr.textContent = e.message || String(e); } finally { b.disabled = false; }
537
+ };
538
+ cfgProxy.onkeydown = (e) => { if (e.key === 'Enter') document.getElementById('cfg-save').onclick(); if (e.key === 'Escape') closeCfgModal(); };
539
+ document.getElementById('cfg-test').onclick = async () => {
540
+ if (!cfgP) return; const b = document.getElementById('cfg-test'); const o = b.textContent; b.disabled = true; b.textContent = '测 IP…'; cfgErr.textContent = '';
541
+ const r = await probeIp(cfgP);
542
+ b.disabled = false; b.textContent = o;
543
+ if (r.ok) cfgIp.textContent = cfgIpText(cfgP); else cfgErr.textContent = '探测失败:' + r.error;
544
+ };
545
+ cfgDel.onclick = async () => {
546
+ if (!cfgP) return;
547
+ if (!delArmed) { delArmed = true; cfgDel.textContent = '确认删除(不可恢复)'; setTimeout(() => { if (delArmed) { delArmed = false; cfgDel.textContent = '删除此 Profile'; } }, 4000); return; }
548
+ delArmed = false; cfgDel.disabled = true; cfgErr.textContent = '';
549
+ try { await removeProfile(cfgP); closeCfgModal(); }
550
+ catch (e) { cfgErr.textContent = e.message || String(e); cfgDel.disabled = false; cfgDel.textContent = '删除此 Profile'; }
551
+ };
552
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !cfgModal.hidden) closeCfgModal(); });
371
553
 
372
554
  new ResizeObserver(() => requestAnimationFrame(syncPreview)).observe(preview);
373
555
  window.addEventListener('resize', syncPreview);
556
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) { syncPreview(); setTimeout(syncPreview, 500); setTimeout(pullStates, 800); } });
374
557
  try { window.panelAPI.onCellState(applyCellState); } catch (e) {}
375
558
  pullStates({ adopt: true })
376
559
  .catch(() => {})
377
560
  .then(() => loadProfiles())
561
+ // 打开面板即默认进批量网格:把所有 profile 的 webview 平铺出来。
562
+ // 崩溃循环保护:若上次开机自动批量后没在几秒内清除标记(说明渲染进程崩溃重载了),
563
+ // 这次跳过自动批量、停在列表,避免弱机反复「崩溃→自动批量→再崩」的刷新循环。
564
+ .then(() => {
565
+ try {
566
+ const K = 'fb-matrix-autobatch-pending';
567
+ let prev = null; try { prev = localStorage.getItem(K); } catch (e) {}
568
+ if (prev && (Date.now() - Number(prev)) < 20000) {
569
+ try { localStorage.removeItem(K); } catch (e) {}
570
+ flash('上次批量疑似导致崩溃,已跳过自动批量。点「批量网格」可手动打开。', 6000);
571
+ return;
572
+ }
573
+ try { localStorage.setItem(K, String(Date.now())); } catch (e) {}
574
+ enterBatch();
575
+ setTimeout(() => { try { localStorage.removeItem(K); } catch (e) {} }, 8000);
576
+ } catch (e) {}
577
+ })
378
578
  .catch((e) => { flash(e.message || String(e), 8000); render(); });
579
+
580
+ // 自动更新:构建时给本页打了内容版本号(<meta name="pv">)。每 60s 拉一次自身
581
+ // (同源 cicyui://panel,无跨域;cicyui handler 每次都从远端取最新),版本变了就 reload。
582
+ // 这样服务端一部署,所有开着的面板自己就切到新版,不用手动逐台重载。DEV 态不轮询。
583
+ (function autoUpdate() {
584
+ const meta = document.querySelector('meta[name="pv"]');
585
+ const PV = meta ? meta.content : '';
586
+ if (!PV || PV === 'DEV') return;
587
+ setInterval(async () => {
588
+ try {
589
+ const r = await fetch(location.href, { cache: 'no-store' });
590
+ if (!r.ok) return;
591
+ const t = await r.text();
592
+ const mm = t.match(/<meta name="pv" content="([^"]*)">/);
593
+ if (mm && mm[1] && mm[1] !== PV) location.reload();
594
+ } catch (e) {}
595
+ }, 60000);
596
+ })();
379
597
  </script>
380
598
  </body>
381
599
  </html>