@jacksontian/kite-server 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/server.js +8 -8
- package/config.example.yaml +5 -5
- package/lib/api.js +121 -81
- package/lib/auth.js +1 -1
- package/lib/broadcast.js +3 -3
- package/lib/config.js +2 -2
- package/lib/im.js +23 -23
- package/lib/logger.js +1 -1
- package/lib/pty.js +16 -16
- package/lib/static.js +5 -10
- package/lib/store.js +31 -21
- package/lib/summary.js +3 -2
- package/lib/util.js +5 -5
- package/lib/websocket.js +41 -35
- package/package.json +2 -2
- package/web/app.js +1107 -0
- package/web/favicon.svg +18 -0
- package/web/index.html +138 -1035
- package/web/style.css +486 -0
- package/web/pc.html +0 -1192
package/web/app.js
ADDED
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const $ = (id) => document.getElementById(id);
|
|
3
|
+
// 触屏设备(手机/平板):终端输入走隐藏代理 + 按键工具条;PC 有物理键盘,xterm 直接承接
|
|
4
|
+
const isTouch = matchMedia('(pointer: coarse)').matches;
|
|
5
|
+
// 非 localhost 的明文 HTTP 环境:常驻风险提示(localhost 本地联调不打扰)
|
|
6
|
+
if (location.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(location.hostname)) {
|
|
7
|
+
$('http-warn').classList.remove('hidden');
|
|
8
|
+
}
|
|
9
|
+
const secretInput = $('secret');
|
|
10
|
+
secretInput.value = localStorage.getItem('rw_secret') || '';
|
|
11
|
+
|
|
12
|
+
function toast(msg) {
|
|
13
|
+
const el = $('toast');
|
|
14
|
+
el.textContent = msg; el.style.opacity = '1';
|
|
15
|
+
setTimeout(() => { el.style.opacity = '0'; }, 2200);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---------- 主题:light/dark 切换,偏好存 localStorage,首访跟随系统外观 ----------
|
|
19
|
+
// xterm 是 canvas 绘制读不到 CSS 变量,终端配色在这里维护并同步给各实例;
|
|
20
|
+
// 浅色下重排 16 色调色板(默认 ANSI 的黄/亮黄在白底上不可见)
|
|
21
|
+
// 终端色对齐 mwt 色板:深底 #0a0c12 / 浅底 #ffffff,前景即 --text-primary
|
|
22
|
+
const TERM_THEME = {
|
|
23
|
+
// 不显式给 selectionBackground 时,xterm 默认用 rgba(255,255,255,.3),
|
|
24
|
+
// 浅色下与白底混完还是白的——选中文字完全看不见,故两个主题都显式指定
|
|
25
|
+
dark: { background: '#0a0c12', foreground: '#d4d8e8', cursor: '#d4d8e8', selectionBackground: 'rgba(108,140,255,.45)' },
|
|
26
|
+
light: {
|
|
27
|
+
background: '#ffffff', foreground: '#1a1d2e', cursor: '#1a1d2e',
|
|
28
|
+
selectionBackground: 'rgba(74,108,247,.3)',
|
|
29
|
+
black: '#1a1d2e', red: '#d43d4e', green: '#1f9d7a', yellow: '#ca8a04',
|
|
30
|
+
blue: '#4a6cf7', magenta: '#8c5cf6', cyan: '#0891b2', white: '#f3f4f6',
|
|
31
|
+
brightBlack: '#6b7280', brightRed: '#ef4444', brightGreen: '#22c55e',
|
|
32
|
+
brightYellow: '#eab308', brightBlue: '#6c8cff', brightMagenta: '#a855f7',
|
|
33
|
+
brightCyan: '#22d3ee', brightWhite: '#ffffff',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
function currentTheme() {
|
|
37
|
+
return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
|
38
|
+
}
|
|
39
|
+
function applyTheme(theme) {
|
|
40
|
+
document.documentElement.dataset.theme = theme;
|
|
41
|
+
localStorage.setItem('rw_theme', theme);
|
|
42
|
+
$('hdr-theme').title = theme === 'light' ? '切换到深色主题' : '切换到浅色主题';
|
|
43
|
+
$('ic-theme').setAttribute('href', theme === 'light' ? '#i-moon' : '#i-sun');
|
|
44
|
+
document.querySelector('meta[name="theme-color"]').content = theme === 'light' ? '#ffffff' : '#121520';
|
|
45
|
+
for (const ent of termEntries.values()) ent.xt.options.theme = { ...TERM_THEME[theme] };
|
|
46
|
+
}
|
|
47
|
+
$('hdr-theme').addEventListener('click', () => applyTheme(currentTheme() === 'light' ? 'dark' : 'light'));
|
|
48
|
+
|
|
49
|
+
// ---------- 纯 JS SHA-256 / HMAC-SHA256 ----------
|
|
50
|
+
// 非 HTTPS 环境(http://VPS-IP)下浏览器的 crypto.subtle 不可用,故内联实现。
|
|
51
|
+
// 密钥只参与本地 HMAC 运算,从不随请求发送。
|
|
52
|
+
const SHA256_K = new Uint32Array([
|
|
53
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
54
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
55
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
56
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
57
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
58
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
59
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
60
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
61
|
+
]);
|
|
62
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
63
|
+
|
|
64
|
+
function sha256Bytes(data) {
|
|
65
|
+
const H = new Uint32Array([
|
|
66
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
|
67
|
+
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
68
|
+
]);
|
|
69
|
+
const len = data.length;
|
|
70
|
+
const paddedLen = (((len + 8) >> 6) + 1) << 6;
|
|
71
|
+
const padded = new Uint8Array(paddedLen);
|
|
72
|
+
padded.set(data);
|
|
73
|
+
padded[len] = 0x80;
|
|
74
|
+
const dv = new DataView(padded.buffer);
|
|
75
|
+
dv.setUint32(paddedLen - 8, Math.floor(len / 0x20000000)); // 比特长度高 32 位
|
|
76
|
+
dv.setUint32(paddedLen - 4, (len << 3) >>> 0); // 比特长度低 32 位
|
|
77
|
+
const w = new Uint32Array(64);
|
|
78
|
+
for (let off = 0; off < paddedLen; off += 64) {
|
|
79
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
|
80
|
+
for (let i = 16; i < 64; i++) {
|
|
81
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
82
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
83
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
84
|
+
}
|
|
85
|
+
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
|
|
86
|
+
for (let i = 0; i < 64; i++) {
|
|
87
|
+
const t1 = (h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_K[i] + w[i]) >>> 0;
|
|
88
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
|
89
|
+
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
90
|
+
}
|
|
91
|
+
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
|
|
92
|
+
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
|
|
93
|
+
}
|
|
94
|
+
const out = new Uint8Array(32);
|
|
95
|
+
const odv = new DataView(out.buffer);
|
|
96
|
+
for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
101
|
+
const utf8 = (s) => new TextEncoder().encode(s);
|
|
102
|
+
const sha256Hex = (s) => bytesToHex(sha256Bytes(utf8(s)));
|
|
103
|
+
|
|
104
|
+
function hmacSha256Hex(keyStr, msgStr) {
|
|
105
|
+
let key = utf8(keyStr);
|
|
106
|
+
if (key.length > 64) key = sha256Bytes(key);
|
|
107
|
+
const msg = utf8(msgStr);
|
|
108
|
+
const inner = new Uint8Array(64 + msg.length);
|
|
109
|
+
const outer = new Uint8Array(64 + 32);
|
|
110
|
+
for (let i = 0; i < 64; i++) {
|
|
111
|
+
const k = i < key.length ? key[i] : 0;
|
|
112
|
+
inner[i] = k ^ 0x36;
|
|
113
|
+
outer[i] = k ^ 0x5c;
|
|
114
|
+
}
|
|
115
|
+
inner.set(msg, 64);
|
|
116
|
+
outer.set(sha256Bytes(inner), 64);
|
|
117
|
+
return bytesToHex(sha256Bytes(outer));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- 请求签名(与 server/worker 的签名串格式严格一致)----------
|
|
121
|
+
function makeNonce() {
|
|
122
|
+
const bytes = new Uint8Array(16);
|
|
123
|
+
if (window.crypto && crypto.getRandomValues) crypto.getRandomValues(bytes);
|
|
124
|
+
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
125
|
+
return bytesToHex(bytes);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function signHeaders(method, path, bodyStr) {
|
|
129
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
130
|
+
const nonce = makeNonce();
|
|
131
|
+
const sig = hmacSha256Hex(secretInput.value.trim(), [method, path, ts, nonce, sha256Hex(bodyStr)].join('\n'));
|
|
132
|
+
return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
|
|
136
|
+
// 还会白刷服务端验签失败限流计数,统一在本地拦截
|
|
137
|
+
function hasSecret() {
|
|
138
|
+
return secretInput.value.trim() !== '';
|
|
139
|
+
}
|
|
140
|
+
function requireSecret() {
|
|
141
|
+
if (!hasSecret()) throw new Error('请先配置 API Secret');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function api(path, body) {
|
|
145
|
+
requireSecret();
|
|
146
|
+
const method = body === undefined ? 'GET' : 'POST';
|
|
147
|
+
const bodyStr = body === undefined ? '' : JSON.stringify(body);
|
|
148
|
+
const res = await fetch(path, {
|
|
149
|
+
method,
|
|
150
|
+
headers: {
|
|
151
|
+
'Content-Type': 'application/json',
|
|
152
|
+
...signHeaders(method, path, bodyStr),
|
|
153
|
+
},
|
|
154
|
+
body: body === undefined ? undefined : bodyStr,
|
|
155
|
+
});
|
|
156
|
+
const data = await res.json().catch(() => ({}));
|
|
157
|
+
if (res.status === 401) throw new Error('签名验证失败(检查密钥与本机时钟)');
|
|
158
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
159
|
+
return data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- 本地状态 ----------
|
|
163
|
+
const sessionCache = new Map(); // id -> 会话摘要
|
|
164
|
+
const tabEls = new Map(); // id -> 会话 tab 元素(tab 条即会话列表,窗格按需懒建)
|
|
165
|
+
const hiddenSessions = new Set(); // 本次浏览中 ✕ 关掉的会话(记录已被服务端删除;本地隐藏防删除生效前的广播/轮询回流)
|
|
166
|
+
let wsReady = false;
|
|
167
|
+
let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变更
|
|
168
|
+
// 刷新/换端恢复:会话「已打开」是服务端状态(session.openedAt),快照到达后
|
|
169
|
+
// 按它自动重开窗格——任意浏览器/客户端打开都能看到同一现场
|
|
170
|
+
let gotSnapshot = false; // 首份会话快照(WS hello 或 REST 轮询)是否已到达
|
|
171
|
+
|
|
172
|
+
secretInput.addEventListener('change', () => {
|
|
173
|
+
localStorage.setItem('rw_secret', secretInput.value.trim());
|
|
174
|
+
wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
|
|
175
|
+
renderSecretState();
|
|
176
|
+
if (hasSecret()) {
|
|
177
|
+
if (!wsReady) connectWS();
|
|
178
|
+
refresh(); // 之前无密钥没拉过数据,配好后补一次
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ---------- 设置下拉面板(topbar「设置」入口):密钥 ----------
|
|
183
|
+
function renderSecretState() {
|
|
184
|
+
$('secret-state').classList.toggle('hidden', !hasSecret());
|
|
185
|
+
}
|
|
186
|
+
function openSettings() {
|
|
187
|
+
renderSecretState();
|
|
188
|
+
setSecretVisible(false); // 每次打开恢复掩码,避免直接露出明文
|
|
189
|
+
$('settings-pop').classList.remove('hidden');
|
|
190
|
+
}
|
|
191
|
+
function closeSettings() { $('settings-pop').classList.add('hidden'); }
|
|
192
|
+
$('hdr-settings').addEventListener('click', (ev) => {
|
|
193
|
+
ev.stopPropagation(); // 防止全局「点面板外收起」监听把刚打开的面板立刻收掉
|
|
194
|
+
if ($('settings-pop').classList.contains('hidden')) openSettings(); else closeSettings();
|
|
195
|
+
});
|
|
196
|
+
// 点面板外收起
|
|
197
|
+
document.addEventListener('click', (ev) => {
|
|
198
|
+
const pop = $('settings-pop');
|
|
199
|
+
if (!pop.classList.contains('hidden') && !pop.contains(ev.target)) closeSettings();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 明文/掩码切换,仅改变本地展示方式,不影响签名
|
|
203
|
+
function setSecretVisible(visible) {
|
|
204
|
+
secretInput.type = visible ? 'text' : 'password';
|
|
205
|
+
const btn = $('btn-eye');
|
|
206
|
+
btn.querySelector('span').textContent = visible ? '隐藏' : '显示';
|
|
207
|
+
$('ic-eye').setAttribute('href', visible ? '#i-eye-off' : '#i-eye');
|
|
208
|
+
btn.classList.toggle('on', visible);
|
|
209
|
+
}
|
|
210
|
+
$('btn-eye').addEventListener('click', () => setSecretVisible(secretInput.type === 'password'));
|
|
211
|
+
|
|
212
|
+
// ---------- WebSocket 实时通道(断线自动回退轮询)----------
|
|
213
|
+
let curWs = null; // 当前实时连接,pty 终端输入经它上行
|
|
214
|
+
|
|
215
|
+
function sendWs(msg) {
|
|
216
|
+
if (curWs && curWs.readyState === WebSocket.OPEN) curWs.send(JSON.stringify(msg));
|
|
217
|
+
else toast('实时通道未连接,输入无法送达');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function setConnMode(realtime) {
|
|
221
|
+
const el = $('conn-mode');
|
|
222
|
+
el.textContent = realtime ? '实时' : '轮询';
|
|
223
|
+
el.className = realtime ? 'realtime' : 'polling';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function connectWS() {
|
|
227
|
+
if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
|
|
228
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
229
|
+
const sock = new WebSocket(`${proto}://${location.host}/ws`);
|
|
230
|
+
curWs = sock;
|
|
231
|
+
|
|
232
|
+
sock.addEventListener('open', () => {
|
|
233
|
+
// 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),密钥本身不进网络
|
|
234
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
235
|
+
const nonce = makeNonce();
|
|
236
|
+
const sig = hmacSha256Hex(secretInput.value.trim(), ['WS', ts, nonce].join('\n'));
|
|
237
|
+
sock.send(JSON.stringify({ type: 'auth', ts, nonce, sig }));
|
|
238
|
+
});
|
|
239
|
+
sock.addEventListener('message', (ev) => {
|
|
240
|
+
let msg;
|
|
241
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
242
|
+
switch (msg.type) {
|
|
243
|
+
case 'hello':
|
|
244
|
+
wsReady = true;
|
|
245
|
+
setConnMode(true);
|
|
246
|
+
sessionCache.clear();
|
|
247
|
+
for (const t of msg.sessions) sessionCache.set(t.id, t);
|
|
248
|
+
gotSnapshot = true;
|
|
249
|
+
syncTabs();
|
|
250
|
+
renderWorkers(msg.workers);
|
|
251
|
+
// 重连后给打开着的终端补回看缓冲与会话存活状态
|
|
252
|
+
for (const id of termEntries.keys()) {
|
|
253
|
+
syncPaneSession(id);
|
|
254
|
+
const ent = termEntries.get(id);
|
|
255
|
+
ent.pendingAttach = true;
|
|
256
|
+
sock.send(JSON.stringify({ type: 'pty.attach', sessionId: id }));
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
case 'session.created':
|
|
260
|
+
case 'session.updated':
|
|
261
|
+
sessionCache.set(msg.session.id, msg.session);
|
|
262
|
+
syncTabs();
|
|
263
|
+
syncPaneSession(msg.session.id); // 已开窗的同步状态(如 pending→running 后隐藏取消钮)
|
|
264
|
+
break;
|
|
265
|
+
case 'session.closed':
|
|
266
|
+
// 关窗即关会话:记录已被服务端删除(可能来自其他控制台),本地同步清掉
|
|
267
|
+
sessionCache.delete(msg.id);
|
|
268
|
+
hiddenSessions.delete(msg.id);
|
|
269
|
+
syncTabs(); // 不存在的会话在此统一移除 tab 与已开窗格
|
|
270
|
+
break;
|
|
271
|
+
case 'workers':
|
|
272
|
+
renderWorkers(msg.workers);
|
|
273
|
+
break;
|
|
274
|
+
// --- pty 交互终端流 ---
|
|
275
|
+
case 'pty.data': {
|
|
276
|
+
// 非当前会话有新输出:tab 亮活动圆点(窗格没打开也亮,输出由服务端缓冲,开窗时回补)
|
|
277
|
+
if (msg.id !== activeTermId) {
|
|
278
|
+
const tab = tabEls.get(msg.id);
|
|
279
|
+
if (tab) tab.classList.add('dirty');
|
|
280
|
+
const me = termEntries.get(msg.id);
|
|
281
|
+
if (me && me.minimized && me.minIcon) me.minIcon.classList.add('dirty'); // 最小化停靠图标同样亮活动点
|
|
282
|
+
}
|
|
283
|
+
const ent = termEntries.get(msg.id);
|
|
284
|
+
if (ent) {
|
|
285
|
+
ent.xt.write(msg.data);
|
|
286
|
+
ent.live = true;
|
|
287
|
+
termStatus(ent);
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case 'pty.started': {
|
|
292
|
+
const ent = termEntries.get(msg.id);
|
|
293
|
+
if (ent && !ent.live) {
|
|
294
|
+
ent.live = true;
|
|
295
|
+
ent.xt.write('\r\n\x1b[32m[终端会话已就绪]\x1b[0m\r\n');
|
|
296
|
+
termStatus(ent);
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case 'pty.exited': {
|
|
301
|
+
const ent = termEntries.get(msg.id);
|
|
302
|
+
if (ent) {
|
|
303
|
+
ent.live = false;
|
|
304
|
+
ent.xt.write(`\r\n\x1b[31m[会话结束:${msg.reason || ''}]\x1b[0m\r\n`);
|
|
305
|
+
termStatus(ent);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case 'pty.attach': {
|
|
310
|
+
const ent = termEntries.get(msg.id);
|
|
311
|
+
if (ent && ent.pendingAttach) {
|
|
312
|
+
ent.pendingAttach = false;
|
|
313
|
+
if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
|
|
314
|
+
ent.live = msg.live;
|
|
315
|
+
ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
|
|
316
|
+
if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
|
|
317
|
+
termStatus(ent);
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
sock.addEventListener('close', (ev) => {
|
|
324
|
+
wsReady = false;
|
|
325
|
+
setConnMode(false);
|
|
326
|
+
for (const ent of termEntries.values()) termStatus(ent); // 各打开终端的状态条都显示重连中
|
|
327
|
+
if (ev.code === 4003) {
|
|
328
|
+
// 验签被拒:无意义重试只会刷服务端日志,等用户改密钥
|
|
329
|
+
wsAuthRejected = true;
|
|
330
|
+
openSettings(); // 打开设置面板,方便直接修改密钥
|
|
331
|
+
toast('密钥无效或时钟偏差过大,实时连接已停止');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setTimeout(connectWS, 3000); // 锁屏/切网导致的断线,3 秒后重连
|
|
335
|
+
});
|
|
336
|
+
sock.addEventListener('error', () => sock.close());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------- 渲染(增量 diff:节点按 id 复用,只更新变化的部分) ----------
|
|
340
|
+
const STATUS_TEXT = { pending: '等待中', running: '执行中', done: '完成', failed: '失败', canceled: '已取消' };
|
|
341
|
+
function fmtTime(ts) {
|
|
342
|
+
if (!ts) return '-';
|
|
343
|
+
const d = new Date(ts);
|
|
344
|
+
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ` +
|
|
345
|
+
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let workerList = []; // 心跳下发的 Worker 快照;目标 Worker 与项目目录下拉都基于它
|
|
349
|
+
|
|
350
|
+
/** 相对时间:离线 Worker 的最近心跳展示成「X 分钟前」,超过一天回退具体时间 */
|
|
351
|
+
function relTime(ts) {
|
|
352
|
+
if (!ts) return '从未';
|
|
353
|
+
const diff = Date.now() - ts;
|
|
354
|
+
if (diff < 60_000) return '1 分钟内';
|
|
355
|
+
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
|
356
|
+
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`;
|
|
357
|
+
return fmtTime(ts);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderWorkers(workers) {
|
|
361
|
+
workerList = workers;
|
|
362
|
+
const ws = $('worker-status');
|
|
363
|
+
const online = workers.filter((w) => w.online).length;
|
|
364
|
+
let text;
|
|
365
|
+
if (!workers.length) {
|
|
366
|
+
text = '无 Worker';
|
|
367
|
+
} else if (online) {
|
|
368
|
+
text = `${online}/${workers.length} 在线`;
|
|
369
|
+
} else {
|
|
370
|
+
// 全离线时给出最近一次心跳时间,便于判断是「刚掉线」还是「长期没上线」
|
|
371
|
+
const lastSeen = Math.max(...workers.map((w) => w.lastSeenAt || 0));
|
|
372
|
+
text = `全离线(心跳 ${relTime(lastSeen)})`;
|
|
373
|
+
}
|
|
374
|
+
if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
|
|
375
|
+
ws.className = online ? 'online' : 'offline';
|
|
376
|
+
// 当前过滤的 Worker 已注销(如配置移除):清掉选中态(打开的终端不打扰,继续挂着)
|
|
377
|
+
if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) {
|
|
378
|
+
currentWorkerId = null;
|
|
379
|
+
$('hdr-project').disabled = true; // 无 Worker:目录下拉回禁用态
|
|
380
|
+
syncTabs();
|
|
381
|
+
}
|
|
382
|
+
renderWorkerSelect();
|
|
383
|
+
// 首份快照到达后按地址栏 hash 恢复(刷新后还原所选 Worker)
|
|
384
|
+
if (!routeInited) { routeInited = true; applyRoute(); }
|
|
385
|
+
}
|
|
386
|
+
// 「X 分钟前」随时间变化:存在离线 Worker 时定期用最近快照重渲染,
|
|
387
|
+
// 保持离线时长读数新鲜(全在线时不动,避免打扰交互)
|
|
388
|
+
setInterval(() => {
|
|
389
|
+
if (workerList.length && !workerList.some((w) => w.online)) renderWorkers(workerList);
|
|
390
|
+
}, 30_000);
|
|
391
|
+
|
|
392
|
+
// ---------- 单页工作台:会话即终端窗口,顶部 tab 条就是会话列表 ----------
|
|
393
|
+
let currentWorkerId = null; // tab 条的 Worker 过滤(null = 全部)
|
|
394
|
+
let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(下拉还没建好)
|
|
395
|
+
|
|
396
|
+
function updateTermEmpty() {
|
|
397
|
+
// 可见窗格(未最小化)为零即空态:全部最小化时也显示提示(停靠图标在底部最小化栏)
|
|
398
|
+
const visible = [...termEntries.values()].filter((e) => !e.minimized).length;
|
|
399
|
+
$('term-empty').classList.toggle('hidden', visible > 0);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// tab 条即会话列表:一会话一 tab(窗格点开才建),按时间倒序;会话消失/被 ✕ 关掉则移除
|
|
403
|
+
function syncTabs() {
|
|
404
|
+
const sessions = [...sessionCache.values()]
|
|
405
|
+
.filter((t) => !hiddenSessions.has(t.id))
|
|
406
|
+
.filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
|
|
407
|
+
.sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
|
|
408
|
+
// 清理已不存在的会话:tab 移除,开着的窗格一并释放
|
|
409
|
+
const ids = new Set(sessions.map((t) => t.id));
|
|
410
|
+
for (const [id, tab] of tabEls) {
|
|
411
|
+
if (!ids.has(id)) { tab.remove(); tabEls.delete(id); hiddenSessions.delete(id); disposePane(id); }
|
|
412
|
+
}
|
|
413
|
+
// 增量更新/插入,并保证顺序(新会话插到正确位置,而不是闪到末尾)
|
|
414
|
+
const strip = $('tab-list');
|
|
415
|
+
let prev = null;
|
|
416
|
+
for (const t of sessions) {
|
|
417
|
+
let tab = tabEls.get(t.id);
|
|
418
|
+
if (!tab) { tab = createTabEl(t); tabEls.set(t.id, tab); }
|
|
419
|
+
patchTab(t, tab);
|
|
420
|
+
const expected = prev ? prev.nextSibling : strip.firstChild;
|
|
421
|
+
if (tab !== expected) strip.insertBefore(tab, expected);
|
|
422
|
+
prev = tab;
|
|
423
|
+
}
|
|
424
|
+
syncTabsBar();
|
|
425
|
+
restoreOpenPanes();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// tab 条显隐:仅「标签页」布局且有 tab 时显示(平铺布局所有终端同屏可见,无窗格需要 tab 承接)
|
|
429
|
+
function syncTabsBar() {
|
|
430
|
+
$('tabs').classList.toggle('hidden', termLayout !== 'tabs' || tabEls.size === 0);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 开窗标记落服务端(会话记录 openedAt):刷新/换浏览器/换客户端都按它恢复现场;
|
|
434
|
+
// 失败不打扰(快照里已有标记时恢复不受影响)
|
|
435
|
+
function markSessionOpened(id) {
|
|
436
|
+
api(`/api/sessions/${id}/open`, {}).catch(() => {});
|
|
437
|
+
}
|
|
438
|
+
let panesRestored = false;
|
|
439
|
+
function restoreOpenPanes() {
|
|
440
|
+
if (panesRestored || !gotSnapshot) return;
|
|
441
|
+
panesRestored = true;
|
|
442
|
+
let n = 0;
|
|
443
|
+
for (const t of sessionCache.values()) { // 恢复上限 8 窗,防一次性建过多 xterm 实例
|
|
444
|
+
if (n >= 8) break;
|
|
445
|
+
if (t.openedAt && !termEntries.has(t.id)) { openTerm(t); n++; }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ✕ 关窗即关会话:释放本地终端并从 tab 条移除,服务端删除会话记录(刷新不再回来);
|
|
450
|
+
// 活跃 PTY 会先被通知杀掉,迟到的上报由服务端 404 / Worker 侧容忍兜底
|
|
451
|
+
async function closeTerm(id) {
|
|
452
|
+
hiddenSessions.add(id);
|
|
453
|
+
disposePane(id);
|
|
454
|
+
const tab = tabEls.get(id);
|
|
455
|
+
if (tab) { tab.remove(); tabEls.delete(id); }
|
|
456
|
+
syncTabsBar();
|
|
457
|
+
if (!sessionCache.has(id)) return;
|
|
458
|
+
try {
|
|
459
|
+
await api(`/api/sessions/${id}/kill`, {});
|
|
460
|
+
sessionCache.delete(id); // 记录已在服务端删除,本地同步清掉防轮询回流
|
|
461
|
+
if (!wsReady) await refresh();
|
|
462
|
+
} catch (err) {
|
|
463
|
+
toast(`关闭会话失败:${err.message}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 选中 Worker:只影响 tab 条过滤与目录下拉(Worker 面板已并入 topbar 下拉)
|
|
468
|
+
$('hdr-worker').addEventListener('change', () => {
|
|
469
|
+
currentWorkerId = $('hdr-worker').value || null;
|
|
470
|
+
renderProjectOptions();
|
|
471
|
+
syncTabs();
|
|
472
|
+
history.replaceState(null, '', buildHash()); // 所选 Worker 入 URL:刷新后恢复
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ---------- 页头 Worker 下拉:随心跳快照重建,「全部」清空过滤,各 Worker 带在线标记 ----------
|
|
476
|
+
// sig 去重,快照没变不重建(避免打断正在拉开的下拉)
|
|
477
|
+
let selectSig = '';
|
|
478
|
+
function renderWorkerSelect() {
|
|
479
|
+
const sig = JSON.stringify(workerList.map((w) => [w.workerId, w.hostname, w.online]));
|
|
480
|
+
if (sig !== selectSig) {
|
|
481
|
+
selectSig = sig;
|
|
482
|
+
const sel = $('hdr-worker');
|
|
483
|
+
sel.textContent = '';
|
|
484
|
+
const all = document.createElement('option');
|
|
485
|
+
all.value = '';
|
|
486
|
+
all.textContent = workerList.length ? `全部 Worker(${workerList.length})` : '无 Worker';
|
|
487
|
+
sel.appendChild(all);
|
|
488
|
+
for (const w of workerList) {
|
|
489
|
+
const opt = document.createElement('option');
|
|
490
|
+
opt.value = w.workerId;
|
|
491
|
+
const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
|
|
492
|
+
opt.textContent = `${w.online ? '●' : '○'} ${name}`; // ● 在线 / ○ 离线
|
|
493
|
+
sel.appendChild(opt);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
$('hdr-worker').value = currentWorkerId && workerList.some((w) => w.workerId === currentWorkerId)
|
|
497
|
+
? currentWorkerId : '';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------- hash 路由:单工作台无视图切换,只记所选 Worker ----------
|
|
501
|
+
// 兼容旧链接:#/、#/term、#/sessions… 即工作台;#/w/<workerId> 恢复 Worker 过滤
|
|
502
|
+
function route() {
|
|
503
|
+
const parts = location.hash.slice(1).split('/').filter(Boolean);
|
|
504
|
+
if (parts[0] === 'w') return { worker: decodeURIComponent(parts[1] || '') };
|
|
505
|
+
return {};
|
|
506
|
+
}
|
|
507
|
+
function buildHash() {
|
|
508
|
+
return currentWorkerId ? `#/w/${encodeURIComponent(currentWorkerId)}` : '#/';
|
|
509
|
+
}
|
|
510
|
+
function applyRoute() {
|
|
511
|
+
const r = route();
|
|
512
|
+
if (r.worker && r.worker !== currentWorkerId
|
|
513
|
+
&& workerList.some((w) => w.workerId === r.worker)) {
|
|
514
|
+
currentWorkerId = r.worker;
|
|
515
|
+
$('hdr-worker').value = r.worker;
|
|
516
|
+
renderProjectOptions();
|
|
517
|
+
syncTabs();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
window.addEventListener('hashchange', applyRoute);
|
|
521
|
+
|
|
522
|
+
// 项目目录下拉:只列所选 Worker 上报的项目目录(显式指派,不做跨机并集);
|
|
523
|
+
// 末尾常驻「+ 新建目录…」,输入的新目录在会话执行时由 Worker 自动创建(pty 执行前会 mkdir)
|
|
524
|
+
const NEW_PROJECT = '__new__';
|
|
525
|
+
|
|
526
|
+
function renderProjectOptions() {
|
|
527
|
+
const selected = workerList.find((w) => w.workerId === currentWorkerId);
|
|
528
|
+
const projects = [...new Set(selected ? (selected.projects || []) : [])].sort();
|
|
529
|
+
const sel = $('hdr-project');
|
|
530
|
+
sel.disabled = !currentWorkerId; // 目录跟着 Worker 走,未选时禁用
|
|
531
|
+
if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
|
|
532
|
+
const prev = sel.value !== NEW_PROJECT ? sel.value : 'default';
|
|
533
|
+
sel.textContent = '';
|
|
534
|
+
for (const p of projects) {
|
|
535
|
+
const opt = document.createElement('option');
|
|
536
|
+
opt.value = p;
|
|
537
|
+
opt.textContent = p;
|
|
538
|
+
sel.appendChild(opt);
|
|
539
|
+
}
|
|
540
|
+
const newOpt = document.createElement('option');
|
|
541
|
+
newOpt.value = NEW_PROJECT;
|
|
542
|
+
newOpt.textContent = '+ 新建目录…';
|
|
543
|
+
sel.appendChild(newOpt);
|
|
544
|
+
sel.value = [...sel.options].some((o) => o.value === prev) ? prev : projects[0];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 选「+ 新建目录…」:弹输入框收目录名,合法则加入下拉并选中;
|
|
548
|
+
// 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
|
|
549
|
+
$('hdr-project').addEventListener('change', (ev) => {
|
|
550
|
+
const sel = ev.target;
|
|
551
|
+
if (sel.value !== NEW_PROJECT) return;
|
|
552
|
+
const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
|
|
553
|
+
const name = (window.prompt('新项目目录名(会话执行时在 Worker workspace 下自动创建)') || '').trim();
|
|
554
|
+
if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
|
|
555
|
+
if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
|
|
556
|
+
sel.value = fallback;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (![...sel.options].some((o) => o.value === name)) {
|
|
560
|
+
const opt = document.createElement('option');
|
|
561
|
+
opt.value = name;
|
|
562
|
+
opt.textContent = name;
|
|
563
|
+
sel.insertBefore(opt, sel.querySelector(`option[value="${NEW_PROJECT}"]`));
|
|
564
|
+
}
|
|
565
|
+
sel.value = name;
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// ---------- 新建会话:无弹框,直接为当前 Worker + 所选目录提交 pty 会话,并立即开出终端窗 ----------
|
|
569
|
+
$('hdr-new-session').addEventListener('click', async () => {
|
|
570
|
+
const targetWorker = currentWorkerId;
|
|
571
|
+
if (!targetWorker) return toast('请先在页头下拉选择 Worker');
|
|
572
|
+
const project = $('hdr-project').value;
|
|
573
|
+
if (!project || project === NEW_PROJECT) return toast('请先选择项目目录');
|
|
574
|
+
if (!/^[\w.-]+$/.test(project)) return toast('项目目录名只能含字母/数字/._-');
|
|
575
|
+
const btn = $('hdr-new-session');
|
|
576
|
+
try {
|
|
577
|
+
btn.disabled = true;
|
|
578
|
+
const session = await api('/api/sessions', { type: 'pty', title: `交互终端(${project})`, payload: { project }, targetWorker });
|
|
579
|
+
sessionCache.set(session.id, session);
|
|
580
|
+
syncTabs();
|
|
581
|
+
openTerm(session); // 会话即终端窗口:直开,待领取/启动中在终端内提示,就绪后自动接通
|
|
582
|
+
if (!wsReady) await refresh();
|
|
583
|
+
} catch (err) {
|
|
584
|
+
toast(err.message);
|
|
585
|
+
} finally {
|
|
586
|
+
btn.disabled = false;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// 会话 tab:点击开窗(终端窗格懒建),✕ 关窗即关会话
|
|
591
|
+
function createTabEl(t) {
|
|
592
|
+
const tab = document.createElement('div');
|
|
593
|
+
tab.className = 'term-tab';
|
|
594
|
+
tab.innerHTML = '<span class="tt-dot"></span><span class="tt-title"></span>' +
|
|
595
|
+
'<button class="tt-close" type="button" title="关闭终端并结束该会话">✕</button>';
|
|
596
|
+
tab.addEventListener('click', () => {
|
|
597
|
+
const s = sessionCache.get(t.id);
|
|
598
|
+
if (s) openTerm(s);
|
|
599
|
+
});
|
|
600
|
+
tab.querySelector('.tt-close').addEventListener('click', (ev) => {
|
|
601
|
+
ev.stopPropagation(); // 关窗不触发 tab 的开窗逻辑
|
|
602
|
+
closeTerm(t.id);
|
|
603
|
+
});
|
|
604
|
+
return tab;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function patchTab(t, tab) {
|
|
608
|
+
const title = tab.querySelector('.tt-title');
|
|
609
|
+
const label = termLabel(t);
|
|
610
|
+
if (title.textContent !== label) title.textContent = label;
|
|
611
|
+
const ended = ['done', 'failed', 'canceled'].includes(t.status);
|
|
612
|
+
tab.classList.toggle('pending', t.status === 'pending');
|
|
613
|
+
tab.classList.toggle('ended', ended);
|
|
614
|
+
tab.title = `${label} · ${STATUS_TEXT[t.status] || t.status} · ${fmtTime(t.createdAt)}`;
|
|
615
|
+
const active = activeTermId === t.id;
|
|
616
|
+
tab.classList.toggle('active', active);
|
|
617
|
+
if (active) tab.classList.remove('dirty'); // 切到当前窗即视为已读
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------- 交互终端(pty 会话:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
|
|
621
|
+
// 多窗口管理借鉴 mwt LayoutManager:并排/横排/网格/标签页四种布局,窗格可最大化/最小化,
|
|
622
|
+
// 底部状态栏汇总,Alt 快捷键切换;tab 活动圆点标记非当前终端的新输出;偏好存 localStorage。
|
|
623
|
+
// 触屏无物理键盘:隐藏 textarea 承接击键,工具条补 Tab/方向键/Ctrl 组合键
|
|
624
|
+
const termEntries = new Map(); // sessionId -> { xt, fit, pane, tab, statusEl, cancelBtn, live, pendingAttach, liveHint, attached, minimized, label, maxBtn, minIcon }
|
|
625
|
+
let activeTermId = null;
|
|
626
|
+
const TERM_LAYOUTS = ['columns', 'rows', 'grid', 'tabs'];
|
|
627
|
+
// 旧版偏好 'single'(单屏)与「标签页」同义,不在新枚举中自然落到默认 'tabs'
|
|
628
|
+
const savedLayout = localStorage.getItem('rw_termLayout');
|
|
629
|
+
let termLayout = TERM_LAYOUTS.includes(savedLayout) ? savedLayout : 'tabs';
|
|
630
|
+
let termMaxId = null; // 当前最大化的终端(独占 #term-body),null 表示无
|
|
631
|
+
// 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
|
|
632
|
+
const ATTACH_HINTS = {
|
|
633
|
+
not_claimed: '会话尚未被领取,等待 Worker 上线领取…',
|
|
634
|
+
worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
|
|
635
|
+
starting: '会话已被领取,会话启动中,请稍候…',
|
|
636
|
+
ended: '会话已结束或已被回收',
|
|
637
|
+
unknown: '会话尚未被领取或 Worker 离线,请稍候…',
|
|
638
|
+
};
|
|
639
|
+
const KEY_SEQ = { BS: '\x7f', Tab: '\t', Esc: '\x1b', Up: '\x1b[A', Down: '\x1b[B', CtrlC: '\x03', CtrlD: '\x04' };
|
|
640
|
+
|
|
641
|
+
// 终端窗口的显示名:自定义标题照用;默认标题(空或「交互终端…」)
|
|
642
|
+
// 退化为「项目目录 · #会话短号」,同项目开多个终端也不会重名
|
|
643
|
+
function termLabel(t) {
|
|
644
|
+
if (t.title && !/^交互终端/.test(t.title)) return t.title;
|
|
645
|
+
const short = String(t.id).slice(-4);
|
|
646
|
+
const proj = t.project || (t.payload && t.payload.project);
|
|
647
|
+
return proj ? `${proj} · #${short}` : `终端 · #${short}`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 状态条挂在各终端自己的 pane 头部(并排时各有各的状态)
|
|
651
|
+
function termStatusText(ent) {
|
|
652
|
+
if (!wsReady) return '实时通道断开,重连中…';
|
|
653
|
+
if (ent.live) return '已连接';
|
|
654
|
+
return `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
|
|
655
|
+
}
|
|
656
|
+
function termStatus(ent) {
|
|
657
|
+
if (ent && ent.statusEl) ent.statusEl.textContent = termStatusText(ent);
|
|
658
|
+
updateTermFoot();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 会话状态同步进已打开的窗格:只有 pending(未领取)才可取消
|
|
662
|
+
function syncPaneSession(id) {
|
|
663
|
+
const ent = termEntries.get(id);
|
|
664
|
+
const t = sessionCache.get(id);
|
|
665
|
+
if (!ent || !t) return;
|
|
666
|
+
ent.cancelBtn.classList.toggle('hidden', t.status !== 'pending');
|
|
667
|
+
termStatus(ent);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 底部状态栏:活动终端/连接状态 · 窗口尺寸 · 终端计数(最大化/最小化信息内嵌)
|
|
671
|
+
function updateTermFoot() {
|
|
672
|
+
const ent = termEntries.get(activeTermId);
|
|
673
|
+
const maxTag = termMaxId && termMaxId === activeTermId ? ' · 最大化' : '';
|
|
674
|
+
$('tf-active').textContent = ent ? `${ent.label} · ${termStatusText(ent)}${maxTag}` : '无活动终端';
|
|
675
|
+
$('tf-size').textContent = ent ? `${ent.xt.cols}×${ent.xt.rows}` : '';
|
|
676
|
+
const total = termEntries.size;
|
|
677
|
+
const mins = [...termEntries.values()].filter((e) => e.minimized).length;
|
|
678
|
+
$('tf-count').textContent = total ? `${total} 个终端${mins ? ` · ${mins} 已最小化` : ''}` : '';
|
|
679
|
+
// 触屏没有快捷键提示行,状态同步进工具条右侧
|
|
680
|
+
if (isTouch) $('term-bar-status').textContent = ent ? termStatusText(ent) : '';
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function ensureTerm(t) {
|
|
684
|
+
let ent = termEntries.get(t.id);
|
|
685
|
+
if (ent) return ent;
|
|
686
|
+
if (!window.Terminal || !window.FitAddon) {
|
|
687
|
+
toast('xterm.js 加载失败(vendor/ 资源缺失,请刷新重试)');
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
const xt = new window.Terminal({
|
|
691
|
+
fontSize: isTouch ? 13 : 14, scrollback: isTouch ? 3000 : 5000, cursorBlink: true,
|
|
692
|
+
theme: { ...TERM_THEME[currentTheme()] },
|
|
693
|
+
});
|
|
694
|
+
const fit = new window.FitAddon.FitAddon();
|
|
695
|
+
xt.loadAddon(fit);
|
|
696
|
+
// 每个终端独占一个 pane(标题栏 + 渲染区):挂在同一容器里会让多实例叠在一起串台
|
|
697
|
+
const pane = document.createElement('div');
|
|
698
|
+
pane.className = 'term-pane';
|
|
699
|
+
pane.innerHTML = `
|
|
700
|
+
<div class="tp-head">
|
|
701
|
+
<span class="tp-title"></span>
|
|
702
|
+
<span class="tp-status"></span>
|
|
703
|
+
<button class="tp-cancel hidden" type="button" title="取消该会话(仅限未领取)">取消</button>
|
|
704
|
+
<button class="tp-min" type="button" title="最小化(Alt+-)">—</button>
|
|
705
|
+
<button class="tp-max" type="button" title="最大化/还原(Alt+M)">⤢</button>
|
|
706
|
+
<button class="tp-close" type="button" title="关闭终端并结束该会话">✕</button>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="tp-term"></div>`;
|
|
709
|
+
pane.querySelector('.tp-title').textContent = termLabel(t); // textContent 天然防注入
|
|
710
|
+
pane.querySelector('.tp-cancel').addEventListener('click', async (ev) => {
|
|
711
|
+
ev.stopPropagation();
|
|
712
|
+
const btn = ev.currentTarget;
|
|
713
|
+
try {
|
|
714
|
+
btn.disabled = true;
|
|
715
|
+
await api(`/api/sessions/${t.id}/cancel`, {});
|
|
716
|
+
if (!wsReady) await refresh();
|
|
717
|
+
} catch (e) { toast(e.message); } finally { btn.disabled = false; }
|
|
718
|
+
});
|
|
719
|
+
pane.querySelector('.tp-min').addEventListener('click', (ev) => {
|
|
720
|
+
ev.stopPropagation();
|
|
721
|
+
minimizeTerm(t.id);
|
|
722
|
+
});
|
|
723
|
+
pane.querySelector('.tp-max').addEventListener('click', (ev) => {
|
|
724
|
+
ev.stopPropagation();
|
|
725
|
+
toggleTermMax(t.id);
|
|
726
|
+
});
|
|
727
|
+
pane.querySelector('.tp-close').addEventListener('click', (ev) => {
|
|
728
|
+
ev.stopPropagation();
|
|
729
|
+
closeTerm(t.id); // 关窗即关会话:释放本地窗格,服务端会话同步落终态
|
|
730
|
+
});
|
|
731
|
+
pane.addEventListener('mousedown', () => { // 并排时点非当前 pane 即切焦点过去
|
|
732
|
+
if (activeTermId !== t.id) activateTerm(t.id);
|
|
733
|
+
});
|
|
734
|
+
$('term-body').appendChild(pane);
|
|
735
|
+
xt.open(pane.querySelector('.tp-term'));
|
|
736
|
+
// tab 条上的会话 tab 一般已由 syncTabs 建好;兜底缺失场景(如未过滤到的会话直开)
|
|
737
|
+
if (!tabEls.has(t.id)) syncTabs();
|
|
738
|
+
const tab = tabEls.get(t.id);
|
|
739
|
+
ent = {
|
|
740
|
+
xt, fit, pane, tab, statusEl: pane.querySelector('.tp-status'),
|
|
741
|
+
cancelBtn: pane.querySelector('.tp-cancel'),
|
|
742
|
+
live: false, pendingAttach: false, liveHint: '', attached: false,
|
|
743
|
+
minimized: false, label: termLabel(t), maxBtn: pane.querySelector('.tp-max'), minIcon: null,
|
|
744
|
+
};
|
|
745
|
+
termEntries.set(t.id, ent);
|
|
746
|
+
if (!t.openedAt) markSessionOpened(t.id); // 开窗标记落服务端(已带标记的恢复开窗不重复上报)
|
|
747
|
+
syncPaneSession(t.id); // 初始按会话状态决定取消钮可见性
|
|
748
|
+
updateTermEmpty();
|
|
749
|
+
xt.onData((data) => sendWs({ type: 'session.input', sessionId: t.id, data }));
|
|
750
|
+
return ent;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 切换当前终端:点亮对应 tab 与 pane,熄灭活动圆点,聚焦承接输入
|
|
754
|
+
function activateTerm(id) {
|
|
755
|
+
const ent = termEntries.get(id);
|
|
756
|
+
if (!ent) return;
|
|
757
|
+
if (ent.minimized) restoreTerm(id); // 兜底:激活最小化窗格前先还原
|
|
758
|
+
activeTermId = id;
|
|
759
|
+
for (const [tid, e] of termEntries) {
|
|
760
|
+
const on = tid === id;
|
|
761
|
+
e.tab.classList.toggle('active', on);
|
|
762
|
+
if (on) e.tab.classList.remove('dirty'); // 切到当前窗即视为已读
|
|
763
|
+
// 标签页模式 pane 叠层只显示当前;平铺模式 CSS 让全部可见,active 只高亮边框
|
|
764
|
+
e.pane.classList.toggle('active', on);
|
|
765
|
+
}
|
|
766
|
+
fitTerm(id, ent); // pane 可见后再 fit(隐藏状态下拿不到尺寸)
|
|
767
|
+
termStatus(ent);
|
|
768
|
+
ent.tab.scrollIntoView({ block: 'nearest', inline: 'nearest' }); // tab 条横滚时保证当前窗可见
|
|
769
|
+
focusTerm();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 聚焦输入承接方:PC 聚焦 xterm 本体;触屏聚焦隐藏代理(键盘弹起由它触发)
|
|
773
|
+
function focusTerm() {
|
|
774
|
+
if (!isTouch) {
|
|
775
|
+
const ent = termEntries.get(activeTermId);
|
|
776
|
+
if (ent) ent.xt.focus();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!$('term-input').value) resetTermInput();
|
|
780
|
+
$('term-input').focus({ preventScroll: true });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// 按容器尺寸重算 cols/rows,并通知 Worker 端 PTY 同步窗口大小
|
|
784
|
+
function fitTerm(id, ent) {
|
|
785
|
+
try { ent.fit.fit(); } catch { return; }
|
|
786
|
+
if (id === activeTermId) $('tf-size').textContent = `${ent.xt.cols}×${ent.xt.rows}`;
|
|
787
|
+
if (wsReady) sendWs({ type: 'session.resize', sessionId: id, cols: ent.xt.cols, rows: ent.xt.rows });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// fit 所有当前可见终端:平铺布局全部(最小化的跳过);标签页模式仅 active;最大化时只 fit 最大化窗格
|
|
791
|
+
function fitVisibleTerms() {
|
|
792
|
+
for (const [id, ent] of termEntries) {
|
|
793
|
+
if (ent.minimized) continue;
|
|
794
|
+
if (termMaxId) {
|
|
795
|
+
if (id === termMaxId) fitTerm(id, ent);
|
|
796
|
+
} else if (termLayout !== 'tabs' || id === activeTermId) {
|
|
797
|
+
fitTerm(id, ent);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// 应用布局模式:标签页(叠层)/ 行 / 列 / 网格,按未最小化终端数重算行列,再重 fit;
|
|
803
|
+
// 会话 tab 只在「标签页」布局显示(平铺时所有终端同屏可见,tab 冗余)。
|
|
804
|
+
// 最小化只存在于平铺布局(停靠图标在底部最小化栏承接);切去「标签页」前先还原
|
|
805
|
+
// 所有最小化窗格(该布局下窗格头按钮隐藏,无处再最小化,与 mwt 一致)
|
|
806
|
+
function applyTermLayout() {
|
|
807
|
+
const body = $('term-body');
|
|
808
|
+
const tiled = termLayout !== 'tabs';
|
|
809
|
+
if (!tiled) {
|
|
810
|
+
for (const [, e] of termEntries) {
|
|
811
|
+
if (!e.minimized) continue;
|
|
812
|
+
e.minimized = false;
|
|
813
|
+
e.pane.classList.remove('minimized');
|
|
814
|
+
removeMinIcon(e);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
body.classList.toggle('tiled', tiled);
|
|
818
|
+
body.dataset.layout = termLayout;
|
|
819
|
+
syncTabsBar(); // 标签页布局才有 tab 条;平铺时整条收起(选择器已在 topbar)
|
|
820
|
+
document.querySelectorAll('#term-layout-btns button').forEach((b) => {
|
|
821
|
+
b.classList.toggle('active', b.dataset.layout === termLayout);
|
|
822
|
+
});
|
|
823
|
+
const n = [...termEntries.values()].filter((e) => !e.minimized).length;
|
|
824
|
+
if (termLayout === 'grid') {
|
|
825
|
+
body.style.setProperty('--cols', String(n <= 1 ? 1 : (n <= 4 ? 2 : 3)));
|
|
826
|
+
} else if (termLayout === 'columns') {
|
|
827
|
+
body.style.setProperty('--n', String(Math.max(1, n)));
|
|
828
|
+
}
|
|
829
|
+
$('tf-hints').textContent = tiled
|
|
830
|
+
? 'Alt+1~9 切窗 · Alt+←/→ 相邻 · Alt+M 最大化 · Alt+- 最小化 · Alt+0 还原 · Alt+S/R/C/G 布局 · Alt+F 全屏'
|
|
831
|
+
: 'Alt+1~9 切窗 · Alt+←/→ 相邻 · Alt+M 最大化 · Alt+S/R/C/G 布局 · Alt+F 全屏';
|
|
832
|
+
updateTermEmpty();
|
|
833
|
+
updateTermFoot();
|
|
834
|
+
setTimeout(fitVisibleTerms, 60); // 等布局切换稳定再重 fit
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 最大化:单个窗格独占 #term-body(再点/再按 Alt+M 还原)
|
|
838
|
+
function toggleTermMax(id) {
|
|
839
|
+
const ent = termEntries.get(id);
|
|
840
|
+
if (!ent) return;
|
|
841
|
+
if (ent.minimized) restoreTerm(id);
|
|
842
|
+
termMaxId = termMaxId === id ? null : id;
|
|
843
|
+
activateTerm(id);
|
|
844
|
+
syncTermMax();
|
|
845
|
+
}
|
|
846
|
+
function syncTermMax() {
|
|
847
|
+
const body = $('term-body');
|
|
848
|
+
body.classList.toggle('maxed', !!termMaxId);
|
|
849
|
+
for (const [tid, e] of termEntries) {
|
|
850
|
+
const on = tid === termMaxId;
|
|
851
|
+
e.pane.classList.toggle('maxed', on);
|
|
852
|
+
e.maxBtn.textContent = on ? '⤡' : '⤢';
|
|
853
|
+
e.maxBtn.title = on ? '还原(Alt+M)' : '最大化(Alt+M)';
|
|
854
|
+
}
|
|
855
|
+
updateTermFoot();
|
|
856
|
+
setTimeout(fitVisibleTerms, 60);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 最小化停靠图标(底部状态栏的最小化栏):标题 + 活动圆点,点图标还原该窗格;
|
|
860
|
+
// 图标插在「全部还原」钮之前,无图标时整栏收起(与 mwt minimized-bar 一致)
|
|
861
|
+
function addMinIcon(id) {
|
|
862
|
+
const ent = termEntries.get(id);
|
|
863
|
+
if (!ent || ent.minIcon) return;
|
|
864
|
+
const icon = document.createElement('button');
|
|
865
|
+
icon.type = 'button';
|
|
866
|
+
icon.className = 'min-icon';
|
|
867
|
+
icon.title = '还原该终端';
|
|
868
|
+
icon.innerHTML = '<span class="mi-title"></span><span class="mi-dot"></span>';
|
|
869
|
+
icon.querySelector('.mi-title').textContent = ent.label; // textContent 天然防注入
|
|
870
|
+
icon.addEventListener('click', () => activateTerm(id));
|
|
871
|
+
const bar = $('minimized-bar');
|
|
872
|
+
bar.insertBefore(icon, $('min-restore-all'));
|
|
873
|
+
bar.classList.remove('hidden');
|
|
874
|
+
ent.minIcon = icon;
|
|
875
|
+
}
|
|
876
|
+
function removeMinIcon(ent) {
|
|
877
|
+
if (!ent || !ent.minIcon) return;
|
|
878
|
+
ent.minIcon.remove();
|
|
879
|
+
ent.minIcon = null;
|
|
880
|
+
const bar = $('minimized-bar');
|
|
881
|
+
if (!bar.querySelector('.min-icon')) bar.classList.add('hidden');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// 最小化:窗格从平铺布局里收起,停靠为底部最小化栏的图标(点图标或 Alt+0 还原);
|
|
885
|
+
// 仅平铺布局可用——「标签页」布局窗格头按钮本就隐藏(由 tab 条承接),与 mwt 一致
|
|
886
|
+
function minimizeTerm(id) {
|
|
887
|
+
if (termLayout === 'tabs') return;
|
|
888
|
+
const ent = termEntries.get(id);
|
|
889
|
+
if (!ent || ent.minimized) return;
|
|
890
|
+
if (termMaxId === id) { termMaxId = null; syncTermMax(); }
|
|
891
|
+
ent.minimized = true;
|
|
892
|
+
ent.pane.classList.remove('active');
|
|
893
|
+
ent.pane.classList.add('minimized');
|
|
894
|
+
addMinIcon(id);
|
|
895
|
+
if (activeTermId === id) {
|
|
896
|
+
activeTermId = null;
|
|
897
|
+
const next = [...termEntries.entries()].find(([tid, e]) => tid !== id && !e.minimized);
|
|
898
|
+
if (next) activateTerm(next[0]);
|
|
899
|
+
}
|
|
900
|
+
applyTermLayout(); // 可见窗格数有变:重算行列、空态与状态栏
|
|
901
|
+
}
|
|
902
|
+
function restoreTerm(id) {
|
|
903
|
+
const ent = termEntries.get(id);
|
|
904
|
+
if (!ent || !ent.minimized) return;
|
|
905
|
+
ent.minimized = false;
|
|
906
|
+
ent.pane.classList.remove('minimized');
|
|
907
|
+
removeMinIcon(ent);
|
|
908
|
+
applyTermLayout();
|
|
909
|
+
}
|
|
910
|
+
function restoreAllTerms() {
|
|
911
|
+
let changed = false;
|
|
912
|
+
for (const [, e] of termEntries) {
|
|
913
|
+
if (!e.minimized) continue;
|
|
914
|
+
e.minimized = false;
|
|
915
|
+
e.pane.classList.remove('minimized');
|
|
916
|
+
removeMinIcon(e);
|
|
917
|
+
changed = true;
|
|
918
|
+
}
|
|
919
|
+
if (changed) applyTermLayout();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// 相邻切换(Alt+←/→):在未最小化终端里按打开顺序环绕
|
|
923
|
+
function stepTerm(dir) {
|
|
924
|
+
const ids = [...termEntries.entries()].filter(([, e]) => !e.minimized).map(([id]) => id);
|
|
925
|
+
if (!ids.length) return;
|
|
926
|
+
const i = ids.indexOf(activeTermId);
|
|
927
|
+
activateTerm(ids[(i + dir + ids.length) % ids.length]);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// 布局切换:按钮与快捷键共用,偏好持久化;切换即清除最大化(mwt:布局优先于最大化)
|
|
931
|
+
function setTermLayout(layout) {
|
|
932
|
+
if (!TERM_LAYOUTS.includes(layout)) return;
|
|
933
|
+
if (termMaxId) { termMaxId = null; syncTermMax(); }
|
|
934
|
+
termLayout = layout;
|
|
935
|
+
localStorage.setItem('rw_termLayout', layout);
|
|
936
|
+
applyTermLayout();
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function openTerm(t) {
|
|
940
|
+
const ent = ensureTerm(t);
|
|
941
|
+
if (!ent) return;
|
|
942
|
+
// 打开另一扇窗先退出最大化(mwt:新窗口会先还原最大化),否则新窗格被挡在最大化窗后不可见
|
|
943
|
+
if (termMaxId && termMaxId !== t.id) { termMaxId = null; syncTermMax(); }
|
|
944
|
+
applyTermLayout(); // 终端数可能有变:重算布局类与行列
|
|
945
|
+
activateTerm(t.id);
|
|
946
|
+
// 重复打开不重复 attach:回看缓冲只回补一次,避免终端内容翻倍;
|
|
947
|
+
// 通道未就绪时不发(免误报「未连接」),hello 重连后会给所有已开窗格补 attach
|
|
948
|
+
if (!ent.attached) {
|
|
949
|
+
ent.attached = true;
|
|
950
|
+
ent.pendingAttach = true;
|
|
951
|
+
if (wsReady) sendWs({ type: 'pty.attach', sessionId: t.id }); // 取回看缓冲 + 会话存活状态
|
|
952
|
+
}
|
|
953
|
+
termStatus(ent);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 释放终端窗格(不动 tab):✕ 关窗与服务端清理会话时调用
|
|
957
|
+
function disposePane(id) {
|
|
958
|
+
const ent = termEntries.get(id);
|
|
959
|
+
if (!ent) return;
|
|
960
|
+
removeMinIcon(ent); // 最小化中被关闭:同步撤掉停靠图标
|
|
961
|
+
termEntries.delete(id);
|
|
962
|
+
if (termMaxId === id) { termMaxId = null; $('term-body').classList.remove('maxed'); }
|
|
963
|
+
try { ent.xt.dispose(); } catch { /* 已释放 */ }
|
|
964
|
+
ent.pane.remove();
|
|
965
|
+
if (activeTermId === id) {
|
|
966
|
+
activeTermId = null;
|
|
967
|
+
const next = termEntries.keys().next().value;
|
|
968
|
+
if (next) activateTerm(next);
|
|
969
|
+
}
|
|
970
|
+
applyTermLayout(); // 终端数有变:重算并排列数
|
|
971
|
+
updateTermEmpty();
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
$('term-body').addEventListener('click', focusTerm);
|
|
975
|
+
// 布局切换:并排 / 横排 / 网格 / 标签页,偏好持久化
|
|
976
|
+
document.querySelectorAll('#term-layout-btns button').forEach((btn) => {
|
|
977
|
+
btn.addEventListener('click', () => setTermLayout(btn.dataset.layout));
|
|
978
|
+
});
|
|
979
|
+
$('min-restore-all').addEventListener('click', restoreAllTerms);
|
|
980
|
+
$('empty-new-session').addEventListener('click', () => $('hdr-new-session').click());
|
|
981
|
+
|
|
982
|
+
// ---------- 触屏键盘代理:隐藏 textarea 承接击键;换行统一归一成 \r 交给 PTY ----------
|
|
983
|
+
// 常驻一个哨兵字符:手机键盘在空 textarea 上按退格不产生任何事件,
|
|
984
|
+
// 哨兵被删掉即代表一次 Backspace(发 \x7f),随后立即补回
|
|
985
|
+
const TERM_SENTINEL = '\u00a0';
|
|
986
|
+
|
|
987
|
+
function resetTermInput() {
|
|
988
|
+
const el = $('term-input');
|
|
989
|
+
el.value = TERM_SENTINEL;
|
|
990
|
+
try { el.setSelectionRange(1, 1); } catch { /* 个别浏览器不支持,忽略 */ }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
$('term-input').addEventListener('input', (ev) => {
|
|
994
|
+
const el = ev.target;
|
|
995
|
+
let val = el.value;
|
|
996
|
+
if (!val) {
|
|
997
|
+
// 哨兵被删 = 用户按了退格
|
|
998
|
+
if (activeTermId) sendWs({ type: 'session.input', sessionId: activeTermId, data: '\x7f' });
|
|
999
|
+
resetTermInput();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (val.startsWith(TERM_SENTINEL)) val = val.slice(TERM_SENTINEL.length);
|
|
1003
|
+
resetTermInput();
|
|
1004
|
+
if (activeTermId && val) {
|
|
1005
|
+
sendWs({ type: 'session.input', sessionId: activeTermId, data: val.replace(/\n/g, '\r') });
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
$('term-input').addEventListener('keydown', (ev) => {
|
|
1009
|
+
if (ev.key === 'Enter') {
|
|
1010
|
+
ev.preventDefault(); // 拦住换行,直接发回车,避免 input 事件补发 \n
|
|
1011
|
+
if (activeTermId) sendWs({ type: 'session.input', sessionId: activeTermId, data: '\r' });
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
document.querySelectorAll('#term-bar button').forEach((btn) => {
|
|
1015
|
+
btn.addEventListener('click', () => {
|
|
1016
|
+
if (!activeTermId) return;
|
|
1017
|
+
sendWs({ type: 'session.input', sessionId: activeTermId, data: KEY_SEQ[btn.dataset.key] });
|
|
1018
|
+
focusTerm();
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// ---------- 快捷键(Alt/⌥ 组合,PC 物理键盘):捕获阶段拦截并 preventDefault/stopPropagation,
|
|
1023
|
+
// 避免 xterm 把这些按键转发进 pty;用 ev.code 匹配(mac 下 Alt+字母的 ev.key 会变特殊字符) ----------
|
|
1024
|
+
document.addEventListener('keydown', (ev) => {
|
|
1025
|
+
if (!termEntries.size) return;
|
|
1026
|
+
// 焦点在页头表单(密钥输入/下拉)时不抢键;终端与触屏键盘代理(textarea)照常响应
|
|
1027
|
+
const tag = ev.target && ev.target.tagName;
|
|
1028
|
+
if (tag === 'INPUT' || tag === 'SELECT') return;
|
|
1029
|
+
if (!ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return;
|
|
1030
|
+
const LAYOUT_KEYS = { KeyS: 'tabs', KeyR: 'rows', KeyC: 'columns', KeyG: 'grid' };
|
|
1031
|
+
let handled = true;
|
|
1032
|
+
if (/^Digit[1-9]$/.test(ev.code)) { // Alt+1~9:切到第 N 个终端(按打开顺序)
|
|
1033
|
+
const ids = [...termEntries.keys()];
|
|
1034
|
+
const id = ids[Number(ev.code.slice(5)) - 1];
|
|
1035
|
+
if (id) activateTerm(id); else handled = false;
|
|
1036
|
+
} else if (ev.code === 'ArrowRight' || ev.code === 'ArrowLeft') {
|
|
1037
|
+
stepTerm(ev.code === 'ArrowRight' ? 1 : -1);
|
|
1038
|
+
} else if (ev.code === 'KeyM') {
|
|
1039
|
+
if (activeTermId) toggleTermMax(activeTermId); else handled = false;
|
|
1040
|
+
} else if (ev.code === 'Minus') {
|
|
1041
|
+
if (activeTermId) minimizeTerm(activeTermId); else handled = false;
|
|
1042
|
+
} else if (ev.code === 'Digit0') {
|
|
1043
|
+
restoreAllTerms();
|
|
1044
|
+
} else if (ev.code === 'KeyF') {
|
|
1045
|
+
toggleFs(document.documentElement);
|
|
1046
|
+
} else if (LAYOUT_KEYS[ev.code]) {
|
|
1047
|
+
setTermLayout(LAYOUT_KEYS[ev.code]);
|
|
1048
|
+
} else {
|
|
1049
|
+
handled = false;
|
|
1050
|
+
}
|
|
1051
|
+
if (handled) { ev.preventDefault(); ev.stopPropagation(); }
|
|
1052
|
+
}, true);
|
|
1053
|
+
|
|
1054
|
+
// ---------- 全屏(Fullscreen API:整页全屏,入口只有页头按钮与 Alt+F)----------
|
|
1055
|
+
function fsElement() { return document.fullscreenElement || document.webkitFullscreenElement; }
|
|
1056
|
+
function exitFs() {
|
|
1057
|
+
if (!fsElement()) return;
|
|
1058
|
+
(document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
|
1059
|
+
}
|
|
1060
|
+
function toggleFs(el) {
|
|
1061
|
+
if (fsElement()) { exitFs(); return; }
|
|
1062
|
+
try {
|
|
1063
|
+
const p = el.requestFullscreen ? el.requestFullscreen()
|
|
1064
|
+
: (el.webkitRequestFullscreen ? el.webkitRequestFullscreen() : null);
|
|
1065
|
+
if (p && p.catch) p.catch(() => toast('进入全屏失败(浏览器限制,请重试)'));
|
|
1066
|
+
else if (!p) toast('当前浏览器不支持全屏');
|
|
1067
|
+
} catch { toast('当前浏览器不支持全屏'); }
|
|
1068
|
+
}
|
|
1069
|
+
function syncFsButtons() {
|
|
1070
|
+
const on = !!fsElement();
|
|
1071
|
+
$('hdr-fullscreen').querySelector('span').textContent = on ? '退出全屏' : '全屏';
|
|
1072
|
+
$('ic-fs').setAttribute('href', on ? '#i-shrink' : '#i-expand');
|
|
1073
|
+
}
|
|
1074
|
+
document.addEventListener('fullscreenchange', () => {
|
|
1075
|
+
syncFsButtons();
|
|
1076
|
+
setTimeout(fitVisibleTerms, 120); // 全屏进出后视口尺寸变了,终端重 fit
|
|
1077
|
+
});
|
|
1078
|
+
document.addEventListener('webkitfullscreenchange', syncFsButtons);
|
|
1079
|
+
$('hdr-fullscreen').addEventListener('click', () => toggleFs(document.documentElement));
|
|
1080
|
+
// 屏幕旋转/键盘弹起都会改变可用高度,延迟一点等布局稳定再 fit(有终端时)
|
|
1081
|
+
window.addEventListener('resize', () => { if (termEntries.size) setTimeout(fitVisibleTerms, 120); });
|
|
1082
|
+
|
|
1083
|
+
// ---------- 轮询回退(仅 WS 不可用时生效)----------
|
|
1084
|
+
async function refresh() {
|
|
1085
|
+
try {
|
|
1086
|
+
const [{ sessions }, { workers }] = await Promise.all([
|
|
1087
|
+
api('/api/sessions?limit=50'),
|
|
1088
|
+
api('/api/worker/status'),
|
|
1089
|
+
]);
|
|
1090
|
+
sessionCache.clear();
|
|
1091
|
+
for (const t of sessions) sessionCache.set(t.id, t);
|
|
1092
|
+
gotSnapshot = true;
|
|
1093
|
+
syncTabs();
|
|
1094
|
+
renderWorkers(workers);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
toast(err.message);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
|
|
1100
|
+
|
|
1101
|
+
applyTheme(currentTheme()); // 主题初始化:同步按钮文案与 theme-color(head 已设好 data-theme)
|
|
1102
|
+
updateTermEmpty(); // 首屏空态提示(无打开的终端时)
|
|
1103
|
+
applyTermLayout(); // 持久化的布局偏好首屏生效(平铺布局藏 tab 条、同步快捷键提示)
|
|
1104
|
+
connectWS();
|
|
1105
|
+
// 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
|
|
1106
|
+
if (hasSecret()) refresh();
|
|
1107
|
+
else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
|