@wendongfly/myhi 1.3.174 → 1.3.179

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.
@@ -1,1063 +1,1064 @@
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, maximum-scale=1.0, viewport-fit=cover">
6
- <meta name="theme-color" content="#0d1117">
7
- <meta name="apple-mobile-web-app-capable" content="yes">
8
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
- <link rel="manifest" href="/manifest.json">
10
- <link rel="apple-touch-icon" href="/icon.png">
11
- <title>myhi 终端</title>
12
- <link rel="stylesheet" href="/lib/xterm/css/xterm.css">
13
- <script src="/lib/xterm/lib/xterm.js"></script>
14
- <script src="/lib/xterm-fit/lib/xterm-addon-fit.js"></script>
15
- <script src="/lib/xterm-links/lib/xterm-addon-web-links.js"></script>
16
- <script src="/sio-client/socket.io.min.js"></script>
17
- <style>
18
- * { box-sizing: border-box; margin: 0; padding: 0; }
19
-
20
- html, body {
21
- height: 100%;
22
- background: #0d1117;
23
- color: #e6edf3;
24
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
25
- overflow: hidden;
26
- }
27
-
28
- body {
29
- display: flex;
30
- flex-direction: column;
31
- }
32
-
33
- #top-bar {
34
- display: flex;
35
- align-items: center;
36
- justify-content: space-between;
37
- padding: 0.5rem 0.75rem;
38
- background: #161b22;
39
- border-bottom: 1px solid #30363d;
40
- flex-shrink: 0;
41
- gap: 0.5rem;
42
- padding-top: max(0.5rem, env(safe-area-inset-top));
43
- }
44
-
45
- #session-title {
46
- font-size: 0.9rem;
47
- font-weight: 600;
48
- color: #58a6ff;
49
- overflow: hidden;
50
- text-overflow: ellipsis;
51
- white-space: nowrap;
52
- flex: 1;
53
- }
54
-
55
- #viewer-count {
56
- font-size: 0.75rem;
57
- color: #8b949e;
58
- white-space: nowrap;
59
- }
60
-
61
- .top-btn {
62
- background: #21262d;
63
- color: #e6edf3;
64
- border: 1px solid #30363d;
65
- border-radius: 6px;
66
- padding: 0.3rem 0.6rem;
67
- font-size: 0.75rem;
68
- cursor: pointer;
69
- white-space: nowrap;
70
- }
71
- .top-btn:hover { background: #30363d; }
72
-
73
- /* ── Tab bar ── */
74
- #tab-bar {
75
- display: flex;
76
- align-items: center;
77
- background: #0d1117;
78
- border-bottom: 1px solid #30363d;
79
- flex-shrink: 0;
80
- overflow-x: auto;
81
- scrollbar-width: none;
82
- -webkit-overflow-scrolling: touch;
83
- }
84
- #tab-bar::-webkit-scrollbar { display: none; }
85
-
86
- .tab {
87
- display: flex;
88
- align-items: center;
89
- gap: 0.4rem;
90
- padding: 0.4rem 0.75rem;
91
- font-size: 0.78rem;
92
- color: #8b949e;
93
- background: transparent;
94
- border: none;
95
- border-right: 1px solid #21262d;
96
- cursor: pointer;
97
- white-space: nowrap;
98
- flex-shrink: 0;
99
- max-width: 160px;
100
- overflow: hidden;
101
- text-overflow: ellipsis;
102
- position: relative;
103
- }
104
- .tab:hover { color: #e6edf3; background: #161b22; }
105
- .tab.active {
106
- color: #e6edf3;
107
- background: #161b22;
108
- border-bottom: 2px solid #7c3aed;
109
- }
110
- .tab .tab-close {
111
- font-size: 0.7rem;
112
- color: #6e7681;
113
- border: none;
114
- background: none;
115
- cursor: pointer;
116
- padding: 0 0.15rem;
117
- border-radius: 3px;
118
- line-height: 1;
119
- }
120
- .tab .tab-close:hover { color: #f85149; background: #21262d; }
121
- .tab .tab-dot {
122
- width: 6px; height: 6px;
123
- border-radius: 50%;
124
- background: #3fb950;
125
- flex-shrink: 0;
126
- }
127
- .tab .tab-dot.dead { background: #f85149; }
128
-
129
- #tab-add {
130
- background: none;
131
- border: none;
132
- color: #6e7681;
133
- font-size: 1.1rem;
134
- padding: 0.3rem 0.6rem;
135
- cursor: pointer;
136
- flex-shrink: 0;
137
- }
138
- #tab-add:hover { color: #e6edf3; }
139
-
140
- /* ── Terminal area ── */
141
- #terminal-wrapper {
142
- flex: 1;
143
- overflow: hidden;
144
- position: relative;
145
- }
146
-
147
- .term-pane {
148
- position: absolute;
149
- inset: 0;
150
- padding: 4px;
151
- display: none;
152
- }
153
- .term-pane.active { display: block; }
154
-
155
- /* Mobile shortcut bar */
156
- #shortcut-bar {
157
- display: flex;
158
- align-items: center;
159
- background: #161b22;
160
- border-top: 1px solid #30363d;
161
- padding: 0.4rem 0.5rem;
162
- padding-bottom: max(0.4rem, env(safe-area-inset-bottom));
163
- gap: 0.4rem;
164
- overflow-x: auto;
165
- flex-shrink: 0;
166
- -webkit-overflow-scrolling: touch;
167
- scrollbar-width: none;
168
- }
169
- #shortcut-bar::-webkit-scrollbar { display: none; }
170
-
171
- .sk {
172
- background: #21262d;
173
- color: #e6edf3;
174
- border: 1px solid #30363d;
175
- border-radius: 6px;
176
- padding: 0.35rem 0.65rem;
177
- font-size: 0.8rem;
178
- font-family: 'SF Mono', 'Consolas', monospace;
179
- cursor: pointer;
180
- white-space: nowrap;
181
- flex-shrink: 0;
182
- user-select: none;
183
- -webkit-user-select: none;
184
- touch-action: manipulation;
185
- }
186
- .sk:active { background: #30363d; transform: scale(0.95); }
187
-
188
- /* QR modal */
189
- #qr-modal {
190
- display: none;
191
- position: fixed;
192
- inset: 0;
193
- background: rgba(0,0,0,0.7);
194
- z-index: 100;
195
- align-items: center;
196
- justify-content: center;
197
- }
198
- #qr-modal.open { display: flex; }
199
- #qr-box {
200
- background: #161b22;
201
- border: 1px solid #30363d;
202
- border-radius: 12px;
203
- padding: 1.5rem;
204
- text-align: center;
205
- max-width: 320px;
206
- width: 90%;
207
- }
208
- #qr-box h3 { margin-bottom: 1rem; font-size: 0.95rem; }
209
- #qr-box img { width: 100%; max-width: 240px; border-radius: 8px; background: #fff; padding: 8px; }
210
- #qr-box .close-btn {
211
- margin-top: 1rem;
212
- background: #21262d;
213
- color: #e6edf3;
214
- border: 1px solid #30363d;
215
- border-radius: 6px;
216
- padding: 0.5rem 1.5rem;
217
- cursor: pointer;
218
- font-size: 0.9rem;
219
- }
220
-
221
- #status-overlay {
222
- position: absolute;
223
- inset: 0;
224
- display: flex;
225
- flex-direction: column;
226
- align-items: center;
227
- justify-content: center;
228
- background: rgba(13,17,23,0.85);
229
- color: #8b949e;
230
- font-size: 0.9rem;
231
- z-index: 10;
232
- }
233
- #status-overlay.hidden { display: none; }
234
-
235
- /* ── 文件管理面板 ── */
236
- #file-panel-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 59; display: none; }
237
- #file-panel-backdrop.open { display: block; }
238
- #file-panel {
239
- position: fixed; top: 0; right: -340px; width: 320px; max-width: 90vw; height: 100%;
240
- background: #161b22; border-left: 1px solid #30363d; z-index: 60;
241
- transition: right 0.25s ease; display: flex; flex-direction: column;
242
- }
243
- #file-panel.open { right: 0; }
244
- .fp-header { display: flex; align-items: center; padding: 0.75rem; border-bottom: 1px solid #30363d; gap: 0.5rem; flex-shrink: 0; }
245
- .fp-header h3 { flex: 1; font-size: 0.9rem; margin: 0; color: #e6edf3; }
246
- .fp-btn { background: #21262d; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.75rem; cursor: pointer; }
247
- .fp-btn:active { background: #30363d; }
248
- .fp-path { padding: 0.4rem 0.75rem; font-size: 0.7rem; color: #8b949e; border-bottom: 1px solid #21262d; word-break: break-all; flex-shrink: 0; }
249
- .fp-list { flex: 1; overflow-y: auto; padding: 0.25rem 0; }
250
- .fp-item { display: flex; align-items: center; padding: 0.5rem 0.75rem; gap: 0.5rem; cursor: pointer; font-size: 0.82rem; color: #e6edf3; }
251
- .fp-item:active { background: #21262d; }
252
- .fp-item .fp-icon { width: 1.2rem; text-align: center; flex-shrink: 0; }
253
- .fp-item .fp-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
254
- .fp-item .fp-size { color: #8b949e; font-size: 0.7rem; white-space: nowrap; }
255
- .fp-item .fp-dl { color: #58a6ff; font-size: 0.7rem; text-decoration: none; padding: 0.2rem 0.4rem; }
256
- .fp-empty { text-align: center; color: #8b949e; padding: 2rem 0.75rem; font-size: 0.85rem; }
257
- .fp-drop-zone {
258
- margin: 0.5rem 0.75rem; padding: 1.5rem; border: 2px dashed #30363d; border-radius: 8px;
259
- text-align: center; color: #8b949e; font-size: 0.8rem; flex-shrink: 0;
260
- transition: border-color 0.2s, background 0.2s;
261
- }
262
- .fp-drop-zone.dragover { border-color: #58a6ff; background: rgba(88,166,255,0.08); }
263
- .fp-progress { padding: 0.4rem 0.75rem; font-size: 0.75rem; color: #d29922; flex-shrink: 0; display: none; }
264
- .fp-sync-bar { display: flex; gap: 0.4rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; flex-shrink: 0; align-items: center; flex-wrap: wrap; }
265
- .fp-sync-bar .fp-btn { font-size: 0.7rem; padding: 0.25rem 0.5rem; }
266
- .fp-sync-bar .sync-dir { font-size: 0.68rem; color: #8b949e; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
267
- .fp-diff-item { display: flex; align-items: center; padding: 0.4rem 0.75rem; gap: 0.4rem; font-size: 0.78rem; color: #e6edf3; }
268
- .fp-diff-item .badge { font-size: 0.65rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; flex-shrink: 0; }
269
- .badge-new { background: #238636; color: #fff; }
270
- .badge-changed { background: #9e6a03; color: #fff; }
271
- .badge-arrow { color: #8b949e; flex-shrink: 0; font-size: 0.7rem; }
272
- .fp-diff-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
273
- .fp-diff-summary { padding: 0.75rem; text-align: center; color: #8b949e; font-size: 0.8rem; }
274
- .fp-diff-actions { display: flex; gap: 0.5rem; padding: 0.5rem 0.75rem; flex-shrink: 0; }
275
- .fp-diff-actions .fp-btn { flex: 1; text-align: center; padding: 0.45rem; }
276
- .fp-btn-primary { background: #238636; border-color: #2ea04380; }
277
- .fp-btn-primary:active { background: #2ea043; }
278
- .fp-btn-warn { background: #9e6a03; border-color: #d2992280; }
279
- .fp-btn-warn:active { background: #bb8009; }
280
- </style>
281
- </head>
282
- <body>
283
-
284
- <div id="top-bar">
285
- <div id="session-title">加载中...</div>
286
- <span id="viewer-count"></span>
287
- <button class="top-btn" onclick="openFilePanel()">文件</button>
288
- <button class="top-btn" onclick="showQR()">扫码分享</button>
289
- <button class="top-btn" onclick="goBack()">← 返回</button>
290
- <button class="top-btn" id="logout-btn" onclick="doLogout()" style="display:none;color:#f0883e">退出</button>
291
- </div>
292
-
293
- <!-- Tab bar -->
294
- <div id="tab-bar">
295
- <button id="tab-add" onclick="addTab()" title="新建标签页">+</button>
296
- </div>
297
-
298
- <div id="terminal-wrapper"></div>
299
-
300
- <!-- Mobile shortcut bar -->
301
- <div id="shortcut-bar">
302
- <button class="sk" id="btn-photo" onclick="openCamera()" style="color:#7c3aed;border-color:#7c3aed33;background:#7c3aed11">📷</button>
303
- <button class="sk" data-send="ctrl-c">Ctrl+C</button>
304
- <button class="sk" data-send="ctrl-d">Ctrl+D</button>
305
- <button class="sk" data-send="tab">Tab</button>
306
- <button class="sk" data-send="up">↑</button>
307
- <button class="sk" data-send="down">↓</button>
308
- <button class="sk" data-send="right">→</button>
309
- <button class="sk" data-send="left">←</button>
310
- <button class="sk" data-send="esc">Esc</button>
311
- <button class="sk" data-send="ctrl-a">Ctrl+A</button>
312
- <button class="sk" data-send="ctrl-e">Ctrl+E</button>
313
- <button class="sk" data-send="ctrl-k">Ctrl+K</button>
314
- <button class="sk" data-send="ctrl-u">Ctrl+U</button>
315
- <button class="sk" data-send="ctrl-r">Ctrl+R</button>
316
- <button class="sk" data-send="alt-.">Alt+.</button>
317
- </div>
318
-
319
- <!-- 文件管理面板 -->
320
- <div id="file-panel-backdrop" onclick="closeFilePanel()"></div>
321
- <div id="file-panel">
322
- <div class="fp-header">
323
- <h3 id="fp-title">文件管理</h3>
324
- <button class="fp-btn" onclick="fpUploadClick()">上传</button>
325
- <button class="fp-btn" onclick="fpStartSync()">同步</button>
326
- <button class="fp-btn" onclick="closeFilePanel()">关闭</button>
327
- </div>
328
- <div class="fp-sync-bar" id="fp-sync-bar" style="display:none">
329
- <span class="sync-dir" id="fp-sync-dir"></span>
330
- <button class="fp-btn" onclick="fpPickDir()">换目录</button>
331
- <button class="fp-btn" onclick="fpExitSync()">退出同步</button>
332
- </div>
333
- <div class="fp-path" id="fp-path"></div>
334
- <div class="fp-list" id="fp-list"></div>
335
- <div class="fp-diff-actions" id="fp-diff-actions" style="display:none">
336
- <button class="fp-btn fp-btn-primary" onclick="fpSyncToLocal()">下载到本地</button>
337
- <button class="fp-btn fp-btn-warn" onclick="fpSyncToServer()">上传到服务器</button>
338
- </div>
339
- <div class="fp-progress" id="fp-progress"></div>
340
- <div class="fp-drop-zone" id="fp-drop">拖拽文件到此处上传,或点击上方按钮</div>
341
- <input type="file" id="fp-file-input" multiple style="display:none">
342
- </div>
343
-
344
- <!-- QR modal -->
345
- <div id="qr-modal">
346
- <div id="qr-box">
347
- <h3>扫码在其他设备打开此终端</h3>
348
- <img id="qr-img" src="" alt="QR Code">
349
- <br>
350
- <button class="close-btn" onclick="closeQR()">关闭</button>
351
- </div>
352
- </div>
353
-
354
- <script>
355
- // 加载用户信息(独占模式显示退出按钮)
356
- fetch('/api/me').then(r => r.json()).then(data => {
357
- if (data.exclusive || data.hasUsers) document.getElementById('logout-btn').style.display = '';
358
- }).catch(() => {});
359
- function doLogout() {
360
- if (!confirm('确定退出登录?')) return;
361
- try { for (const t of Object.values(tabs)) t.socket?.disconnect(); } catch {}
362
- fetch('/logout', { method: 'POST' }).then(() => { location.href = '/login'; });
363
- }
364
-
365
- // ── Tab 管理 ─────────────────────────────────────────────
366
- const TERM_THEME = {
367
- background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff',
368
- selectionBackground: '#264f78',
369
- black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
370
- blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#e6edf3',
371
- brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
372
- brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
373
- brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
374
- };
375
-
376
- const tabs = []; // { id, sessionId, title, term, fitAddon, socket, pane, tabEl, alive }
377
- let activeTabId = null;
378
-
379
- const tabBar = document.getElementById('tab-bar');
380
- const tabAddBtn = document.getElementById('tab-add');
381
- const wrapper = document.getElementById('terminal-wrapper');
382
-
383
- // 从 URL 获取初始会话 ID
384
- const pathParts = location.pathname.split('/');
385
- const INIT_SESSION_ID = pathParts[pathParts.length - 1];
386
-
387
- // ── 创建 Tab ──
388
- function createTab(sessionId) {
389
- const id = Date.now() + '-' + Math.random().toString(36).slice(2, 6);
390
-
391
- // 创建终端容器
392
- const pane = document.createElement('div');
393
- pane.className = 'term-pane';
394
- wrapper.appendChild(pane);
395
-
396
- // 创建 xterm
397
- const term = new Terminal({
398
- theme: TERM_THEME,
399
- fontFamily: "'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace",
400
- fontSize: 14, lineHeight: 1.2, cursorBlink: true,
401
- allowTransparency: false, scrollback: 5000,
402
- });
403
- const fitAddon = new FitAddon.FitAddon();
404
- const webLinksAddon = new WebLinksAddon.WebLinksAddon();
405
- term.loadAddon(fitAddon);
406
- term.loadAddon(webLinksAddon);
407
- term.open(pane);
408
-
409
- // 创建 Socket 连接
410
- const socket = io({
411
- transports: ['websocket', 'polling'],
412
- reconnection: true,
413
- reconnectionAttempts: Infinity,
414
- reconnectionDelay: 1000,
415
- reconnectionDelayMax: 10000,
416
- timeout: 30000,
417
- });
418
-
419
- const tab = { id, sessionId, title: '连接中...', term, fitAddon, socket, pane, tabEl: null, alive: true };
420
- tabs.push(tab);
421
-
422
- // Tab 按钮
423
- renderTab(tab);
424
-
425
- // Socket 事件
426
- socket.on('connect', () => {
427
- socket.emit('join', sessionId);
428
- });
429
-
430
- socket.on('joined', (session) => {
431
- tab.title = session.title || 'shell';
432
- tab.alive = session.alive !== false;
433
- renderTab(tab);
434
- if (activeTabId === id) {
435
- updateTopBar(tab);
436
- fitTab(tab);
437
- }
438
- });
439
-
440
- socket.on('output', (data) => {
441
- term.write(data);
442
- });
443
-
444
- socket.on('kicked', ({ reason }) => { alert(reason || '你已被管理员踢出'); location.href = '/'; });
445
-
446
- socket.on('session-exit', ({ code }) => {
447
- tab.alive = false;
448
- tab.title = (tab.title || 'shell') + ' (已退出)';
449
- renderTab(tab);
450
- if (activeTabId === id) updateTopBar(tab);
451
- });
452
-
453
- socket.on('sessions', (sessions) => {
454
- const s = sessions.find(x => x.id === sessionId);
455
- if (s && activeTabId === id) {
456
- document.getElementById('viewer-count').textContent = s.viewers > 1 ? `👁 ${s.viewers}` : '';
457
- }
458
- });
459
-
460
- socket.on('disconnect', () => {
461
- // 显示断连提示
462
- term.write('
463
- [myhi] 连接已断开,正在重连...
464
- ');
465
- });
466
-
467
- // 终端输入
468
- term.onData((data) => {
469
- socket.emit('input', data);
470
- });
471
-
472
- // 点击终端获取焦点
473
- pane.addEventListener('click', () => term.focus());
474
-
475
- return tab;
476
- }
477
-
478
- // ── 渲染 Tab 按钮 ──
479
- function renderTab(tab) {
480
- if (tab.tabEl) tab.tabEl.remove();
481
-
482
- const el = document.createElement('div');
483
- el.className = 'tab' + (activeTabId === tab.id ? ' active' : '');
484
- el.innerHTML = `<span class="tab-dot${tab.alive ? '' : ' dead'}"></span>` +
485
- `<span style="overflow:hidden;text-overflow:ellipsis">${esc(tab.title)}</span>` +
486
- `<button class="tab-close" title="关闭">&times;</button>`;
487
-
488
- el.addEventListener('click', (e) => {
489
- if (e.target.classList.contains('tab-close')) {
490
- closeTab(tab.id);
491
- } else {
492
- switchTab(tab.id);
493
- }
494
- });
495
-
496
- tab.tabEl = el;
497
- tabBar.insertBefore(el, tabAddBtn);
498
- }
499
-
500
- // ── 切换 Tab ──
501
- function switchTab(id) {
502
- activeTabId = id;
503
- tabs.forEach(t => {
504
- const isActive = t.id === id;
505
- t.pane.classList.toggle('active', isActive);
506
- if (t.tabEl) t.tabEl.classList.toggle('active', isActive);
507
- });
508
- const tab = tabs.find(t => t.id === id);
509
- if (tab) {
510
- updateTopBar(tab);
511
- fitTab(tab);
512
- tab.term.focus();
513
- }
514
- }
515
-
516
- // ── 关闭 Tab ──
517
- function closeTab(id) {
518
- const idx = tabs.findIndex(t => t.id === id);
519
- if (idx === -1) return;
520
- const tab = tabs[idx];
521
-
522
- // 断开连接
523
- tab.socket.disconnect();
524
- tab.term.dispose();
525
- tab.pane.remove();
526
- if (tab.tabEl) tab.tabEl.remove();
527
- tabs.splice(idx, 1);
528
-
529
- if (tabs.length === 0) {
530
- window.location.href = '/';
531
- return;
532
- }
533
-
534
- // 切换到相邻 tab
535
- if (activeTabId === id) {
536
- const next = tabs[Math.min(idx, tabs.length - 1)];
537
- switchTab(next.id);
538
- }
539
- }
540
-
541
- // ── 新建 Tab(弹出会话选择)──
542
- async function addTab() {
543
- try {
544
- const res = await fetch('/api/sessions');
545
- const sessions = await res.json();
546
- const alive = sessions.filter(s => s.alive && s.mode !== 'agent');
547
-
548
- if (alive.length === 0) {
549
- alert('没有可用的终端会话,请先在首页创建');
550
- return;
551
- }
552
-
553
- // 过滤掉已打开的会话
554
- const opened = new Set(tabs.map(t => t.sessionId));
555
- const available = alive.filter(s => !opened.has(s.id));
556
-
557
- if (available.length === 0) {
558
- alert('所有会话已在标签页中打开');
559
- return;
560
- }
561
-
562
- if (available.length === 1) {
563
- // 只有一个,直接打开
564
- const tab = createTab(available[0].id);
565
- switchTab(tab.id);
566
- return;
567
- }
568
-
569
- // 多个可选:简单弹窗选择
570
- const names = available.map((s, i) => `${i + 1}. ${s.title}`).join('\n');
571
- const choice = prompt(`选择要打开的会话:\n\n${names}\n\n输入编号:`);
572
- if (!choice) return;
573
- const n = parseInt(choice, 10) - 1;
574
- if (n >= 0 && n < available.length) {
575
- const tab = createTab(available[n].id);
576
- switchTab(tab.id);
577
- }
578
- } catch (e) {
579
- alert('获取会话列表失败: ' + e.message);
580
- }
581
- }
582
-
583
- // ── 辅助函数 ──
584
- function fitTab(tab) {
585
- requestAnimationFrame(() => {
586
- try { tab.fitAddon.fit(); } catch {}
587
- if (tab.socket.connected) {
588
- tab.socket.emit('resize', { cols: tab.term.cols, rows: tab.term.rows });
589
- }
590
- });
591
- }
592
-
593
- function updateTopBar(tab) {
594
- document.getElementById('session-title').textContent = tab.title;
595
- document.title = tab.title + ' — myhi';
596
- }
597
-
598
- function esc(s) {
599
- const d = document.createElement('div');
600
- d.textContent = s;
601
- return d.innerHTML;
602
- }
603
-
604
- // 窗口 resize 时 fit 当前 tab
605
- window.addEventListener('resize', () => {
606
- const tab = tabs.find(t => t.id === activeTabId);
607
- if (tab) fitTab(tab);
608
- });
609
-
610
- // ── 移动端连接保活 ──────────────────────────────
611
- document.addEventListener('visibilitychange', () => {
612
- if (!document.hidden) {
613
- for (const tab of tabs) {
614
- if (!tab.socket.connected) tab.socket.connect();
615
- else tab.socket.emit('join', tab.sessionId);
616
- }
617
- }
618
- });
619
- window.addEventListener('online', () => {
620
- for (const tab of tabs) { if (!tab.socket.connected) tab.socket.connect(); }
621
- });
622
- setInterval(() => {
623
- if (!document.hidden) {
624
- for (const tab of tabs) { if (!tab.socket.connected) tab.socket.connect(); }
625
- }
626
- }, 15000);
627
-
628
- // ── 快捷键发送到当前 tab ──
629
- const SEQ = {
630
- 'ctrl-c': '\x03', 'ctrl-d': '\x04', 'tab': '\t',
631
- 'up': '\x1b[A', 'down': '\x1b[B', 'right': '\x1b[C', 'left': '\x1b[D',
632
- 'esc': '\x1b',
633
- 'ctrl-a': '\x01', 'ctrl-e': '\x05', 'ctrl-k': '\x0b',
634
- 'ctrl-u': '\x15', 'ctrl-r': '\x12', 'alt-.': '\x1b.',
635
- };
636
-
637
- document.querySelectorAll('.sk').forEach(btn => {
638
- const send = () => {
639
- const tab = tabs.find(t => t.id === activeTabId);
640
- if (!tab) return;
641
- const seq = SEQ[btn.dataset.send] || btn.dataset.send;
642
- tab.socket.emit('input', seq);
643
- tab.term.focus();
644
- };
645
- btn.addEventListener('touchend', (e) => { e.preventDefault(); send(); });
646
- btn.addEventListener('click', send);
647
- });
648
-
649
- // ── QR modal ──
650
- function showQR() {
651
- const tab = tabs.find(t => t.id === activeTabId);
652
- if (!tab) return;
653
- document.getElementById('qr-img').src = `/qr/${tab.sessionId}`;
654
- document.getElementById('qr-modal').classList.add('open');
655
- }
656
- function closeQR() {
657
- document.getElementById('qr-modal').classList.remove('open');
658
- }
659
- document.getElementById('qr-modal').addEventListener('click', (e) => {
660
- if (e.target === e.currentTarget) closeQR();
661
- });
662
-
663
- function goBack() { window.location.href = '/'; }
664
-
665
- // ── Photo upload ──
666
- const MAX_PX = 1920, QUALITY = 0.85;
667
-
668
- function compressImage(file) {
669
- return new Promise((resolve, reject) => {
670
- const img = new Image();
671
- const url = URL.createObjectURL(file);
672
- img.onload = () => {
673
- URL.revokeObjectURL(url);
674
- let { width: w, height: h } = img;
675
- if (w > MAX_PX || h > MAX_PX) {
676
- if (w >= h) { h = Math.round(h * MAX_PX / w); w = MAX_PX; }
677
- else { w = Math.round(w * MAX_PX / h); h = MAX_PX; }
678
- }
679
- const canvas = document.createElement('canvas');
680
- canvas.width = w; canvas.height = h;
681
- canvas.getContext('2d').drawImage(img, 0, 0, w, h);
682
- canvas.toBlob(
683
- blob => blob ? resolve(blob) : reject(new Error('canvas toBlob failed')),
684
- 'image/jpeg', QUALITY
685
- );
686
- };
687
- img.onerror = reject;
688
- img.src = url;
689
- });
690
- }
691
-
692
- function openCamera() {
693
- const input = document.createElement('input');
694
- input.type = 'file';
695
- input.accept = 'image/*';
696
- input.onchange = async () => {
697
- const file = input.files?.[0];
698
- if (!file) return;
699
- const tab = tabs.find(t => t.id === activeTabId);
700
- if (!tab) return;
701
-
702
- const btn = document.getElementById('btn-photo');
703
- btn.textContent = '⏳';
704
- btn.disabled = true;
705
-
706
- try {
707
- const compressed = await compressImage(file);
708
- const form = new FormData();
709
- form.append('image', compressed, 'photo.jpg');
710
- const res = await fetch('/upload?sessionId=' + tab.sessionId, { method: 'POST', body: form });
711
- const data = await res.json();
712
- if (data.path) {
713
- tab.socket.emit('input', data.path);
714
- tab.term.focus();
715
- } else {
716
- alert('上传失败: ' + (data.error || '未知'));
717
- }
718
- } catch (e) {
719
- alert('出错: ' + e.message);
720
- } finally {
721
- btn.textContent = '📷';
722
- btn.disabled = false;
723
- }
724
- };
725
- input.click();
726
- }
727
-
728
- // ── 文件管理 ──────────────────────────────────────
729
- let fpCurrentPath = '';
730
- let fpSessionId = null;
731
-
732
- function openFilePanel() {
733
- const tab = tabs.find(t => t.id === activeTabId);
734
- if (!tab) return;
735
- fpSessionId = tab.sessionId;
736
- fpCurrentPath = '';
737
- loadFiles();
738
- document.getElementById('file-panel').classList.add('open');
739
- document.getElementById('file-panel-backdrop').classList.add('open');
740
- }
741
- function closeFilePanel() {
742
- document.getElementById('file-panel').classList.remove('open');
743
- document.getElementById('file-panel-backdrop').classList.remove('open');
744
- }
745
-
746
- async function loadFiles() {
747
- if (!fpSessionId) return;
748
- const list = document.getElementById('fp-list');
749
- list.innerHTML = '<div class="fp-empty">加载中...</div>';
750
- try {
751
- const r = await fetch(`/api/files/${fpSessionId}?path=${encodeURIComponent(fpCurrentPath)}`);
752
- const data = await r.json();
753
- if (!data.ok) { list.innerHTML = `<div class="fp-empty">${esc(data.error)}</div>`; return; }
754
- document.getElementById('fp-path').textContent = data.cwd + (data.path ? '/' + data.path : '');
755
- if (!data.entries.length && data.parent == null) {
756
- list.innerHTML = '<div class="fp-empty">空目录</div>';
757
- return;
758
- }
759
- let html = '';
760
- if (data.parent != null) {
761
- html += `<div class="fp-item" onclick="fpNav('${esc(data.parent)}')"><span class="fp-icon">📁</span><span class="fp-name">..</span></div>`;
762
- }
763
- for (const e of data.entries) {
764
- const rel = fpCurrentPath ? fpCurrentPath + '/' + e.name : e.name;
765
- if (e.isDir) {
766
- html += `<div class="fp-item" onclick="fpNav('${esc(rel)}')"><span class="fp-icon">📁</span><span class="fp-name">${esc(e.name)}</span></div>`;
767
- } else {
768
- html += `<div class="fp-item">
769
- <span class="fp-icon">📄</span>
770
- <span class="fp-name">${esc(e.name)}</span>
771
- <span class="fp-size">${formatSize(e.size)}</span>
772
- <a class="fp-dl" href="/api/files/${fpSessionId}/download?path=${encodeURIComponent(rel)}" download title="下载">下载</a>
773
- </div>`;
774
- }
775
- }
776
- list.innerHTML = html;
777
- } catch (e) {
778
- list.innerHTML = `<div class="fp-empty">加载失败: ${esc(e.message)}</div>`;
779
- }
780
- }
781
-
782
- function fpNav(path) { fpCurrentPath = path; loadFiles(); }
783
-
784
- function formatSize(bytes) {
785
- if (bytes == null) return '';
786
- if (bytes < 1024) return bytes + ' B';
787
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
788
- if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
789
- return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
790
- }
791
-
792
- function fpUploadClick() { document.getElementById('fp-file-input').click(); }
793
-
794
- document.getElementById('fp-file-input').addEventListener('change', (e) => {
795
- if (e.target.files.length) fpUploadFiles(e.target.files);
796
- e.target.value = '';
797
- });
798
-
799
- async function fpUploadFiles(files) {
800
- if (!fpSessionId || !files.length) return;
801
- const progress = document.getElementById('fp-progress');
802
- progress.style.display = 'block';
803
- progress.textContent = `正在上传 ${files.length} 个文件...`;
804
- try {
805
- const form = new FormData();
806
- for (const f of files) form.append('files', f, f.webkitRelativePath || f.name);
807
- const r = await fetch(`/api/files/${fpSessionId}/upload?path=${encodeURIComponent(fpCurrentPath)}`, {
808
- method: 'POST', body: form,
809
- });
810
- const data = await r.json();
811
- if (data.ok) {
812
- progress.textContent = `已上传 ${data.files.length} 个文件`;
813
- loadFiles();
814
- } else {
815
- progress.textContent = '上传失败: ' + (data.error || '未知错误');
816
- }
817
- } catch (e) {
818
- progress.textContent = '上传出错: ' + e.message;
819
- }
820
- setTimeout(() => { progress.style.display = 'none'; }, 3000);
821
- }
822
-
823
- // 拖拽上传
824
- const fpDrop = document.getElementById('fp-drop');
825
- fpDrop.addEventListener('dragover', (e) => { e.preventDefault(); fpDrop.classList.add('dragover'); });
826
- fpDrop.addEventListener('dragleave', () => fpDrop.classList.remove('dragover'));
827
- fpDrop.addEventListener('drop', (e) => {
828
- e.preventDefault();
829
- fpDrop.classList.remove('dragover');
830
- if (e.dataTransfer.files.length) fpUploadFiles(e.dataTransfer.files);
831
- });
832
-
833
- // ── 文件同步(File System Access API)──────────
834
- let syncDirHandle = null;
835
- let syncMode = false;
836
- let syncDiff = { toDownload: [], toUpload: [] };
837
-
838
- async function fpStartSync() {
839
- if (!window.showDirectoryPicker) {
840
- alert('当前浏览器不支持文件夹访问,请使用 Chrome 或 Edge');
841
- return;
842
- }
843
- if (!syncDirHandle) {
844
- await fpPickDir();
845
- if (!syncDirHandle) return;
846
- }
847
- syncMode = true;
848
- document.getElementById('fp-sync-bar').style.display = 'flex';
849
- document.getElementById('fp-drop').style.display = 'none';
850
- await fpComputeDiff();
851
- }
852
-
853
- async function fpPickDir() {
854
- try {
855
- syncDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
856
- document.getElementById('fp-sync-dir').textContent = syncDirHandle.name;
857
- if (syncMode) await fpComputeDiff();
858
- } catch (e) {
859
- if (e.name !== 'AbortError') alert('选择文件夹失败: ' + e.message);
860
- }
861
- }
862
-
863
- function fpExitSync() {
864
- syncMode = false;
865
- document.getElementById('fp-sync-bar').style.display = 'none';
866
- document.getElementById('fp-diff-actions').style.display = 'none';
867
- document.getElementById('fp-drop').style.display = '';
868
- loadFiles();
869
- }
870
-
871
- async function walkLocalDir(dirHandle, prefix) {
872
- const files = [];
873
- for await (const [name, handle] of dirHandle.entries()) {
874
- if (name.startsWith('.')) continue;
875
- const rel = prefix ? prefix + '/' + name : name;
876
- if (handle.kind === 'directory') {
877
- files.push(...await walkLocalDir(handle, rel));
878
- } else {
879
- const file = await handle.getFile();
880
- files.push({ name: rel, size: file.size, handle });
881
- }
882
- }
883
- return files;
884
- }
885
-
886
- async function hashFile(file) {
887
- const buf = await file.arrayBuffer();
888
- const hash = await crypto.subtle.digest('SHA-256', buf);
889
- return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
890
- }
891
-
892
- async function fpComputeDiff() {
893
- if (!fpSessionId || !syncDirHandle) return;
894
- const list = document.getElementById('fp-list');
895
- list.innerHTML = '<div class="fp-empty">正在比对文件...</div>';
896
- const progress = document.getElementById('fp-progress');
897
- progress.style.display = 'block';
898
-
899
- try {
900
- progress.textContent = '获取服务器文件清单...';
901
- const r = await fetch(`/api/files/${fpSessionId}/manifest?path=${encodeURIComponent(fpCurrentPath)}`);
902
- const manifest = await r.json();
903
- if (!manifest.ok) throw new Error(manifest.error);
904
-
905
- progress.textContent = '扫描本地文件...';
906
- const localFiles = await walkLocalDir(syncDirHandle, '');
907
-
908
- const serverMap = new Map(manifest.files.map(f => [f.name, f]));
909
- const localMap = new Map(localFiles.map(f => [f.name, f]));
910
-
911
- const toDownload = [];
912
- const toUpload = [];
913
-
914
- let checked = 0;
915
- for (const [name, sf] of serverMap) {
916
- checked++;
917
- if (checked % 20 === 0) progress.textContent = `比对中... ${checked}/${serverMap.size + localMap.size}`;
918
- const lf = localMap.get(name);
919
- if (!lf) {
920
- toDownload.push({ name, size: sf.size, reason: 'new' });
921
- } else if (lf.size !== sf.size) {
922
- toDownload.push({ name, size: sf.size, reason: 'changed' });
923
- } else if (sf.hash && sf.size < 10 * 1024 * 1024) {
924
- const file = await lf.handle.getFile();
925
- const localHash = await hashFile(file);
926
- if (localHash !== sf.hash) toDownload.push({ name, size: sf.size, reason: 'changed' });
927
- }
928
- }
929
- for (const [name, lf] of localMap) {
930
- checked++;
931
- if (checked % 20 === 0) progress.textContent = `比对中... ${checked}/${serverMap.size + localMap.size}`;
932
- const sf = serverMap.get(name);
933
- if (!sf) {
934
- toUpload.push({ name, size: lf.size, reason: 'new', handle: lf.handle });
935
- } else if (lf.size !== sf.size) {
936
- toUpload.push({ name, size: lf.size, reason: 'changed', handle: lf.handle });
937
- } else if (sf.hash && lf.size < 10 * 1024 * 1024) {
938
- const file = await lf.handle.getFile();
939
- const localHash = await hashFile(file);
940
- if (localHash !== sf.hash) toUpload.push({ name, size: lf.size, reason: 'changed', handle: lf.handle });
941
- }
942
- }
943
-
944
- syncDiff = { toDownload, toUpload };
945
- fpRenderDiff();
946
- } catch (e) {
947
- list.innerHTML = `<div class="fp-empty">比对失败: ${esc(e.message)}</div>`;
948
- }
949
- progress.style.display = 'none';
950
- }
951
-
952
- function fpRenderDiff() {
953
- const { toDownload, toUpload } = syncDiff;
954
- const list = document.getElementById('fp-list');
955
-
956
- if (!toDownload.length && !toUpload.length) {
957
- list.innerHTML = '<div class="fp-diff-summary">文件已同步,无差异</div>';
958
- document.getElementById('fp-diff-actions').style.display = 'none';
959
- return;
960
- }
961
-
962
- let html = '<div class="fp-diff-summary">';
963
- if (toDownload.length) html += `${toDownload.length} 个文件需下载到本地`;
964
- if (toDownload.length && toUpload.length) html += ',';
965
- if (toUpload.length) html += `${toUpload.length} 个文件需上传到服务器`;
966
- html += '</div>';
967
-
968
- for (const f of toDownload) {
969
- html += `<div class="fp-diff-item">
970
- <span class="badge ${f.reason === 'new' ? 'badge-new' : 'badge-changed'}">${f.reason === 'new' ? '新' : '改'}</span>
971
- <span class="badge-arrow">↓</span>
972
- <span class="fp-diff-name">${esc(f.name)}</span>
973
- <span class="fp-size">${formatSize(f.size)}</span>
974
- </div>`;
975
- }
976
- for (const f of toUpload) {
977
- html += `<div class="fp-diff-item">
978
- <span class="badge ${f.reason === 'new' ? 'badge-new' : 'badge-changed'}">${f.reason === 'new' ? '新' : '改'}</span>
979
- <span class="badge-arrow">↑</span>
980
- <span class="fp-diff-name">${esc(f.name)}</span>
981
- <span class="fp-size">${formatSize(f.size)}</span>
982
- </div>`;
983
- }
984
-
985
- list.innerHTML = html;
986
- document.getElementById('fp-diff-actions').style.display = toDownload.length || toUpload.length ? 'flex' : 'none';
987
- }
988
-
989
- async function fpSyncToLocal() {
990
- const { toDownload } = syncDiff;
991
- if (!toDownload.length) return;
992
- const progress = document.getElementById('fp-progress');
993
- progress.style.display = 'block';
994
- let done = 0;
995
- for (const f of toDownload) {
996
- progress.textContent = `下载中 ${++done}/${toDownload.length}: ${f.name}`;
997
- try {
998
- const relPath = fpCurrentPath ? fpCurrentPath + '/' + f.name : f.name;
999
- const r = await fetch(`/api/files/${fpSessionId}/download?path=${encodeURIComponent(relPath)}`);
1000
- if (!r.ok) continue;
1001
- const blob = await r.blob();
1002
- const parts = f.name.split('/');
1003
- let dir = syncDirHandle;
1004
- for (let i = 0; i < parts.length - 1; i++) {
1005
- dir = await dir.getDirectoryHandle(parts[i], { create: true });
1006
- }
1007
- const fileHandle = await dir.getFileHandle(parts[parts.length - 1], { create: true });
1008
- const writable = await fileHandle.createWritable();
1009
- await writable.write(blob);
1010
- await writable.close();
1011
- } catch (e) { console.error(`同步下载失败 ${f.name}:`, e); }
1012
- }
1013
- progress.textContent = `已下载 ${done} 个文件到本地`;
1014
- setTimeout(() => { progress.style.display = 'none'; }, 3000);
1015
- await fpComputeDiff();
1016
- }
1017
-
1018
- async function fpSyncToServer() {
1019
- const { toUpload } = syncDiff;
1020
- if (!toUpload.length) return;
1021
- const progress = document.getElementById('fp-progress');
1022
- progress.style.display = 'block';
1023
- const BATCH = 20;
1024
- let done = 0;
1025
- for (let i = 0; i < toUpload.length; i += BATCH) {
1026
- const batch = toUpload.slice(i, i + BATCH);
1027
- progress.textContent = `上传中 ${done}/${toUpload.length}...`;
1028
- const form = new FormData();
1029
- for (const f of batch) {
1030
- try { form.append('files', await f.handle.getFile(), f.name); }
1031
- catch (e) { console.error(`读取本地文件失败 ${f.name}:`, e); }
1032
- }
1033
- try {
1034
- await fetch(`/api/files/${fpSessionId}/upload?path=${encodeURIComponent(fpCurrentPath)}`, {
1035
- method: 'POST', body: form,
1036
- });
1037
- } catch (e) { console.error('上传批次失败:', e); }
1038
- done += batch.length;
1039
- }
1040
- progress.textContent = `已上传 ${done} 个文件到服务器`;
1041
- setTimeout(() => { progress.style.display = 'none'; }, 3000);
1042
- await fpComputeDiff();
1043
- }
1044
-
1045
- // 监听文件变更通知
1046
- function setupFileChangeListener() {
1047
- const tab = tabs[0];
1048
- if (!tab) return;
1049
- tab.socket.on('files:changed', ({ sessionId }) => {
1050
- if (sessionId === fpSessionId && document.getElementById('file-panel').classList.contains('open')) {
1051
- if (syncMode) fpComputeDiff();
1052
- else loadFiles();
1053
- }
1054
- });
1055
- }
1056
-
1057
- // ── 初始化:打开 URL 中指定的会话 ──
1058
- const initTab = createTab(INIT_SESSION_ID);
1059
- switchTab(initTab.id);
1060
- setupFileChangeListener();
1061
- </script>
1062
- </body>
1063
- </html>
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, maximum-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <link rel="manifest" href="/manifest.json">
10
+ <link rel="apple-touch-icon" href="/icon.png">
11
+ <title>myhi 终端</title>
12
+ <script src="/lib/machine.js"></script>
13
+ <link rel="stylesheet" href="/lib/xterm/css/xterm.css">
14
+ <script src="/lib/xterm/lib/xterm.js"></script>
15
+ <script src="/lib/xterm-fit/lib/xterm-addon-fit.js"></script>
16
+ <script src="/lib/xterm-links/lib/xterm-addon-web-links.js"></script>
17
+ <script src="/sio-client/socket.io.min.js"></script>
18
+ <style>
19
+ * { box-sizing: border-box; margin: 0; padding: 0; }
20
+
21
+ html, body {
22
+ height: 100%;
23
+ background: #0d1117;
24
+ color: #e6edf3;
25
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
26
+ overflow: hidden;
27
+ }
28
+
29
+ body {
30
+ display: flex;
31
+ flex-direction: column;
32
+ }
33
+
34
+ #top-bar {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ padding: 0.5rem 0.75rem;
39
+ background: #161b22;
40
+ border-bottom: 1px solid #30363d;
41
+ flex-shrink: 0;
42
+ gap: 0.5rem;
43
+ padding-top: max(0.5rem, env(safe-area-inset-top));
44
+ }
45
+
46
+ #session-title {
47
+ font-size: 0.9rem;
48
+ font-weight: 600;
49
+ color: #58a6ff;
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ flex: 1;
54
+ }
55
+
56
+ #viewer-count {
57
+ font-size: 0.75rem;
58
+ color: #8b949e;
59
+ white-space: nowrap;
60
+ }
61
+
62
+ .top-btn {
63
+ background: #21262d;
64
+ color: #e6edf3;
65
+ border: 1px solid #30363d;
66
+ border-radius: 6px;
67
+ padding: 0.3rem 0.6rem;
68
+ font-size: 0.75rem;
69
+ cursor: pointer;
70
+ white-space: nowrap;
71
+ }
72
+ .top-btn:hover { background: #30363d; }
73
+
74
+ /* ── Tab bar ── */
75
+ #tab-bar {
76
+ display: flex;
77
+ align-items: center;
78
+ background: #0d1117;
79
+ border-bottom: 1px solid #30363d;
80
+ flex-shrink: 0;
81
+ overflow-x: auto;
82
+ scrollbar-width: none;
83
+ -webkit-overflow-scrolling: touch;
84
+ }
85
+ #tab-bar::-webkit-scrollbar { display: none; }
86
+
87
+ .tab {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.4rem;
91
+ padding: 0.4rem 0.75rem;
92
+ font-size: 0.78rem;
93
+ color: #8b949e;
94
+ background: transparent;
95
+ border: none;
96
+ border-right: 1px solid #21262d;
97
+ cursor: pointer;
98
+ white-space: nowrap;
99
+ flex-shrink: 0;
100
+ max-width: 160px;
101
+ overflow: hidden;
102
+ text-overflow: ellipsis;
103
+ position: relative;
104
+ }
105
+ .tab:hover { color: #e6edf3; background: #161b22; }
106
+ .tab.active {
107
+ color: #e6edf3;
108
+ background: #161b22;
109
+ border-bottom: 2px solid #7c3aed;
110
+ }
111
+ .tab .tab-close {
112
+ font-size: 0.7rem;
113
+ color: #6e7681;
114
+ border: none;
115
+ background: none;
116
+ cursor: pointer;
117
+ padding: 0 0.15rem;
118
+ border-radius: 3px;
119
+ line-height: 1;
120
+ }
121
+ .tab .tab-close:hover { color: #f85149; background: #21262d; }
122
+ .tab .tab-dot {
123
+ width: 6px; height: 6px;
124
+ border-radius: 50%;
125
+ background: #3fb950;
126
+ flex-shrink: 0;
127
+ }
128
+ .tab .tab-dot.dead { background: #f85149; }
129
+
130
+ #tab-add {
131
+ background: none;
132
+ border: none;
133
+ color: #6e7681;
134
+ font-size: 1.1rem;
135
+ padding: 0.3rem 0.6rem;
136
+ cursor: pointer;
137
+ flex-shrink: 0;
138
+ }
139
+ #tab-add:hover { color: #e6edf3; }
140
+
141
+ /* ── Terminal area ── */
142
+ #terminal-wrapper {
143
+ flex: 1;
144
+ overflow: hidden;
145
+ position: relative;
146
+ }
147
+
148
+ .term-pane {
149
+ position: absolute;
150
+ inset: 0;
151
+ padding: 4px;
152
+ display: none;
153
+ }
154
+ .term-pane.active { display: block; }
155
+
156
+ /* Mobile shortcut bar */
157
+ #shortcut-bar {
158
+ display: flex;
159
+ align-items: center;
160
+ background: #161b22;
161
+ border-top: 1px solid #30363d;
162
+ padding: 0.4rem 0.5rem;
163
+ padding-bottom: max(0.4rem, env(safe-area-inset-bottom));
164
+ gap: 0.4rem;
165
+ overflow-x: auto;
166
+ flex-shrink: 0;
167
+ -webkit-overflow-scrolling: touch;
168
+ scrollbar-width: none;
169
+ }
170
+ #shortcut-bar::-webkit-scrollbar { display: none; }
171
+
172
+ .sk {
173
+ background: #21262d;
174
+ color: #e6edf3;
175
+ border: 1px solid #30363d;
176
+ border-radius: 6px;
177
+ padding: 0.35rem 0.65rem;
178
+ font-size: 0.8rem;
179
+ font-family: 'SF Mono', 'Consolas', monospace;
180
+ cursor: pointer;
181
+ white-space: nowrap;
182
+ flex-shrink: 0;
183
+ user-select: none;
184
+ -webkit-user-select: none;
185
+ touch-action: manipulation;
186
+ }
187
+ .sk:active { background: #30363d; transform: scale(0.95); }
188
+
189
+ /* QR modal */
190
+ #qr-modal {
191
+ display: none;
192
+ position: fixed;
193
+ inset: 0;
194
+ background: rgba(0,0,0,0.7);
195
+ z-index: 100;
196
+ align-items: center;
197
+ justify-content: center;
198
+ }
199
+ #qr-modal.open { display: flex; }
200
+ #qr-box {
201
+ background: #161b22;
202
+ border: 1px solid #30363d;
203
+ border-radius: 12px;
204
+ padding: 1.5rem;
205
+ text-align: center;
206
+ max-width: 320px;
207
+ width: 90%;
208
+ }
209
+ #qr-box h3 { margin-bottom: 1rem; font-size: 0.95rem; }
210
+ #qr-box img { width: 100%; max-width: 240px; border-radius: 8px; background: #fff; padding: 8px; }
211
+ #qr-box .close-btn {
212
+ margin-top: 1rem;
213
+ background: #21262d;
214
+ color: #e6edf3;
215
+ border: 1px solid #30363d;
216
+ border-radius: 6px;
217
+ padding: 0.5rem 1.5rem;
218
+ cursor: pointer;
219
+ font-size: 0.9rem;
220
+ }
221
+
222
+ #status-overlay {
223
+ position: absolute;
224
+ inset: 0;
225
+ display: flex;
226
+ flex-direction: column;
227
+ align-items: center;
228
+ justify-content: center;
229
+ background: rgba(13,17,23,0.85);
230
+ color: #8b949e;
231
+ font-size: 0.9rem;
232
+ z-index: 10;
233
+ }
234
+ #status-overlay.hidden { display: none; }
235
+
236
+ /* ── 文件管理面板 ── */
237
+ #file-panel-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 59; display: none; }
238
+ #file-panel-backdrop.open { display: block; }
239
+ #file-panel {
240
+ position: fixed; top: 0; right: -340px; width: 320px; max-width: 90vw; height: 100%;
241
+ background: #161b22; border-left: 1px solid #30363d; z-index: 60;
242
+ transition: right 0.25s ease; display: flex; flex-direction: column;
243
+ }
244
+ #file-panel.open { right: 0; }
245
+ .fp-header { display: flex; align-items: center; padding: 0.75rem; border-bottom: 1px solid #30363d; gap: 0.5rem; flex-shrink: 0; }
246
+ .fp-header h3 { flex: 1; font-size: 0.9rem; margin: 0; color: #e6edf3; }
247
+ .fp-btn { background: #21262d; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.75rem; cursor: pointer; }
248
+ .fp-btn:active { background: #30363d; }
249
+ .fp-path { padding: 0.4rem 0.75rem; font-size: 0.7rem; color: #8b949e; border-bottom: 1px solid #21262d; word-break: break-all; flex-shrink: 0; }
250
+ .fp-list { flex: 1; overflow-y: auto; padding: 0.25rem 0; }
251
+ .fp-item { display: flex; align-items: center; padding: 0.5rem 0.75rem; gap: 0.5rem; cursor: pointer; font-size: 0.82rem; color: #e6edf3; }
252
+ .fp-item:active { background: #21262d; }
253
+ .fp-item .fp-icon { width: 1.2rem; text-align: center; flex-shrink: 0; }
254
+ .fp-item .fp-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
255
+ .fp-item .fp-size { color: #8b949e; font-size: 0.7rem; white-space: nowrap; }
256
+ .fp-item .fp-dl { color: #58a6ff; font-size: 0.7rem; text-decoration: none; padding: 0.2rem 0.4rem; }
257
+ .fp-empty { text-align: center; color: #8b949e; padding: 2rem 0.75rem; font-size: 0.85rem; }
258
+ .fp-drop-zone {
259
+ margin: 0.5rem 0.75rem; padding: 1.5rem; border: 2px dashed #30363d; border-radius: 8px;
260
+ text-align: center; color: #8b949e; font-size: 0.8rem; flex-shrink: 0;
261
+ transition: border-color 0.2s, background 0.2s;
262
+ }
263
+ .fp-drop-zone.dragover { border-color: #58a6ff; background: rgba(88,166,255,0.08); }
264
+ .fp-progress { padding: 0.4rem 0.75rem; font-size: 0.75rem; color: #d29922; flex-shrink: 0; display: none; }
265
+ .fp-sync-bar { display: flex; gap: 0.4rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; flex-shrink: 0; align-items: center; flex-wrap: wrap; }
266
+ .fp-sync-bar .fp-btn { font-size: 0.7rem; padding: 0.25rem 0.5rem; }
267
+ .fp-sync-bar .sync-dir { font-size: 0.68rem; color: #8b949e; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
268
+ .fp-diff-item { display: flex; align-items: center; padding: 0.4rem 0.75rem; gap: 0.4rem; font-size: 0.78rem; color: #e6edf3; }
269
+ .fp-diff-item .badge { font-size: 0.65rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; flex-shrink: 0; }
270
+ .badge-new { background: #238636; color: #fff; }
271
+ .badge-changed { background: #9e6a03; color: #fff; }
272
+ .badge-arrow { color: #8b949e; flex-shrink: 0; font-size: 0.7rem; }
273
+ .fp-diff-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
274
+ .fp-diff-summary { padding: 0.75rem; text-align: center; color: #8b949e; font-size: 0.8rem; }
275
+ .fp-diff-actions { display: flex; gap: 0.5rem; padding: 0.5rem 0.75rem; flex-shrink: 0; }
276
+ .fp-diff-actions .fp-btn { flex: 1; text-align: center; padding: 0.45rem; }
277
+ .fp-btn-primary { background: #238636; border-color: #2ea04380; }
278
+ .fp-btn-primary:active { background: #2ea043; }
279
+ .fp-btn-warn { background: #9e6a03; border-color: #d2992280; }
280
+ .fp-btn-warn:active { background: #bb8009; }
281
+ </style>
282
+ </head>
283
+ <body>
284
+
285
+ <div id="top-bar">
286
+ <div id="session-title">加载中...</div>
287
+ <span id="viewer-count"></span>
288
+ <button class="top-btn" onclick="openFilePanel()">文件</button>
289
+ <button class="top-btn" onclick="showQR()">扫码分享</button>
290
+ <button class="top-btn" onclick="goBack()">← 返回</button>
291
+ <button class="top-btn" id="logout-btn" onclick="doLogout()" style="display:none;color:#f0883e">退出</button>
292
+ </div>
293
+
294
+ <!-- Tab bar -->
295
+ <div id="tab-bar">
296
+ <button id="tab-add" onclick="addTab()" title="新建标签页">+</button>
297
+ </div>
298
+
299
+ <div id="terminal-wrapper"></div>
300
+
301
+ <!-- Mobile shortcut bar -->
302
+ <div id="shortcut-bar">
303
+ <button class="sk" id="btn-photo" onclick="openCamera()" style="color:#7c3aed;border-color:#7c3aed33;background:#7c3aed11">📷</button>
304
+ <button class="sk" data-send="ctrl-c">Ctrl+C</button>
305
+ <button class="sk" data-send="ctrl-d">Ctrl+D</button>
306
+ <button class="sk" data-send="tab">Tab</button>
307
+ <button class="sk" data-send="up">↑</button>
308
+ <button class="sk" data-send="down">↓</button>
309
+ <button class="sk" data-send="right">→</button>
310
+ <button class="sk" data-send="left">←</button>
311
+ <button class="sk" data-send="esc">Esc</button>
312
+ <button class="sk" data-send="ctrl-a">Ctrl+A</button>
313
+ <button class="sk" data-send="ctrl-e">Ctrl+E</button>
314
+ <button class="sk" data-send="ctrl-k">Ctrl+K</button>
315
+ <button class="sk" data-send="ctrl-u">Ctrl+U</button>
316
+ <button class="sk" data-send="ctrl-r">Ctrl+R</button>
317
+ <button class="sk" data-send="alt-.">Alt+.</button>
318
+ </div>
319
+
320
+ <!-- 文件管理面板 -->
321
+ <div id="file-panel-backdrop" onclick="closeFilePanel()"></div>
322
+ <div id="file-panel">
323
+ <div class="fp-header">
324
+ <h3 id="fp-title">文件管理</h3>
325
+ <button class="fp-btn" onclick="fpUploadClick()">上传</button>
326
+ <button class="fp-btn" onclick="fpStartSync()">同步</button>
327
+ <button class="fp-btn" onclick="closeFilePanel()">关闭</button>
328
+ </div>
329
+ <div class="fp-sync-bar" id="fp-sync-bar" style="display:none">
330
+ <span class="sync-dir" id="fp-sync-dir"></span>
331
+ <button class="fp-btn" onclick="fpPickDir()">换目录</button>
332
+ <button class="fp-btn" onclick="fpExitSync()">退出同步</button>
333
+ </div>
334
+ <div class="fp-path" id="fp-path"></div>
335
+ <div class="fp-list" id="fp-list"></div>
336
+ <div class="fp-diff-actions" id="fp-diff-actions" style="display:none">
337
+ <button class="fp-btn fp-btn-primary" onclick="fpSyncToLocal()">下载到本地</button>
338
+ <button class="fp-btn fp-btn-warn" onclick="fpSyncToServer()">上传到服务器</button>
339
+ </div>
340
+ <div class="fp-progress" id="fp-progress"></div>
341
+ <div class="fp-drop-zone" id="fp-drop">拖拽文件到此处上传,或点击上方按钮</div>
342
+ <input type="file" id="fp-file-input" multiple style="display:none">
343
+ </div>
344
+
345
+ <!-- QR modal -->
346
+ <div id="qr-modal">
347
+ <div id="qr-box">
348
+ <h3>扫码在其他设备打开此终端</h3>
349
+ <img id="qr-img" src="" alt="QR Code">
350
+ <br>
351
+ <button class="close-btn" onclick="closeQR()">关闭</button>
352
+ </div>
353
+ </div>
354
+
355
+ <script>
356
+ // 加载用户信息(独占模式显示退出按钮)
357
+ fetch('/api/me').then(r => r.json()).then(data => {
358
+ if (data.exclusive || data.hasUsers) document.getElementById('logout-btn').style.display = '';
359
+ }).catch(() => {});
360
+ function doLogout() {
361
+ if (!confirm('确定退出登录?')) return;
362
+ try { for (const t of Object.values(tabs)) t.socket?.disconnect(); } catch {}
363
+ fetch('/logout', { method: 'POST' }).then(() => { location.href = '/login'; });
364
+ }
365
+
366
+ // ── Tab 管理 ─────────────────────────────────────────────
367
+ const TERM_THEME = {
368
+ background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff',
369
+ selectionBackground: '#264f78',
370
+ black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
371
+ blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#e6edf3',
372
+ brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
373
+ brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
374
+ brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
375
+ };
376
+
377
+ const tabs = []; // { id, sessionId, title, term, fitAddon, socket, pane, tabEl, alive }
378
+ let activeTabId = null;
379
+
380
+ const tabBar = document.getElementById('tab-bar');
381
+ const tabAddBtn = document.getElementById('tab-add');
382
+ const wrapper = document.getElementById('terminal-wrapper');
383
+
384
+ // 从 URL 获取初始会话 ID
385
+ const pathParts = location.pathname.split('/');
386
+ const INIT_SESSION_ID = pathParts[pathParts.length - 1];
387
+
388
+ // ── 创建 Tab ──
389
+ function createTab(sessionId) {
390
+ const id = Date.now() + '-' + Math.random().toString(36).slice(2, 6);
391
+
392
+ // 创建终端容器
393
+ const pane = document.createElement('div');
394
+ pane.className = 'term-pane';
395
+ wrapper.appendChild(pane);
396
+
397
+ // 创建 xterm
398
+ const term = new Terminal({
399
+ theme: TERM_THEME,
400
+ fontFamily: "'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace",
401
+ fontSize: 14, lineHeight: 1.2, cursorBlink: true,
402
+ allowTransparency: false, scrollback: 5000,
403
+ });
404
+ const fitAddon = new FitAddon.FitAddon();
405
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
406
+ term.loadAddon(fitAddon);
407
+ term.loadAddon(webLinksAddon);
408
+ term.open(pane);
409
+
410
+ // 创建 Socket 连接
411
+ const socket = io({
412
+ transports: ['websocket', 'polling'],
413
+ reconnection: true,
414
+ reconnectionAttempts: Infinity,
415
+ reconnectionDelay: 1000,
416
+ reconnectionDelayMax: 10000,
417
+ timeout: 30000,
418
+ });
419
+
420
+ const tab = { id, sessionId, title: '连接中...', term, fitAddon, socket, pane, tabEl: null, alive: true };
421
+ tabs.push(tab);
422
+
423
+ // Tab 按钮
424
+ renderTab(tab);
425
+
426
+ // Socket 事件
427
+ socket.on('connect', () => {
428
+ socket.emit('join', sessionId);
429
+ });
430
+
431
+ socket.on('joined', (session) => {
432
+ tab.title = session.title || 'shell';
433
+ tab.alive = session.alive !== false;
434
+ renderTab(tab);
435
+ if (activeTabId === id) {
436
+ updateTopBar(tab);
437
+ fitTab(tab);
438
+ }
439
+ });
440
+
441
+ socket.on('output', (data) => {
442
+ term.write(data);
443
+ });
444
+
445
+ socket.on('kicked', ({ reason }) => { alert(reason || '你已被管理员踢出'); location.href = '/'; });
446
+
447
+ socket.on('session-exit', ({ code }) => {
448
+ tab.alive = false;
449
+ tab.title = (tab.title || 'shell') + ' (已退出)';
450
+ renderTab(tab);
451
+ if (activeTabId === id) updateTopBar(tab);
452
+ });
453
+
454
+ socket.on('sessions', (sessions) => {
455
+ const s = sessions.find(x => x.id === sessionId);
456
+ if (s && activeTabId === id) {
457
+ document.getElementById('viewer-count').textContent = s.viewers > 1 ? `👁 ${s.viewers}` : '';
458
+ }
459
+ });
460
+
461
+ socket.on('disconnect', () => {
462
+ // 显示断连提示
463
+ term.write('
464
+ [myhi] 连接已断开,正在重连...
465
+ ');
466
+ });
467
+
468
+ // 终端输入
469
+ term.onData((data) => {
470
+ socket.emit('input', data);
471
+ });
472
+
473
+ // 点击终端获取焦点
474
+ pane.addEventListener('click', () => term.focus());
475
+
476
+ return tab;
477
+ }
478
+
479
+ // ── 渲染 Tab 按钮 ──
480
+ function renderTab(tab) {
481
+ if (tab.tabEl) tab.tabEl.remove();
482
+
483
+ const el = document.createElement('div');
484
+ el.className = 'tab' + (activeTabId === tab.id ? ' active' : '');
485
+ el.innerHTML = `<span class="tab-dot${tab.alive ? '' : ' dead'}"></span>` +
486
+ `<span style="overflow:hidden;text-overflow:ellipsis">${esc(tab.title)}</span>` +
487
+ `<button class="tab-close" title="关闭">&times;</button>`;
488
+
489
+ el.addEventListener('click', (e) => {
490
+ if (e.target.classList.contains('tab-close')) {
491
+ closeTab(tab.id);
492
+ } else {
493
+ switchTab(tab.id);
494
+ }
495
+ });
496
+
497
+ tab.tabEl = el;
498
+ tabBar.insertBefore(el, tabAddBtn);
499
+ }
500
+
501
+ // ── 切换 Tab ──
502
+ function switchTab(id) {
503
+ activeTabId = id;
504
+ tabs.forEach(t => {
505
+ const isActive = t.id === id;
506
+ t.pane.classList.toggle('active', isActive);
507
+ if (t.tabEl) t.tabEl.classList.toggle('active', isActive);
508
+ });
509
+ const tab = tabs.find(t => t.id === id);
510
+ if (tab) {
511
+ updateTopBar(tab);
512
+ fitTab(tab);
513
+ tab.term.focus();
514
+ }
515
+ }
516
+
517
+ // ── 关闭 Tab ──
518
+ function closeTab(id) {
519
+ const idx = tabs.findIndex(t => t.id === id);
520
+ if (idx === -1) return;
521
+ const tab = tabs[idx];
522
+
523
+ // 断开连接
524
+ tab.socket.disconnect();
525
+ tab.term.dispose();
526
+ tab.pane.remove();
527
+ if (tab.tabEl) tab.tabEl.remove();
528
+ tabs.splice(idx, 1);
529
+
530
+ if (tabs.length === 0) {
531
+ window.location.href = '/';
532
+ return;
533
+ }
534
+
535
+ // 切换到相邻 tab
536
+ if (activeTabId === id) {
537
+ const next = tabs[Math.min(idx, tabs.length - 1)];
538
+ switchTab(next.id);
539
+ }
540
+ }
541
+
542
+ // ── 新建 Tab(弹出会话选择)──
543
+ async function addTab() {
544
+ try {
545
+ const res = await fetch('/api/sessions');
546
+ const sessions = await res.json();
547
+ const alive = sessions.filter(s => s.alive && s.mode !== 'agent');
548
+
549
+ if (alive.length === 0) {
550
+ alert('没有可用的终端会话,请先在首页创建');
551
+ return;
552
+ }
553
+
554
+ // 过滤掉已打开的会话
555
+ const opened = new Set(tabs.map(t => t.sessionId));
556
+ const available = alive.filter(s => !opened.has(s.id));
557
+
558
+ if (available.length === 0) {
559
+ alert('所有会话已在标签页中打开');
560
+ return;
561
+ }
562
+
563
+ if (available.length === 1) {
564
+ // 只有一个,直接打开
565
+ const tab = createTab(available[0].id);
566
+ switchTab(tab.id);
567
+ return;
568
+ }
569
+
570
+ // 多个可选:简单弹窗选择
571
+ const names = available.map((s, i) => `${i + 1}. ${s.title}`).join('\n');
572
+ const choice = prompt(`选择要打开的会话:\n\n${names}\n\n输入编号:`);
573
+ if (!choice) return;
574
+ const n = parseInt(choice, 10) - 1;
575
+ if (n >= 0 && n < available.length) {
576
+ const tab = createTab(available[n].id);
577
+ switchTab(tab.id);
578
+ }
579
+ } catch (e) {
580
+ alert('获取会话列表失败: ' + e.message);
581
+ }
582
+ }
583
+
584
+ // ── 辅助函数 ──
585
+ function fitTab(tab) {
586
+ requestAnimationFrame(() => {
587
+ try { tab.fitAddon.fit(); } catch {}
588
+ if (tab.socket.connected) {
589
+ tab.socket.emit('resize', { cols: tab.term.cols, rows: tab.term.rows });
590
+ }
591
+ });
592
+ }
593
+
594
+ function updateTopBar(tab) {
595
+ document.getElementById('session-title').textContent = tab.title;
596
+ document.title = tab.title + ' — myhi';
597
+ }
598
+
599
+ function esc(s) {
600
+ const d = document.createElement('div');
601
+ d.textContent = s;
602
+ return d.innerHTML;
603
+ }
604
+
605
+ // 窗口 resize 时 fit 当前 tab
606
+ window.addEventListener('resize', () => {
607
+ const tab = tabs.find(t => t.id === activeTabId);
608
+ if (tab) fitTab(tab);
609
+ });
610
+
611
+ // ── 移动端连接保活 ──────────────────────────────
612
+ document.addEventListener('visibilitychange', () => {
613
+ if (!document.hidden) {
614
+ for (const tab of tabs) {
615
+ if (!tab.socket.connected) tab.socket.connect();
616
+ else tab.socket.emit('join', tab.sessionId);
617
+ }
618
+ }
619
+ });
620
+ window.addEventListener('online', () => {
621
+ for (const tab of tabs) { if (!tab.socket.connected) tab.socket.connect(); }
622
+ });
623
+ setInterval(() => {
624
+ if (!document.hidden) {
625
+ for (const tab of tabs) { if (!tab.socket.connected) tab.socket.connect(); }
626
+ }
627
+ }, 15000);
628
+
629
+ // ── 快捷键发送到当前 tab ──
630
+ const SEQ = {
631
+ 'ctrl-c': '\x03', 'ctrl-d': '\x04', 'tab': '\t',
632
+ 'up': '\x1b[A', 'down': '\x1b[B', 'right': '\x1b[C', 'left': '\x1b[D',
633
+ 'esc': '\x1b',
634
+ 'ctrl-a': '\x01', 'ctrl-e': '\x05', 'ctrl-k': '\x0b',
635
+ 'ctrl-u': '\x15', 'ctrl-r': '\x12', 'alt-.': '\x1b.',
636
+ };
637
+
638
+ document.querySelectorAll('.sk').forEach(btn => {
639
+ const send = () => {
640
+ const tab = tabs.find(t => t.id === activeTabId);
641
+ if (!tab) return;
642
+ const seq = SEQ[btn.dataset.send] || btn.dataset.send;
643
+ tab.socket.emit('input', seq);
644
+ tab.term.focus();
645
+ };
646
+ btn.addEventListener('touchend', (e) => { e.preventDefault(); send(); });
647
+ btn.addEventListener('click', send);
648
+ });
649
+
650
+ // ── QR modal ──
651
+ function showQR() {
652
+ const tab = tabs.find(t => t.id === activeTabId);
653
+ if (!tab) return;
654
+ document.getElementById('qr-img').src = `/qr/${tab.sessionId}`;
655
+ document.getElementById('qr-modal').classList.add('open');
656
+ }
657
+ function closeQR() {
658
+ document.getElementById('qr-modal').classList.remove('open');
659
+ }
660
+ document.getElementById('qr-modal').addEventListener('click', (e) => {
661
+ if (e.target === e.currentTarget) closeQR();
662
+ });
663
+
664
+ function goBack() { window.location.href = '/'; }
665
+
666
+ // ── Photo upload ──
667
+ const MAX_PX = 1920, QUALITY = 0.85;
668
+
669
+ function compressImage(file) {
670
+ return new Promise((resolve, reject) => {
671
+ const img = new Image();
672
+ const url = URL.createObjectURL(file);
673
+ img.onload = () => {
674
+ URL.revokeObjectURL(url);
675
+ let { width: w, height: h } = img;
676
+ if (w > MAX_PX || h > MAX_PX) {
677
+ if (w >= h) { h = Math.round(h * MAX_PX / w); w = MAX_PX; }
678
+ else { w = Math.round(w * MAX_PX / h); h = MAX_PX; }
679
+ }
680
+ const canvas = document.createElement('canvas');
681
+ canvas.width = w; canvas.height = h;
682
+ canvas.getContext('2d').drawImage(img, 0, 0, w, h);
683
+ canvas.toBlob(
684
+ blob => blob ? resolve(blob) : reject(new Error('canvas toBlob failed')),
685
+ 'image/jpeg', QUALITY
686
+ );
687
+ };
688
+ img.onerror = reject;
689
+ img.src = url;
690
+ });
691
+ }
692
+
693
+ function openCamera() {
694
+ const input = document.createElement('input');
695
+ input.type = 'file';
696
+ input.accept = 'image/*';
697
+ input.onchange = async () => {
698
+ const file = input.files?.[0];
699
+ if (!file) return;
700
+ const tab = tabs.find(t => t.id === activeTabId);
701
+ if (!tab) return;
702
+
703
+ const btn = document.getElementById('btn-photo');
704
+ btn.textContent = '⏳';
705
+ btn.disabled = true;
706
+
707
+ try {
708
+ const compressed = await compressImage(file);
709
+ const form = new FormData();
710
+ form.append('image', compressed, 'photo.jpg');
711
+ const res = await fetch('/upload?sessionId=' + tab.sessionId, { method: 'POST', body: form });
712
+ const data = await res.json();
713
+ if (data.path) {
714
+ tab.socket.emit('input', data.path);
715
+ tab.term.focus();
716
+ } else {
717
+ alert('上传失败: ' + (data.error || '未知'));
718
+ }
719
+ } catch (e) {
720
+ alert('出错: ' + e.message);
721
+ } finally {
722
+ btn.textContent = '📷';
723
+ btn.disabled = false;
724
+ }
725
+ };
726
+ input.click();
727
+ }
728
+
729
+ // ── 文件管理 ──────────────────────────────────────
730
+ let fpCurrentPath = '';
731
+ let fpSessionId = null;
732
+
733
+ function openFilePanel() {
734
+ const tab = tabs.find(t => t.id === activeTabId);
735
+ if (!tab) return;
736
+ fpSessionId = tab.sessionId;
737
+ fpCurrentPath = '';
738
+ loadFiles();
739
+ document.getElementById('file-panel').classList.add('open');
740
+ document.getElementById('file-panel-backdrop').classList.add('open');
741
+ }
742
+ function closeFilePanel() {
743
+ document.getElementById('file-panel').classList.remove('open');
744
+ document.getElementById('file-panel-backdrop').classList.remove('open');
745
+ }
746
+
747
+ async function loadFiles() {
748
+ if (!fpSessionId) return;
749
+ const list = document.getElementById('fp-list');
750
+ list.innerHTML = '<div class="fp-empty">加载中...</div>';
751
+ try {
752
+ const r = await fetch(`/api/files/${fpSessionId}?path=${encodeURIComponent(fpCurrentPath)}`);
753
+ const data = await r.json();
754
+ if (!data.ok) { list.innerHTML = `<div class="fp-empty">${esc(data.error)}</div>`; return; }
755
+ document.getElementById('fp-path').textContent = data.cwd + (data.path ? '/' + data.path : '');
756
+ if (!data.entries.length && data.parent == null) {
757
+ list.innerHTML = '<div class="fp-empty">空目录</div>';
758
+ return;
759
+ }
760
+ let html = '';
761
+ if (data.parent != null) {
762
+ html += `<div class="fp-item" onclick="fpNav('${esc(data.parent)}')"><span class="fp-icon">📁</span><span class="fp-name">..</span></div>`;
763
+ }
764
+ for (const e of data.entries) {
765
+ const rel = fpCurrentPath ? fpCurrentPath + '/' + e.name : e.name;
766
+ if (e.isDir) {
767
+ html += `<div class="fp-item" onclick="fpNav('${esc(rel)}')"><span class="fp-icon">📁</span><span class="fp-name">${esc(e.name)}</span></div>`;
768
+ } else {
769
+ html += `<div class="fp-item">
770
+ <span class="fp-icon">📄</span>
771
+ <span class="fp-name">${esc(e.name)}</span>
772
+ <span class="fp-size">${formatSize(e.size)}</span>
773
+ <a class="fp-dl" href="/api/files/${fpSessionId}/download?path=${encodeURIComponent(rel)}" download title="下载">下载</a>
774
+ </div>`;
775
+ }
776
+ }
777
+ list.innerHTML = html;
778
+ } catch (e) {
779
+ list.innerHTML = `<div class="fp-empty">加载失败: ${esc(e.message)}</div>`;
780
+ }
781
+ }
782
+
783
+ function fpNav(path) { fpCurrentPath = path; loadFiles(); }
784
+
785
+ function formatSize(bytes) {
786
+ if (bytes == null) return '';
787
+ if (bytes < 1024) return bytes + ' B';
788
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
789
+ if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
790
+ return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
791
+ }
792
+
793
+ function fpUploadClick() { document.getElementById('fp-file-input').click(); }
794
+
795
+ document.getElementById('fp-file-input').addEventListener('change', (e) => {
796
+ if (e.target.files.length) fpUploadFiles(e.target.files);
797
+ e.target.value = '';
798
+ });
799
+
800
+ async function fpUploadFiles(files) {
801
+ if (!fpSessionId || !files.length) return;
802
+ const progress = document.getElementById('fp-progress');
803
+ progress.style.display = 'block';
804
+ progress.textContent = `正在上传 ${files.length} 个文件...`;
805
+ try {
806
+ const form = new FormData();
807
+ for (const f of files) form.append('files', f, f.webkitRelativePath || f.name);
808
+ const r = await fetch(`/api/files/${fpSessionId}/upload?path=${encodeURIComponent(fpCurrentPath)}`, {
809
+ method: 'POST', body: form,
810
+ });
811
+ const data = await r.json();
812
+ if (data.ok) {
813
+ progress.textContent = `已上传 ${data.files.length} 个文件`;
814
+ loadFiles();
815
+ } else {
816
+ progress.textContent = '上传失败: ' + (data.error || '未知错误');
817
+ }
818
+ } catch (e) {
819
+ progress.textContent = '上传出错: ' + e.message;
820
+ }
821
+ setTimeout(() => { progress.style.display = 'none'; }, 3000);
822
+ }
823
+
824
+ // 拖拽上传
825
+ const fpDrop = document.getElementById('fp-drop');
826
+ fpDrop.addEventListener('dragover', (e) => { e.preventDefault(); fpDrop.classList.add('dragover'); });
827
+ fpDrop.addEventListener('dragleave', () => fpDrop.classList.remove('dragover'));
828
+ fpDrop.addEventListener('drop', (e) => {
829
+ e.preventDefault();
830
+ fpDrop.classList.remove('dragover');
831
+ if (e.dataTransfer.files.length) fpUploadFiles(e.dataTransfer.files);
832
+ });
833
+
834
+ // ── 文件同步(File System Access API)──────────
835
+ let syncDirHandle = null;
836
+ let syncMode = false;
837
+ let syncDiff = { toDownload: [], toUpload: [] };
838
+
839
+ async function fpStartSync() {
840
+ if (!window.showDirectoryPicker) {
841
+ alert('当前浏览器不支持文件夹访问,请使用 Chrome 或 Edge');
842
+ return;
843
+ }
844
+ if (!syncDirHandle) {
845
+ await fpPickDir();
846
+ if (!syncDirHandle) return;
847
+ }
848
+ syncMode = true;
849
+ document.getElementById('fp-sync-bar').style.display = 'flex';
850
+ document.getElementById('fp-drop').style.display = 'none';
851
+ await fpComputeDiff();
852
+ }
853
+
854
+ async function fpPickDir() {
855
+ try {
856
+ syncDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
857
+ document.getElementById('fp-sync-dir').textContent = syncDirHandle.name;
858
+ if (syncMode) await fpComputeDiff();
859
+ } catch (e) {
860
+ if (e.name !== 'AbortError') alert('选择文件夹失败: ' + e.message);
861
+ }
862
+ }
863
+
864
+ function fpExitSync() {
865
+ syncMode = false;
866
+ document.getElementById('fp-sync-bar').style.display = 'none';
867
+ document.getElementById('fp-diff-actions').style.display = 'none';
868
+ document.getElementById('fp-drop').style.display = '';
869
+ loadFiles();
870
+ }
871
+
872
+ async function walkLocalDir(dirHandle, prefix) {
873
+ const files = [];
874
+ for await (const [name, handle] of dirHandle.entries()) {
875
+ if (name.startsWith('.')) continue;
876
+ const rel = prefix ? prefix + '/' + name : name;
877
+ if (handle.kind === 'directory') {
878
+ files.push(...await walkLocalDir(handle, rel));
879
+ } else {
880
+ const file = await handle.getFile();
881
+ files.push({ name: rel, size: file.size, handle });
882
+ }
883
+ }
884
+ return files;
885
+ }
886
+
887
+ async function hashFile(file) {
888
+ const buf = await file.arrayBuffer();
889
+ const hash = await crypto.subtle.digest('SHA-256', buf);
890
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
891
+ }
892
+
893
+ async function fpComputeDiff() {
894
+ if (!fpSessionId || !syncDirHandle) return;
895
+ const list = document.getElementById('fp-list');
896
+ list.innerHTML = '<div class="fp-empty">正在比对文件...</div>';
897
+ const progress = document.getElementById('fp-progress');
898
+ progress.style.display = 'block';
899
+
900
+ try {
901
+ progress.textContent = '获取服务器文件清单...';
902
+ const r = await fetch(`/api/files/${fpSessionId}/manifest?path=${encodeURIComponent(fpCurrentPath)}`);
903
+ const manifest = await r.json();
904
+ if (!manifest.ok) throw new Error(manifest.error);
905
+
906
+ progress.textContent = '扫描本地文件...';
907
+ const localFiles = await walkLocalDir(syncDirHandle, '');
908
+
909
+ const serverMap = new Map(manifest.files.map(f => [f.name, f]));
910
+ const localMap = new Map(localFiles.map(f => [f.name, f]));
911
+
912
+ const toDownload = [];
913
+ const toUpload = [];
914
+
915
+ let checked = 0;
916
+ for (const [name, sf] of serverMap) {
917
+ checked++;
918
+ if (checked % 20 === 0) progress.textContent = `比对中... ${checked}/${serverMap.size + localMap.size}`;
919
+ const lf = localMap.get(name);
920
+ if (!lf) {
921
+ toDownload.push({ name, size: sf.size, reason: 'new' });
922
+ } else if (lf.size !== sf.size) {
923
+ toDownload.push({ name, size: sf.size, reason: 'changed' });
924
+ } else if (sf.hash && sf.size < 10 * 1024 * 1024) {
925
+ const file = await lf.handle.getFile();
926
+ const localHash = await hashFile(file);
927
+ if (localHash !== sf.hash) toDownload.push({ name, size: sf.size, reason: 'changed' });
928
+ }
929
+ }
930
+ for (const [name, lf] of localMap) {
931
+ checked++;
932
+ if (checked % 20 === 0) progress.textContent = `比对中... ${checked}/${serverMap.size + localMap.size}`;
933
+ const sf = serverMap.get(name);
934
+ if (!sf) {
935
+ toUpload.push({ name, size: lf.size, reason: 'new', handle: lf.handle });
936
+ } else if (lf.size !== sf.size) {
937
+ toUpload.push({ name, size: lf.size, reason: 'changed', handle: lf.handle });
938
+ } else if (sf.hash && lf.size < 10 * 1024 * 1024) {
939
+ const file = await lf.handle.getFile();
940
+ const localHash = await hashFile(file);
941
+ if (localHash !== sf.hash) toUpload.push({ name, size: lf.size, reason: 'changed', handle: lf.handle });
942
+ }
943
+ }
944
+
945
+ syncDiff = { toDownload, toUpload };
946
+ fpRenderDiff();
947
+ } catch (e) {
948
+ list.innerHTML = `<div class="fp-empty">比对失败: ${esc(e.message)}</div>`;
949
+ }
950
+ progress.style.display = 'none';
951
+ }
952
+
953
+ function fpRenderDiff() {
954
+ const { toDownload, toUpload } = syncDiff;
955
+ const list = document.getElementById('fp-list');
956
+
957
+ if (!toDownload.length && !toUpload.length) {
958
+ list.innerHTML = '<div class="fp-diff-summary">文件已同步,无差异</div>';
959
+ document.getElementById('fp-diff-actions').style.display = 'none';
960
+ return;
961
+ }
962
+
963
+ let html = '<div class="fp-diff-summary">';
964
+ if (toDownload.length) html += `${toDownload.length} 个文件需下载到本地`;
965
+ if (toDownload.length && toUpload.length) html += ',';
966
+ if (toUpload.length) html += `${toUpload.length} 个文件需上传到服务器`;
967
+ html += '</div>';
968
+
969
+ for (const f of toDownload) {
970
+ html += `<div class="fp-diff-item">
971
+ <span class="badge ${f.reason === 'new' ? 'badge-new' : 'badge-changed'}">${f.reason === 'new' ? '新' : '改'}</span>
972
+ <span class="badge-arrow">↓</span>
973
+ <span class="fp-diff-name">${esc(f.name)}</span>
974
+ <span class="fp-size">${formatSize(f.size)}</span>
975
+ </div>`;
976
+ }
977
+ for (const f of toUpload) {
978
+ html += `<div class="fp-diff-item">
979
+ <span class="badge ${f.reason === 'new' ? 'badge-new' : 'badge-changed'}">${f.reason === 'new' ? '新' : '改'}</span>
980
+ <span class="badge-arrow">↑</span>
981
+ <span class="fp-diff-name">${esc(f.name)}</span>
982
+ <span class="fp-size">${formatSize(f.size)}</span>
983
+ </div>`;
984
+ }
985
+
986
+ list.innerHTML = html;
987
+ document.getElementById('fp-diff-actions').style.display = toDownload.length || toUpload.length ? 'flex' : 'none';
988
+ }
989
+
990
+ async function fpSyncToLocal() {
991
+ const { toDownload } = syncDiff;
992
+ if (!toDownload.length) return;
993
+ const progress = document.getElementById('fp-progress');
994
+ progress.style.display = 'block';
995
+ let done = 0;
996
+ for (const f of toDownload) {
997
+ progress.textContent = `下载中 ${++done}/${toDownload.length}: ${f.name}`;
998
+ try {
999
+ const relPath = fpCurrentPath ? fpCurrentPath + '/' + f.name : f.name;
1000
+ const r = await fetch(`/api/files/${fpSessionId}/download?path=${encodeURIComponent(relPath)}`);
1001
+ if (!r.ok) continue;
1002
+ const blob = await r.blob();
1003
+ const parts = f.name.split('/');
1004
+ let dir = syncDirHandle;
1005
+ for (let i = 0; i < parts.length - 1; i++) {
1006
+ dir = await dir.getDirectoryHandle(parts[i], { create: true });
1007
+ }
1008
+ const fileHandle = await dir.getFileHandle(parts[parts.length - 1], { create: true });
1009
+ const writable = await fileHandle.createWritable();
1010
+ await writable.write(blob);
1011
+ await writable.close();
1012
+ } catch (e) { console.error(`同步下载失败 ${f.name}:`, e); }
1013
+ }
1014
+ progress.textContent = `已下载 ${done} 个文件到本地`;
1015
+ setTimeout(() => { progress.style.display = 'none'; }, 3000);
1016
+ await fpComputeDiff();
1017
+ }
1018
+
1019
+ async function fpSyncToServer() {
1020
+ const { toUpload } = syncDiff;
1021
+ if (!toUpload.length) return;
1022
+ const progress = document.getElementById('fp-progress');
1023
+ progress.style.display = 'block';
1024
+ const BATCH = 20;
1025
+ let done = 0;
1026
+ for (let i = 0; i < toUpload.length; i += BATCH) {
1027
+ const batch = toUpload.slice(i, i + BATCH);
1028
+ progress.textContent = `上传中 ${done}/${toUpload.length}...`;
1029
+ const form = new FormData();
1030
+ for (const f of batch) {
1031
+ try { form.append('files', await f.handle.getFile(), f.name); }
1032
+ catch (e) { console.error(`读取本地文件失败 ${f.name}:`, e); }
1033
+ }
1034
+ try {
1035
+ await fetch(`/api/files/${fpSessionId}/upload?path=${encodeURIComponent(fpCurrentPath)}`, {
1036
+ method: 'POST', body: form,
1037
+ });
1038
+ } catch (e) { console.error('上传批次失败:', e); }
1039
+ done += batch.length;
1040
+ }
1041
+ progress.textContent = `已上传 ${done} 个文件到服务器`;
1042
+ setTimeout(() => { progress.style.display = 'none'; }, 3000);
1043
+ await fpComputeDiff();
1044
+ }
1045
+
1046
+ // 监听文件变更通知
1047
+ function setupFileChangeListener() {
1048
+ const tab = tabs[0];
1049
+ if (!tab) return;
1050
+ tab.socket.on('files:changed', ({ sessionId }) => {
1051
+ if (sessionId === fpSessionId && document.getElementById('file-panel').classList.contains('open')) {
1052
+ if (syncMode) fpComputeDiff();
1053
+ else loadFiles();
1054
+ }
1055
+ });
1056
+ }
1057
+
1058
+ // ── 初始化:打开 URL 中指定的会话 ──
1059
+ const initTab = createTab(INIT_SESSION_ID);
1060
+ switchTab(initTab.id);
1061
+ setupFileChangeListener();
1062
+ </script>
1063
+ </body>
1064
+ </html>