@wendongfly/myhi 1.0.30 → 1.0.31

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 (2) hide show
  1. package/dist/terminal.html +319 -143
  2. package/package.json +1 -1
@@ -25,6 +25,11 @@
25
25
  overflow: hidden;
26
26
  }
27
27
 
28
+ body {
29
+ display: flex;
30
+ flex-direction: column;
31
+ }
32
+
28
33
  #top-bar {
29
34
  display: flex;
30
35
  align-items: center;
@@ -65,16 +70,87 @@
65
70
  }
66
71
  .top-btn:hover { background: #30363d; }
67
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 ── */
68
141
  #terminal-wrapper {
69
142
  flex: 1;
70
143
  overflow: hidden;
71
- padding: 4px;
144
+ position: relative;
72
145
  }
73
146
 
74
- #terminal {
75
- width: 100%;
76
- height: 100%;
147
+ .term-pane {
148
+ position: absolute;
149
+ inset: 0;
150
+ padding: 4px;
151
+ display: none;
77
152
  }
153
+ .term-pane.active { display: block; }
78
154
 
79
155
  /* Mobile shortcut bar */
80
156
  #shortcut-bar {
@@ -109,12 +185,6 @@
109
185
  }
110
186
  .sk:active { background: #30363d; transform: scale(0.95); }
111
187
 
112
- /* Layout */
113
- body {
114
- display: flex;
115
- flex-direction: column;
116
- }
117
-
118
188
  /* QR modal */
119
189
  #qr-modal {
120
190
  display: none;
@@ -173,11 +243,13 @@
173
243
  <button class="top-btn" id="logout-btn" onclick="doLogout()" style="display:none;color:#f0883e">退出</button>
174
244
  </div>
175
245
 
176
- <div id="terminal-wrapper">
177
- <div id="terminal"></div>
178
- <div id="status-overlay">连接中...</div>
246
+ <!-- Tab bar -->
247
+ <div id="tab-bar">
248
+ <button id="tab-add" onclick="addTab()" title="新建标签页">+</button>
179
249
  </div>
180
250
 
251
+ <div id="terminal-wrapper"></div>
252
+
181
253
  <!-- Mobile shortcut bar -->
182
254
  <div id="shortcut-bar">
183
255
  <button class="sk" id="btn-photo" onclick="openCamera()" style="color:#7c3aed;border-color:#7c3aed33;background:#7c3aed11">📷</button>
@@ -217,127 +289,240 @@
217
289
  fetch('/logout', { method: 'POST' }).then(() => { location.href = '/login'; });
218
290
  }
219
291
 
220
- // Parse URL params
292
+ // ── Tab 管理 ─────────────────────────────────────────────
293
+ const TERM_THEME = {
294
+ background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff',
295
+ selectionBackground: '#264f78',
296
+ black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
297
+ blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#e6edf3',
298
+ brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
299
+ brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
300
+ brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
301
+ };
302
+
303
+ const tabs = []; // { id, sessionId, title, term, fitAddon, socket, pane, tabEl, alive }
304
+ let activeTabId = null;
305
+
306
+ const tabBar = document.getElementById('tab-bar');
307
+ const tabAddBtn = document.getElementById('tab-add');
308
+ const wrapper = document.getElementById('terminal-wrapper');
309
+
310
+ // 从 URL 获取初始会话 ID
221
311
  const pathParts = location.pathname.split('/');
