da234c4e73
termminal div 用 absolute inset:0 覆盖了底部 cmdBar。 修复: termContainer(flex-1 relative) 容器隔离,cmdBar 作为 flex sibling 在下方。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
844 lines
35 KiB
HTML
844 lines
35 KiB
HTML
<!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>
|
||
<!-- 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>
|
||
<!-- Quick Commands -->
|
||
<div class="border-t border-slate-800 px-3 py-2 flex items-center justify-between">
|
||
<span class="text-slate-500 text-xs uppercase">⚡ 快速命令</span>
|
||
<button onclick="addQuickCmd()" class="text-slate-500 hover:text-slate-300 text-xs">+</button>
|
||
</div>
|
||
<div id="quickCmdList" class="px-2 pb-2 space-y-0.5 max-h-48 overflow-y-auto"></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'));
|
||
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 }));
|
||
}
|
||
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">×</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 = [...BUILTIN_CMDS, ...userCmds];
|
||
const el = document.getElementById('quickCmdList');
|
||
el.innerHTML = all.map((c, i) => {
|
||
const isBuiltin = c.id.startsWith('__');
|
||
return `<div class="group flex items-center gap-2 px-2 py-1 rounded text-xs hover:bg-slate-800/50 transition cursor-pointer qc-item" data-cmd="${esc(c.cmd).replace(/"/g,'"')}">
|
||
<span class="text-brand-light shrink-0">▶</span>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-slate-300 truncate">${esc(c.name)}</div>
|
||
<div class="text-slate-600 truncate">${esc(c.cmd.replace(/\r$/,''))}</div>
|
||
</div>
|
||
${!isBuiltin ? `<span class="hidden group-hover:flex items-center gap-0.5 shrink-0">
|
||
<button class="qc-edit text-slate-500 hover:text-slate-300 text-xs" data-id="${c.id}" title="编辑">✏</button>
|
||
<button class="qc-del text-slate-500 hover:text-red-400 text-xs" data-id="${c.id}" title="删除">🗑</button>
|
||
</span>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Event delegation
|
||
el.querySelectorAll('.qc-item').forEach(item => {
|
||
item.onclick = () => { execQuickCmd(item.dataset.cmd); };
|
||
});
|
||
el.querySelectorAll('.qc-edit').forEach(btn => {
|
||
btn.onclick = (e) => { e.stopPropagation(); editQuickCmd(btn.dataset.id); };
|
||
});
|
||
el.querySelectorAll('.qc-del').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 editQuickCmd(id) {
|
||
let userCmds = [];
|
||
try { userCmds = JSON.parse(localStorage.getItem(K_CMDS) || '[]'); } catch(e) {}
|
||
const cmd = userCmds.find(c => c.id === id);
|
||
if (!cmd) return;
|
||
document.getElementById('qcTitle').textContent = '编辑命令';
|
||
document.getElementById('qcName').value = cmd.name;
|
||
document.getElementById('qcCmd').value = cmd.cmd;
|
||
document.getElementById('qcEditId').value = id;
|
||
document.getElementById('qcModal').classList.remove('hidden');
|
||
}
|
||
|
||
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,'"'); }
|
||
|
||
// ── 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> |