Files
Nexus/web/app/terminal.html
T
Your Name e8c1acba9f fix: 审计8个FINDING修复 (IDE编辑器+终端)
- H-01 [MEDIUM] esc() 加引号转义('"\') 防止onclick XSS
- H-02 [MEDIUM] initialPath 路径白名单校验(仅允许字母数字/.-_)
- H-03 [LOW] doNewFile 文件名白名单正则(替代简单的includes('/'))
- H-08 [LOW] Monaco CDN SRI — 已知限制, 版本锁定@0.45.0
- H-13 [MEDIUM] showModal 移除已删除的modalTextarea引用
- H-20 [LOW] initialPath仅首次连接cd, 不影响恢复的标签
- H-26 [MEDIUM] esc()跨文件一致性 — files.html对齐terminal.html
- H-30 [LOW] esc()注释更新
2026-05-31 02:06:42 +08:00

835 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style><style>.xterm{padding:4px}#terminalArea{flex:1;min-height:0;position:relative}.term-instance{position:absolute;inset:0}.term-instance.active{z-index:1}.term-instance:not(.active){z-index:0;pointer-events:none;opacity:0}#ctxMenu{position:fixed;z-index:200;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,.5)}body.fs-mode aside,body.fs-mode header,body.fs-mode #tabBar,body.fs-mode #cmdBar{display:none!important}body.fs-mode #terminalArea{position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<aside x-data="{open:true}" x-show="open" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab Bar -->
<div id="tabBar" class="bg-slate-900/80 border-b border-slate-800 px-2 py-1 flex items-center gap-0.5 overflow-x-auto shrink-0">
<div id="tabs" class="flex items-center gap-0.5"></div>
<button onclick="newSession()" class="shrink-0 px-1.5 py-0.5 text-slate-500 hover:text-slate-300 text-xs rounded transition ml-1" title="新会话">+</button>
</div>
<!-- Toolbar -->
<header class="bg-slate-900 border-b border-slate-800 px-4 py-1.5 flex items-center justify-between h-8 shrink-0">
<div class="flex items-center gap-3">
<span id="connServer" class="text-white text-xs font-semibold">...</span>
<span id="connPath" class="text-slate-500 text-xs font-mono truncate max-w-64"></span>
<span id="connStatus" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500">
<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接
</span>
<span id="connUptime" class="text-slate-600 text-xs hidden"></span>
<span id="connPing" class="text-slate-600 text-xs hidden"></span>
</div>
<div class="flex items-center gap-1.5">
<!-- Font size -->
<button onclick="changeFontSize(-1)" class="px-1.5 py-0.5 text-slate-500 hover:text-white text-xs rounded transition" title="缩小字体">A</button>
<span id="fontSizeLabel" class="text-slate-600 text-xs">14</span>
<button onclick="changeFontSize(1)" class="px-1.5 py-0.5 text-slate-500 hover:text-white text-xs rounded transition" title="放大字体">A+</button>
<!-- Scrollback -->
<select id="scrollbackSel" onchange="changeScrollback(this.value)" class="bg-slate-800 border border-slate-700 rounded text-slate-400 text-xs px-1 py-0.5">
<option value="1000">1K 行</option><option value="5000">5K 行</option><option value="10000">10K 行</option><option value="50000">50K 行</option>
</select>
<button onclick="toggleFullscreen()" class="px-2 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">全屏</button>
<button onclick="disconnect()" class="px-2 py-0.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition" id="disconnectBtn">断开</button>
</div>
</header>
<!-- Terminal Area -->
<div id="terminalArea" class="flex-1 min-h-0 flex flex-col relative">
<div id="termContainer" class="flex-1 min-h-0 relative">
<!-- term instances injected here -->
</div>
<div id="disconnectOverlay" class="hidden absolute inset-0 bg-slate-950/80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
<div id="disconnectMsg" class="text-slate-300 text-lg mb-1">连接已断开</div>
<div id="reconnectCountdown" class="text-slate-500 text-sm mb-4 hidden"></div>
<div class="flex gap-3">
<button onclick="reconnect()" class="px-5 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">重新连接</button>
<a href="/app/servers.html" class="px-5 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
</div>
</div>
<!-- Quick Commands -->
<div id="quickCmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-1.5 flex items-center gap-1.5 overflow-x-auto shrink-0">
<span class="text-slate-600 text-xs shrink-0"></span>
<div id="quickCmdChips" class="flex items-center gap-1 overflow-x-auto"></div>
<button onclick="addQuickCmd()" class="shrink-0 text-slate-600 hover:text-slate-300 text-xs px-1">+</button>
</div>
<!-- Command Bar -->
<div id="cmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-2 flex items-center gap-2 shrink-0 opacity-50 pointer-events-none">
<span class="text-brand-light text-sm font-mono select-none shrink-0"></span>
<input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false">
<button onclick="sendCmd()" class="px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded transition">发送</button>
<div class="flex gap-1 border-l border-slate-700 pl-2">
<button onclick="sendCtrl('c')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+C">^C</button>
<button onclick="sendCtrl('d')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+D">^D</button>
<button onclick="sendKey('\t')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Tab补全">Tab</button>
</div>
</div>
</div>
</div>
<!-- Right Panel -->
<aside id="rightPanel" class="w-64 bg-slate-900 border-l border-slate-800 flex flex-col shrink-0">
<div class="p-3 border-b border-slate-800">
<input id="serverSearch" placeholder="搜索服务器…" oninput="filterServerList()" class="w-full px-2 py-1.5 bg-slate-800 border border-slate-700 rounded text-slate-300 text-xs placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-brand/50">
</div>
<div id="serverListPanel" class="flex-1 overflow-y-auto px-2 py-1 space-y-0.5" onscroll=""></div>
</div>
</aside>
<!-- Right-Click Context Menu -->
<div id="ctxMenu" class="hidden bg-slate-900 border border-slate-700 rounded-lg py-1" onclick="event.stopPropagation()">
<button onclick="ctxCopy();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">📋 复制</button>
<button onclick="ctxPaste();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">📄 粘贴</button>
<button onclick="ctxClear();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">🧹 清屏</button>
<button onclick="ctxSelectAll();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">✂ 全选</button>
</div>
<!-- QuickCmd Modal -->
<div id="qcModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-sm p-5 space-y-3">
<h2 id="qcTitle" class="text-white font-semibold">添加命令</h2>
<input id="qcName" placeholder="命令名称" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500">
<textarea id="qcCmd" placeholder="命令内容(含 \\r 回车)" rows="3" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono placeholder-slate-500"></textarea>
<input type="hidden" id="qcEditId">
<div class="flex gap-2">
<button onclick="saveQuickCmd()" class="flex-1 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button>
<button onclick="document.getElementById('qcModal').classList.add('hidden')" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
</div>
</div>
</div>
<!-- Server Selector Modal (for new tab) -->
<div id="serverModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-sm p-4 space-y-3 max-h-[60vh] flex flex-col">
<h2 class="text-white font-semibold text-sm">选择服务器</h2>
<input id="serverModalSearch" placeholder="搜索…" oninput="filterServerModal()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500">
<div id="serverModalList" class="flex-1 overflow-y-auto space-y-1"></div>
</div>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script src="/app/vendor/xterm.js"></script>
<script src="/app/vendor/xterm-addon-fit.js"></script>
<script src="/app/vendor/xterm-addon-web-links.js"></script>
<script>
initLayout('servers');
// ── Parse params ──
const params = new URLSearchParams(window.location.search);
const initialServerId = parseInt(params.get('server_id'));
const _rawPath = params.get('path') || '';
// H-02: Strict path whitelist — only allow safe characters
const initialPath = _rawPath && /^[a-zA-Z0-9/._\-]+$/.test(_rawPath) ? _rawPath : '';
let _initialCdSent = false; // H-20: Only cd on first connection, not restored tabs
if (!initialServerId) window.location.href = '/app/servers.html';
// ── localStorage keys ──
const K_FONT = 'nexus_term_fontSize';
const K_SCROLL = 'nexus_term_scrollback';
const K_HIST = 'nexus_term_history';
const K_CMDS = 'nexus_terminal_cmds';
const K_TABS = 'nexus_term_tabs';
const BUILTIN_CMDS = [
{ id: '__sys__', name: '系统状态', cmd: "systemctl status --no-pager 2>&1 | head -20\r" },
{ id: '__log__', name: '系统日志', cmd: "journalctl -n 30 --no-pager 2>&1\r" },
{ id: '__disk__', name: '磁盘使用', cmd: "df -h\r" },
{ id: '__mem__', name: '内存使用', cmd: "free -h\r" },
{ id: '__proc__', name: '进程列表', cmd: "ps aux --sort=-%mem 2>&1 | head -15\r" },
];
// ── Session state ──
let sessions = []; // [{id,serverId,serverName,ws,term,fitAddon,div,cmdHistory,cmdIdx}]
let activeIdx = -1;
let reconnectTimer = null;
let reconnectAttempt = 0;
let manualDisconnect = false;
let uptimeInterval = null;
let pingInterval = null;
let _serversCache = [];
function active() { return activeIdx >= 0 ? sessions[activeIdx] : null; }
// ── Tab management ──
function switchTab(idx) {
if (idx === activeIdx) return;
sessions.forEach((s,i) => {
s.div.classList.toggle('active', i === idx);
s.div.classList.toggle('hidden', i !== idx);
});
activeIdx = idx;
updateTabUI();
updateConnInfo();
if (idx >= 0) {
setTimeout(() => { try { sessions[idx].fitAddon.fit(); } catch(e){} }, 50);
sessions[idx].term.focus();
}
}
function closeTab(idx) {
const s = sessions[idx];
if (s.ws) { try { s.ws.close(1000); } catch(e){} s.ws = null; }
if (s.term) { try { s.term.dispose(); } catch(e){} }
s.div.remove();
sessions.splice(idx, 1);
saveTabs();
if (sessions.length === 0) {
activeIdx = -1;
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('disconnectMsg').textContent = '所有会话已关闭';
updateConnInfo();
return;
}
const newIdx = Math.min(idx, sessions.length - 1);
switchTab(newIdx);
}
async function newSession(serverId) {
if (sessions.length >= 10) { toast('warning', '最多10个标签'); return; }
if (!serverId) {
showServerModal();
return;
}
// Check duplicate
if (sessions.some(s => s.serverId === serverId)) {
const existing = sessions.findIndex(s => s.serverId === serverId);
switchTab(existing);
return;
}
const server = _serversCache.find(s => s.id === serverId);
const name = server ? server.name : '#' + serverId;
const sid = 'term_' + Date.now();
const div = document.createElement('div');
div.id = sid;
div.className = 'term-instance hidden';
document.getElementById('termContainer').prepend(div);
const term = createTerminal();
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(div);
// Restore font
const fs = parseInt(localStorage.getItem(K_FONT) || '14');
term.options.fontSize = fs;
// Restore scrollback
const sb = parseInt(localStorage.getItem(K_SCROLL) || '1000');
term.options.scrollback = sb;
// Restore history
let cmdHistory = [];
try { cmdHistory = JSON.parse(localStorage.getItem(K_HIST) || '[]'); } catch(e) {}
sessions.push({
id: sid, serverId, serverName: name,
ws: null, term, fitAddon, div, cmdHistory, cmdIdx: -1
});
saveTabs();
const newIdx = sessions.length - 1;
switchTab(newIdx);
await connectSession(newIdx);
// Setup term events
setupTermEvents(term, newIdx);
setTimeout(() => { try { fitAddon.fit(); } catch(e){} }, 100);
new ResizeObserver(() => { try { fitAddon.fit(); } catch(e){} }).observe(div);
}
function saveTabs() {
const data = sessions.map(s => ({ serverId: s.serverId, serverName: s.serverName }));
localStorage.setItem(K_TABS, JSON.stringify(data));
}
// ── Terminal factory ──
function createTerminal() {
return new Terminal({
cursorBlink: true, cursorStyle: 'bar', fontSize: 14,
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Menlo,monospace',
theme: { background:'#0b1120', foreground:'#e2e8f0', cursor:'#7c8bf4', selectionBackground:'#334155',
black:'#1e293b', red:'#f87171', green:'#4ade80', yellow:'#fbbf24',
blue:'#60a5fa', magenta:'#c084fc', cyan:'#22d3ee', white:'#e2e8f0',
brightBlack:'#64748b', brightRed:'#fca5a5', brightGreen:'#86efac',
brightYellow:'#fde047', brightBlue:'#93c5fd', brightMagenta:'#d8b4fe',
brightCyan:'#67e8f9', brightWhite:'#f8fafc',
},
allowProposedApi: true,
});
}
function setupTermEvents(term, idx) {
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.onData((data) => {
const s = sessions[idx];
if (s && s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'DATA', data }));
}
});
term.onResize(({ cols, rows }) => {
const s = sessions[idx];
if (s && s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }));
}
});
// Right-click context menu
term.element.addEventListener('contextmenu', (e) => {
e.preventDefault();
showCtxMenu(e.clientX, e.clientY);
});
// Title change (OSC path tracking)
term.onTitleChange((title) => {
const s = sessions[idx];
if (s && idx === activeIdx) {
const m = title.match(/^(\/.+)/);
if (m) document.getElementById('connPath').textContent = m[1];
}
});
}
// ── Connect session ──
async function connectSession(idx) {
const s = sessions[idx];
if (!s) return;
let websshToken;
try {
const res = await apiFetch(API+'/auth/webssh-token', {
method: 'POST', headers: apiHeadersJSON(),
body: JSON.stringify({ server_id: s.serverId }),
});
if (!res) { s.term.writeln('\r\n\x1b[31m认证失败\x1b[0m'); return; }
const data = await res.json();
websshToken = data.webssh_token;
} catch (e) {
s.term.writeln('\r\n\x1b[31m无法获取终端令牌\x1b[0m');
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws/terminal/${s.serverId}?token=${encodeURIComponent(websshToken)}`;
s.ws = new WebSocket(url);
s.ws.onopen = () => {
if (idx !== activeIdx) return;
manualDisconnect = false;
reconnectAttempt = 0;
reconnectTimer = null;
document.getElementById('disconnectOverlay').classList.add('hidden');
document.getElementById('cmdBar').classList.remove('opacity-50','pointer-events-none');
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>已连接';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-green-900/30 text-green-400';
// Start uptime counter
const startTime = Date.now();
if (uptimeInterval) clearInterval(uptimeInterval);
document.getElementById('connUptime').classList.remove('hidden');
uptimeInterval = setInterval(() => {
const sec = Math.floor((Date.now() - startTime) / 1000);
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s2 = sec % 60;
document.getElementById('connUptime').textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s2).padStart(2,'0')}`;
}, 1000);
// Start ping
document.getElementById('connPing').classList.remove('hidden');
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'PING', ts: Date.now() }));
}
}, 5000);
s.term.focus();
};
s.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'TERMINAL_INIT':
s.serverName = msg.server_name || s.serverName;
document.title = `Nexus — ${s.serverName}`;
updateConnInfo();
updateTabUI();
const d = s.fitAddon.proposeDimensions();
if (d && s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
}
// H-20: Auto-cd to initial path only on first connection (not restored tabs)
if (initialPath && !_initialCdSent && s.ws && s.ws.readyState === WebSocket.OPEN) {
_initialCdSent = true;
setTimeout(() => {
s.ws.send(JSON.stringify({ type: 'DATA', data: `cd ${initialPath}\r` }));
}, 200);
}
break;
case 'DATA':
s.term.write(msg.data);
break;
case 'ERROR':
s.term.write('\r\n\x1b[31m⚠ ' + esc(msg.message) + '\x1b[0m\r\n');
break;
case 'PONG':
const rtt = Date.now() - (msg.ts || 0);
if (idx === activeIdx) document.getElementById('connPing').textContent = rtt + 'ms';
break;
case 'CLOSE':
s.term.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n');
break;
}
} catch (err) {
s.term.write(e.data);
}
};
s.ws.onclose = (ev) => {
if (idx === activeIdx) {
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500';
document.getElementById('connUptime').classList.add('hidden');
document.getElementById('connPing').classList.add('hidden');
if (uptimeInterval) { clearInterval(uptimeInterval); uptimeInterval = null; }
if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
}
if (ev.code === 4001) {
s.term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
setTimeout(() => location.href = '/app/login.html', 2000);
return;
}
if (ev.code === 4003) {
s.term.write('\r\n\x1b[31m⚠ 授权失败: ' + esc(ev.reason || 'SSH凭据未配置') + '\x1b[0m\r\n');
return;
}
s.ws = null;
// Auto-reconnect
if (!manualDisconnect && ev.code !== 1000 && ev.code !== 4001 && ev.code !== 4003) {
startReconnect(idx);
} else if (!manualDisconnect) {
if (idx === activeIdx) {
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('disconnectMsg').textContent = '连接已断开';
document.getElementById('reconnectCountdown').classList.add('hidden');
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
}
}
};
s.ws.onerror = () => {
if (idx === activeIdx) {
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
}
};
}
// ── Auto-reconnect ──
function startReconnect(idx) {
if (idx !== activeIdx) return;
reconnectAttempt++;
const delay = Math.min(reconnectAttempt * 1000, 30000);
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('disconnectMsg').textContent = '连接已断开';
const cd = document.getElementById('reconnectCountdown');
cd.classList.remove('hidden');
cd.textContent = `${Math.ceil(delay/1000)} 秒后自动重连…`;
reconnectTimer = setTimeout(() => {
if (idx !== activeIdx) return;
reconnectSession(idx);
}, delay);
}
async function reconnectSession(idx) {
const s = sessions[idx];
if (!s) return;
document.getElementById('disconnectOverlay').classList.add('hidden');
document.getElementById('reconnectCountdown').classList.add('hidden');
manualDisconnect = false;
reconnectAttempt = 0;
s.term.clear();
await connectSession(idx);
}
function reconnect() {
if (activeIdx < 0) return;
reconnectAttempt = 0;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
reconnectSession(activeIdx);
}
// ── Disconnect ──
function disconnect() {
manualDisconnect = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
document.getElementById('reconnectCountdown').classList.add('hidden');
const s = active();
if (s && s.ws) {
if (s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'CLOSE' }));
s.ws.close(1000, 'User disconnect');
}
s.ws = null;
}
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('disconnectMsg').textContent = '连接已断开';
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
if (uptimeInterval) { clearInterval(uptimeInterval); uptimeInterval = null; }
if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
document.getElementById('connUptime').classList.add('hidden');
document.getElementById('connPing').classList.add('hidden');
}
// ── Update UI ──
function updateTabUI() {
document.getElementById('tabs').innerHTML = sessions.map((s, i) => {
const active = i === activeIdx;
return `<div class="flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition ${active ? 'bg-brand/20 text-brand-light' : 'text-slate-400 hover:bg-slate-800'}"
onclick="switchTab(${i})" title="${esc(s.serverName)}">
<span class="w-1.5 h-1.5 rounded-full ${s.ws && s.ws.readyState===1 ? 'bg-green-400' : 'bg-slate-600'}"></span>
<span class="max-w-28 truncate">${esc(s.serverName)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="text-slate-600 hover:text-red-400 ml-0.5">&times;</button>
</div>`;
}).join('');
}
function updateConnInfo() {
const s = active();
if (s) {
document.getElementById('connServer').textContent = s.serverName;
if (s.ws && s.ws.readyState === WebSocket.OPEN) {
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>已连接';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-green-900/30 text-green-400';
}
} else {
document.getElementById('connServer').textContent = '...';
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500';
}
updateTabUI();
}
// ── Command Input ──
const cmdInput = document.getElementById('cmdInput');
cmdInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendCmd();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const s = active();
if (!s) return;
const hist = s.cmdHistory || [];
if (s.cmdIdx === undefined || s.cmdIdx === null) s.cmdIdx = -1;
if (s.cmdIdx < hist.length - 1) {
s.cmdIdx++;
cmdInput.value = hist[hist.length - 1 - s.cmdIdx];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const s = active();
if (!s) return;
const hist = s.cmdHistory || [];
if (s.cmdIdx === undefined || s.cmdIdx === null) s.cmdIdx = -1;
if (s.cmdIdx > 0) {
s.cmdIdx--;
cmdInput.value = hist[hist.length - 1 - s.cmdIdx];
} else {
s.cmdIdx = -1;
cmdInput.value = '';
}
}
});
function sendCmd() {
const s = active();
if (!s || !s.ws || s.ws.readyState !== WebSocket.OPEN) return;
const cmd = cmdInput.value;
if (!cmd) return;
if (!s.cmdHistory) s.cmdHistory = [];
s.cmdHistory.push(cmd);
s.cmdIdx = -1;
s.ws.send(JSON.stringify({ type: 'DATA', data: cmd + '\r' }));
cmdInput.value = '';
// Persist to localStorage
let hist = [];
try { hist = JSON.parse(localStorage.getItem(K_HIST) || '[]'); } catch(e) {}
if (hist.length >= 200) hist.shift();
hist.push(cmd);
localStorage.setItem(K_HIST, JSON.stringify(hist));
}
function sendCtrl(key) {
const s = active();
if (!s || !s.ws || s.ws.readyState !== WebSocket.OPEN) return;
s.ws.send(JSON.stringify({ type: 'DATA', data: String.fromCharCode(key.toLowerCase().charCodeAt(0) - 96) }));
}
function sendKey(key) {
const s = active();
if (!s || !s.ws || s.ws.readyState !== WebSocket.OPEN) return;
s.ws.send(JSON.stringify({ type: 'DATA', data: key }));
}
// ── Font size ──
function changeFontSize(delta) {
const s = active();
if (!s) return;
let fs = s.term.options.fontSize || 14;
fs = Math.max(10, Math.min(24, fs + delta));
s.term.options.fontSize = fs;
document.getElementById('fontSizeLabel').textContent = fs;
localStorage.setItem(K_FONT, String(fs));
try { s.fitAddon.fit(); } catch(e) {}
}
// ── Scrollback ──
function changeScrollback(val) {
const sb = parseInt(val);
localStorage.setItem(K_SCROLL, String(sb));
sessions.forEach(s => { s.term.options.scrollback = sb; });
}
// ── Fullscreen ──
function toggleFullscreen() {
const body = document.body;
const isFull = body.classList.toggle('fs-mode');
const s = active();
if (s) setTimeout(() => { try { s.fitAddon.fit(); } catch(e){} }, 50);
}
// ── Right-click context menu ──
function showCtxMenu(x, y) {
const menu = document.getElementById('ctxMenu');
menu.style.left = x + 'px';
menu.style.top = y + 'px';
menu.classList.remove('hidden');
}
function hideCtxMenu() { document.getElementById('ctxMenu').classList.add('hidden'); }
function ctxCopy() {
const sel = window.getSelection();
if (sel && sel.toString()) {
navigator.clipboard.writeText(sel.toString()).catch(() => {});
}
hideCtxMenu();
}
function ctxPaste() {
navigator.clipboard.readText().then(text => {
const s = active();
if (s && s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'DATA', data: text }));
}
}).catch(() => {});
hideCtxMenu();
}
function ctxClear() {
const s = active();
if (s) {
s.term.clear();
if (s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'DATA', data: 'clear\r' }));
}
}
hideCtxMenu();
}
function ctxSelectAll() {
const s = active();
if (s) s.term.selectAll();
hideCtxMenu();
}
document.addEventListener('click', () => hideCtxMenu());
// ── Keyboard shortcuts ──
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'l') {
// Only when terminal is focused
const s = active();
if (s && document.activeElement && document.activeElement.closest('#terminalArea')) {
e.preventDefault();
s.term.clear();
if (s.ws && s.ws.readyState === WebSocket.OPEN) {
s.ws.send(JSON.stringify({ type: 'DATA', data: 'clear\r' }));
}
}
}
if (e.ctrlKey && (e.key === '=' || e.key === '+')) { e.preventDefault(); changeFontSize(1); }
if (e.ctrlKey && e.key === '-') { e.preventDefault(); changeFontSize(-1); }
if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); toggleFullscreen(); }
});
// ── Right Panel: Server List ──
async function loadServerList() {
try {
const r = await apiFetch(API + '/servers/?per_page=200');
if (!r) return;
const data = await r.json();
_serversCache = data.items || [];
renderServerList();
} catch(e) { console.warn('Server list load error:', e); }
}
function renderServerList() {
const el = document.getElementById('serverListPanel');
el.innerHTML = _serversCache.map(s => {
const online = s.is_online;
return `<a href="javascript:void(0)" onclick="newSession(${s.id})" class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition text-slate-400 hover:text-white hover:bg-slate-800">
<span class="w-2 h-2 rounded-full shrink-0 ${online ? 'bg-green-400' : 'bg-slate-600'}"></span>
<span class="truncate flex-1">${esc(s.name)}</span>
<span class="text-slate-600 text-xs shrink-0">${esc(s.domain)}</span>
</a>`;
}).join('');
}
function filterServerList() {
const q = (document.getElementById('serverSearch').value || '').toLowerCase().trim();
document.querySelectorAll('#serverListPanel a').forEach(a => {
a.style.display = (!q || (a.textContent || '').toLowerCase().includes(q)) ? '' : 'none';
});
}
// ── Quick Commands ──
function loadQuickCmds() {
let userCmds = [];
try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]'); } catch(e) {}
const all = [...userCmds, ...BUILTIN_CMDS];
const el = document.getElementById('quickCmdChips');
el.innerHTML = all.map((c, i) => {
const isBuiltin = c.id.startsWith('__');
return `<span class="group flex items-center gap-0.5 px-2 py-0.5 rounded text-xs whitespace-nowrap cursor-pointer transition qc-chip
${isBuiltin ? 'bg-slate-800/50 text-slate-400 hover:bg-slate-800 hover:text-slate-200' : 'bg-brand/10 text-brand-light hover:bg-brand/20'}"
data-cmd="${esc(c.cmd).replace(/"/g,'&quot;')}">
${esc(c.name)}
${!isBuiltin ? `<button class="hidden group-hover:inline text-slate-500 hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
</span>`;
}).join('');
// Delegate clicks
el.querySelectorAll('.qc-chip').forEach(chip => {
chip.onclick = () => { execQuickCmd(chip.dataset.cmd); };
});
el.querySelectorAll('.qc-del-chip').forEach(btn => {
btn.onclick = (e) => { e.stopPropagation(); deleteQuickCmd(btn.dataset.id); };
});
}
function execQuickCmd(cmd) {
const s = active();
if (!s || !s.ws || s.ws.readyState !== WebSocket.OPEN) {
toast('warning','终端未连接');
return;
}
s.ws.send(JSON.stringify({ type: 'DATA', data: cmd }));
s.term.focus();
}
function addQuickCmd() {
document.getElementById('qcTitle').textContent = '添加命令';
document.getElementById('qcName').value = '';
document.getElementById('qcCmd').value = '';
document.getElementById('qcEditId').value = '';
document.getElementById('qcModal').classList.remove('hidden');
document.getElementById('qcName').focus();
}
function saveQuickCmd() {
const name = document.getElementById('qcName').value.trim();
const cmd = document.getElementById('qcCmd').value.trim();
const editId = document.getElementById('qcEditId').value;
if (!name || !cmd) { toast('warning','名称和命令不能为空'); return; }
let userCmds = [];
try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]'); } catch(e) {}
if (editId) {
const idx = userCmds.findIndex(c => c.id === editId);
if (idx >= 0) { userCmds[idx].name = name; userCmds[idx].cmd = cmd; }
} else {
userCmds.push({ id: 'qc_' + Date.now(), name, cmd });
}
localStorage.setItem(K_CMDS, JSON.stringify(userCmds));
document.getElementById('qcModal').classList.add('hidden');
loadQuickCmds();
toast('success', editId ? '命令已更新' : '命令已添加');
}
function deleteQuickCmd(id) {
if (!confirm('确定删除此命令?')) return;
let userCmds = [];
try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]'); } catch(e) {}
userCmds = userCmds.filter(c => c.id !== id);
localStorage.setItem(K_CMDS, JSON.stringify(userCmds));
loadQuickCmds();
toast('success', '命令已删除');
}
// ── Server Modal ──
function showServerModal() {
document.getElementById('serverModal').classList.remove('hidden');
document.getElementById('serverModalSearch').value = '';
renderServerModal();
}
function renderServerModal(filter) {
const q = (filter || '').toLowerCase().trim();
const list = _serversCache.filter(s => !q || s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q));
document.getElementById('serverModalList').innerHTML = list.map(s =>
`<div class="flex items-center gap-2 px-2 py-1.5 rounded text-sm cursor-pointer text-slate-300 hover:bg-slate-800 transition"
onclick="document.getElementById('serverModal').classList.add('hidden');newSession(${s.id})">
<span class="w-2 h-2 rounded-full shrink-0 ${s.is_online?'bg-green-400':'bg-slate-600'}"></span>
<span class="truncate">${esc(s.name)}</span><span class="text-slate-600 text-xs">${esc(s.domain)}</span>
</div>`
).join('');
}
function filterServerModal() {
renderServerModal(document.getElementById('serverModalSearch').value);
}
// ── Utils ──
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML.replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// ── Init ──
(async () => {
// Restore settings
const fs = localStorage.getItem(K_FONT) || '14';
document.getElementById('fontSizeLabel').textContent = fs;
const sb = localStorage.getItem(K_SCROLL) || '1000';
document.getElementById('scrollbackSel').value = sb;
await loadServerList();
loadQuickCmds();
// Restore tabs or create initial
let tabData = null;
try { tabData = JSON.parse(localStorage.getItem(K_TABS) || 'null'); } catch(e) {}
if (tabData && tabData.length > 0) {
for (const t of tabData) {
await newSession(t.serverId);
}
if (sessions.length > 0) switchTab(0);
} else {
await newSession(initialServerId);
}
// Restore command history from localStorage
let hist = [];
try { hist = JSON.parse(localStorage.getItem(K_HIST) || '[]'); } catch(e) {}
// Seed the first session's cmdHistory with global history
if (sessions.length > 0) {
sessions[0].cmdHistory = hist;
}
})();
</script>
</body>
</html>