222
- const SESSION_ID = pathParts[pathParts.length - 1];
223
-
224
- // ── xterm.js setup ─────────────────────────────────────────
225
- const term = new Terminal({
226
- theme: {
227
- background: '#0d1117',
228
- foreground: '#e6edf3',
229
- cursor: '#58a6ff',
230
- selectionBackground: '#264f78',
231
- black: '#0d1117',
232
- red: '#ff7b72',
233
- green: '#3fb950',
234
- yellow: '#d29922',
235
- blue: '#58a6ff',
236
- magenta: '#bc8cff',
237
- cyan: '#39c5cf',
238
- white: '#e6edf3',
239
- brightBlack: '#6e7681',
240
- brightRed: '#ffa198',
241
- brightGreen: '#56d364',
242
- brightYellow: '#e3b341',
243
- brightBlue: '#79c0ff',
244
- brightMagenta: '#d2a8ff',
245
- brightCyan: '#56d4dd',
246
- brightWhite: '#f0f6fc',
247
- },
248
- fontFamily: "'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace",
249
- fontSize: 14,
250
- lineHeight: 1.2,
251
- cursorBlink: true,
252
- allowTransparency: false,
253
- scrollback: 5000,
254
- });
312
+ const INIT_SESSION_ID = pathParts[pathParts.length - 1];
313
+
314
+ // ── 创建 Tab ──
315
+ function createTab(sessionId) {
316
+ const id = Date.now() + '-' + Math.random().toString(36).slice(2, 6);
317
+
318
+ // 创建终端容器
319
+ const pane = document.createElement('div');
320
+ pane.className = 'term-pane';
321
+ wrapper.appendChild(pane);
322
+
323
+ // 创建 xterm
324
+ const term = new Terminal({
325
+ theme: TERM_THEME,
326
+ fontFamily: "'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace",
327
+ fontSize: 14, lineHeight: 1.2, cursorBlink: true,
328
+ allowTransparency: false, scrollback: 5000,
329
+ });
330
+ const fitAddon = new FitAddon.FitAddon();
331
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
332
+ term.loadAddon(fitAddon);
333
+ term.loadAddon(webLinksAddon);
334
+ term.open(pane);
335
+
336
+ // 创建 Socket 连接
337
+ const socket = io({ transports: ['websocket'] });
338
+
339
+ const tab = { id, sessionId, title: '连接中...', term, fitAddon, socket, pane, tabEl: null, alive: true };
340
+ tabs.push(tab);
341
+
342
+ // Tab 按钮
343
+ renderTab(tab);
344
+
345
+ // Socket 事件
346
+ socket.on('connect', () => {
347
+ socket.emit('join', sessionId);
348
+ });
349
+
350
+ socket.on('joined', (session) => {
351
+ tab.title = session.title || 'shell';
352
+ tab.alive = session.alive !== false;
353
+ renderTab(tab);
354
+ if (activeTabId === id) {
355
+ updateTopBar(tab);
356
+ fitTab(tab);
357
+ }
358
+ });
359
+
360
+ socket.on('output', (data) => {
361
+ term.write(data);
362
+ });
363
+
364
+ socket.on('session-exit', ({ code }) => {
365
+ tab.alive = false;
366
+ tab.title = (tab.title || 'shell') + ' (已退出)';
367
+ renderTab(tab);
368
+ if (activeTabId === id) updateTopBar(tab);
369
+ });
370
+
371
+ socket.on('sessions', (sessions) => {
372
+ const s = sessions.find(x => x.id === sessionId);
373
+ if (s && activeTabId === id) {
374
+ document.getElementById('viewer-count').textContent = s.viewers > 1 ? `👁 ${s.viewers}` : '';
375
+ }
376
+ });
377
+
378
+ socket.on('disconnect', () => {
379
+ // 自动重连由 socket.io 处理
380
+ });
381
+
382
+ // 终端输入
383
+ term.onData((data) => {
384
+ socket.emit('input', data);
385
+ });
386
+
387
+ // 点击终端获取焦点
388
+ pane.addEventListener('click', () => term.focus());
255
389
 
256
- const fitAddon = new FitAddon.FitAddon();
257
- const webLinksAddon = new WebLinksAddon.WebLinksAddon();
258
- term.loadAddon(fitAddon);
259
- term.loadAddon(webLinksAddon);
260
- term.open(document.getElementById('terminal'));
390
+ return tab;
391
+ }
392
+
393
+ // ── 渲染 Tab 按钮 ──
394
+ function renderTab(tab) {
395
+ if (tab.tabEl) tab.tabEl.remove();
396
+
397
+ const el = document.createElement('div');
398
+ el.className = 'tab' + (activeTabId === tab.id ? ' active' : '');
399
+ el.innerHTML = `<span class="tab-dot${tab.alive ? '' : ' dead'}"></span>` +
400
+ `<span style="overflow:hidden;text-overflow:ellipsis">${esc(tab.title)}</span>` +
401
+ `<button class="tab-close" title="关闭">&times;</button>`;
402
+
403
+ el.addEventListener('click', (e) => {
404
+ if (e.target.classList.contains('tab-close')) {
405
+ closeTab(tab.id);
406
+ } else {
407
+ switchTab(tab.id);
408
+ }
409
+ });
261
410
 
262
- function fitTerm() {
263
- try { fitAddon.fit(); } catch {}
264
- if (typeof socket !== 'undefined' && socket.connected && currentSession) {
265
- socket.emit('resize', { cols: term.cols, rows: term.rows });
411
+ tab.tabEl = el;
412
+ tabBar.insertBefore(el, tabAddBtn);
413
+ }
414
+
415
+ // ── 切换 Tab ──
416
+ function switchTab(id) {
417
+ activeTabId = id;
418
+ tabs.forEach(t => {
419
+ const isActive = t.id === id;
420
+ t.pane.classList.toggle('active', isActive);
421
+ if (t.tabEl) t.tabEl.classList.toggle('active', isActive);
422
+ });
423
+ const tab = tabs.find(t => t.id === id);
424
+ if (tab) {
425
+ updateTopBar(tab);
426
+ fitTab(tab);
427
+ tab.term.focus();
266
428
  }
267
429
  }
268
430
 
269
- // Defer initial fit until after layout, socket setup, and joined
270
- window.addEventListener('resize', fitTerm);
271
-
272
- // ── Socket.IO ──────────────────────────────────────────────
273
- const socket = io({ transports: ['websocket'] });
274
- let currentSession = null;
275
- const overlay = document.getElementById('status-overlay');
276
-
277
- function showOverlay(text, showBack = false) {
278
- overlay.innerHTML = '';
279
- overlay.classList.remove('hidden');
280
- const msg = document.createElement('div');
281
- msg.textContent = text;
282
- overlay.appendChild(msg);
283
- if (showBack) {
284
- const btn = document.createElement('button');
285
- btn.textContent = '返回首页';
286
- btn.style.cssText = 'margin-top:1rem;padding:0.5rem 1.5rem;border:1px solid #8b949e;border-radius:8px;background:transparent;color:#c9d1d9;cursor:pointer;font-size:0.9rem';
287
- btn.onclick = () => window.location.href = '/';
288
- overlay.appendChild(btn);
431
+ // ── 关闭 Tab ──
432
+ function closeTab(id) {
433
+ const idx = tabs.findIndex(t => t.id === id);
434
+ if (idx === -1) return;
435
+ const tab = tabs[idx];
436
+
437
+ // 断开连接
438
+ tab.socket.disconnect();
439
+ tab.term.dispose();
440
+ tab.pane.remove();
441
+ if (tab.tabEl) tab.tabEl.remove();
442
+ tabs.splice(idx, 1);
443
+
444
+ if (tabs.length === 0) {
445
+ window.location.href = '/';
446
+ return;
447
+ }
448
+
449
+ // 切换到相邻 tab
450
+ if (activeTabId === id) {
451
+ const next = tabs[Math.min(idx, tabs.length - 1)];
452
+ switchTab(next.id);
289
453
  }
290
454
  }
291
455
 
292
- socket.on('connect', () => {
293
- showOverlay('正在加入会话...');
294
- socket.emit('join', SESSION_ID);
295
- });
456
+ // ── 新建 Tab(弹出会话选择)──
457
+ async function addTab() {
458
+ try {
459
+ const res = await fetch('/api/sessions');
460
+ const sessions = await res.json();
461
+ const alive = sessions.filter(s => s.alive && s.mode !== 'agent');
296
462
 
297
- socket.on('connect_error', (err) => {
298
- showOverlay('连接失败: ' + err.message, true);
299
- });
463
+ if (alive.length === 0) {
464
+ alert('没有可用的终端会话,请先在首页创建');
465
+ return;
466
+ }
300
467
 
301
- socket.on('disconnect', () => {
302
- showOverlay('已断开连接,正在重连...');
303
- });
468
+ // 过滤掉已打开的会话
469
+ const opened = new Set(tabs.map(t => t.sessionId));
470
+ const available = alive.filter(s => !opened.has(s.id));
304
471
 
305
- socket.on('joined', (session) => {
306
- currentSession = session;
307
- overlay.classList.add('hidden');
308
- document.title = session.title + ' — myhi';
309
- document.getElementById('session-title').textContent = session.title;
310
- updateViewers(session.viewers);
311
- fitTerm();
312
- });
472
+ if (available.length === 0) {
473
+ alert('所有会话已在标签页中打开');
474
+ return;
475
+ }
313
476
 
314
- socket.on('output', (data) => {
315
- term.write(data);
316
- });
477
+ if (available.length === 1) {
478
+ // 只有一个,直接打开
479
+ const tab = createTab(available[0].id);
480
+ switchTab(tab.id);
481
+ return;
482
+ }
317
483
 
318
- socket.on('session-exit', ({ code }) => {
319
- overlay.classList.remove('hidden');
320
- overlay.style.pointerEvents = 'auto';
321
- overlay.textContent = `会话已退出 (code ${code}) — 点击返回`;
322
- overlay.onclick = () => history.back();
323
- });
484
+ // 多个可选:简单弹窗选择
485
+ const names = available.map((s, i) => `${i + 1}. ${s.title}`).join('\n');
486
+ const choice = prompt(`选择要打开的会话:\n\n${names}\n\n输入编号:`);
487
+ if (!choice) return;
488
+ const n = parseInt(choice, 10) - 1;
489
+ if (n >= 0 && n < available.length) {
490
+ const tab = createTab(available[n].id);
491
+ switchTab(tab.id);
492
+ }
493
+ } catch (e) {
494
+ alert('获取会话列表失败: ' + e.message);
495
+ }
496
+ }
324
497
 
325
- socket.on('sessions', (sessions) => {
326
- const s = sessions.find(x => x.id === SESSION_ID);
327
- if (s) updateViewers(s.viewers);
328
- });
498
+ // ── 辅助函数 ──
499
+ function fitTab(tab) {
500
+ requestAnimationFrame(() => {
501
+ try { tab.fitAddon.fit(); } catch {}
502
+ if (tab.socket.connected) {
503
+ tab.socket.emit('resize', { cols: tab.term.cols, rows: tab.term.rows });
504
+ }
505
+ });
506
+ }
329
507
 
330
- socket.on('error', ({ message }) => {
331
- showOverlay('错误: ' + message, true);
332
- });
508
+ function updateTopBar(tab) {
509
+ document.getElementById('session-title').textContent = tab.title;
510
+ document.title = tab.title + ' — myhi';
511
+ }
333
512
 
334
- // ── Input ──────────────────────────────────────────────────
335
- term.onData((data) => {
336
- socket.emit('input', data);
513
+ function esc(s) {
514
+ const d = document.createElement('div');
515
+ d.textContent = s;
516
+ return d.innerHTML;
517
+ }
518
+
519
+ // 窗口 resize 时 fit 当前 tab
520
+ window.addEventListener('resize', () => {
521
+ const tab = tabs.find(t => t.id === activeTabId);
522
+ if (tab) fitTab(tab);
337
523
  });
338
524
 
339
- // ── Shortcut bar ───────────────────────────────────────────
340
- // Map data-send keys to actual byte sequences
525
+ // ── 快捷键发送到当前 tab ──
341
526
  const SEQ = {
342
527
  'ctrl-c': '\x03', 'ctrl-d': '\x04', 'tab': '\t',
343
528
  'up': '\x1b[A', 'down': '\x1b[B', 'right': '\x1b[C', 'left': '\x1b[D',
@@ -348,21 +533,21 @@
348
533
 
349
534
  document.querySelectorAll('.sk').forEach(btn => {
350
535
  const send = () => {
536
+ const tab = tabs.find(t => t.id === activeTabId);
537
+ if (!tab) return;
351
538
  const seq = SEQ[btn.dataset.send] || btn.dataset.send;
352
- socket.emit('input', seq);
353
- term.focus();
539
+ tab.socket.emit('input', seq);
540
+ tab.term.focus();
354
541
  };
355
-
356
- // Mobile: fire on touchend, prevent synthetic click + double-tap zoom
357
542
  btn.addEventListener('touchend', (e) => { e.preventDefault(); send(); });
358
- // Desktop: fire on click
359
543
  btn.addEventListener('click', send);
360
544
  });
361
545
 
362
- // ── QR modal ───────────────────────────────────────────────
546
+ // ── QR modal ──
363
547
  function showQR() {
364
- const img = document.getElementById('qr-img');
365
- img.src = `/qr/${SESSION_ID}`;
548
+ const tab = tabs.find(t => t.id === activeTabId);
549
+ if (!tab) return;
550
+ document.getElementById('qr-img').src = `/qr/${tab.sessionId}`;
366
551
  document.getElementById('qr-modal').classList.add('open');
367
552
  }
368
553
  function closeQR() {
@@ -372,19 +557,10 @@
372
557
  if (e.target === e.currentTarget) closeQR();
373
558
  });
374
559
 
375
- // ── Helpers ────────────────────────────────────────────────
376
- function updateViewers(count) {
377
- document.getElementById('viewer-count').textContent =
378
- count > 1 ? `👁 ${count}` : '';
379
- }
380
-
381
- function goBack() {
382
- window.location.href = '/';
383
- }
560
+ function goBack() { window.location.href = '/'; }
384
561
 
385
- // ── Photo upload ───────────────────────────────────────────
386
- const MAX_PX = 1920; // long side max pixels
387
- const QUALITY = 0.85; // JPEG quality
562
+ // ── Photo upload ──
563
+ const MAX_PX = 1920, QUALITY = 0.85;
388
564
 
389
565
  function compressImage(file) {
390
566
  return new Promise((resolve, reject) => {
@@ -418,6 +594,8 @@
418
594
  input.onchange = async () => {
419
595
  const file = input.files?.[0];
420
596
  if (!file) return;
597
+ const tab = tabs.find(t => t.id === activeTabId);
598
+ if (!tab) return;
421
599
 
422
600
  const btn = document.getElementById('btn-photo');
423
601
  btn.textContent = '⏳';
@@ -425,16 +603,13 @@
425
603
 
426
604
  try {
427
605
  const compressed = await compressImage(file);
428
- const kb = (compressed.size / 1024).toFixed(0);
429
- console.log(`[photo] ${(file.size/1024).toFixed(0)}KB → ${kb}KB`);
430
-
431
606
  const form = new FormData();
432
607
  form.append('image', compressed, 'photo.jpg');
433
- const res = await fetch('/upload', { method: 'POST', body: form });
608
+ const res = await fetch('/upload?sessionId=' + tab.sessionId, { method: 'POST', body: form });
434
609
  const data = await res.json();
435
610
  if (data.path) {
436
- socket.emit('input', data.path);
437
- term.focus();
611
+ tab.socket.emit('input', data.path);
612
+ tab.term.focus();
438
613
  } else {
439
614
  alert('上传失败: ' + (data.error || '未知'));
440
615
  }
@@ -448,8 +623,9 @@
448
623
  input.click();
449
624
  }
450
625
 
451
- // Focus terminal on tap (mobile)
452
- document.getElementById('terminal-wrapper').addEventListener('click', () => term.focus());
626
+ // ── 初始化:打开 URL 中指定的会话 ──
627
+ const initTab = createTab(INIT_SESSION_ID);
628
+ switchTab(initTab.id);
453
629
  </script>
454
630
  </body>
455
631
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendongfly/myhi",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "Web-based terminal sharing with chat UI — control your terminal from phone via LAN/Tailscale",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",