Files
Nexus/web/app/terminal.html
T
Your Name b676e320de
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
前端增强: XSS防护 + 共享布局 + 移动适配 + 设置页
- index.html: WebSocket告警文本esc()转义
- files.html: escAttr()函数 + 路径拼接转义
- layout.js: 移动端侧边栏overlay + 全局搜索 + 用户信息
- servers.html: 点击展开详情面板(系统信息/同步/SSH)
- settings.html: TOTP QR码设置 + 修改密码 + API Key复制
- api.js: JWT refresh token自动刷新 + Toast通知
- terminal.html: WebSSH xterm.js集成

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:29:06 +08:00

194 lines
8.9 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" x-data="{ darkMode: true, sidebarOpen: false, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/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);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!-- Sidebar (collapsed by default — terminal needs space) -->
<aside x-show="sidebarOpen" 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">
<!-- Toolbar -->
<header x-show="!fullscreen" class="bg-slate-900 border-b border-slate-800 px-4 py-2 flex items-center justify-between h-12 shrink-0">
<div class="flex items-center gap-3">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<span class="text-slate-500 text-sm">SSH</span>
<span class="text-white font-semibold text-sm" x-text="serverName"></span>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs" :class="connected ? 'bg-green-900/30 text-green-400' : 'bg-slate-800 text-slate-500'">
<span class="w-1.5 h-1.5 rounded-full" :class="connected ? 'bg-green-400 animate-pulse' : 'bg-slate-600'"></span>
<span x-text="connected ? '已连接' : '未连接'"></span>
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-slate-600 text-xs font-mono" x-text="cols+'×'+rows"></span>
<button onclick="toggleFullscreen()" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" x-text="fullscreen?'退出全屏':'全屏'">全屏</button>
<button onclick="disconnect()" class="px-2 py-1 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition">断开</button>
<a href="/app/servers.html" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">↩ 返回</a>
</div>
</header>
<!-- Terminal -->
<div id="terminalWrap" class="bg-slate-950"><div id="terminal"></div></div>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script>
initLayout('servers'); // terminal is under servers nav
// ── Parse server_id ──
const params = new URLSearchParams(window.location.search);
const serverId = parseInt(params.get('server_id'));
if (!serverId) window.location.href = '/app/servers.html';
// ── Alpine helper ──
function $d() { return Alpine.$data(document.body); }
// ── xterm.js ──
const term = 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,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById('terminal'));
// Initial fit
setTimeout(() => fitAddon.fit(), 50);
window.addEventListener('resize', () => { try { fitAddon.fit(); } catch(e){} });
// ── WebSocket ──
let ws = null;
function connect() {
const token = localStorage.getItem('access_token');
if (!token) { window.location.href = '/app/login.html'; return; }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws/terminal/${serverId}?token=${encodeURIComponent(token)}`);
ws.onopen = () => {
$d().connected = true;
term.focus();
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'TERMINAL_INIT':
$d().serverName = msg.server_name || ('服务器 #' + msg.server_id);
document.title = `Nexus — ${$d().serverName}`;
// Send initial size
const d = fitAddon.proposeDimensions();
if (d && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
$d().cols = d.cols; $d().rows = d.rows;
}
break;
case 'DATA':
term.write(msg.data);
break;
case 'ERROR':
term.write('\r\n\x1b[31m⚠ ' + msg.message + '\x1b[0m\r\n');
break;
case 'CLOSE':
$d().connected = false;
term.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n');
break;
}
} catch (err) {
term.write(e.data);
}
};
ws.onclose = (ev) => {
$d().connected = false;
if (ev.code === 4001) {
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
setTimeout(() => location.href = '/app/login.html', 2000);
} else if (ev.code === 4003) {
term.write('\r\n\x1b[31m⚠ 授权失败: ' + (ev.reason || '服务器SSH凭据未配置') + '\x1b[0m\r\n');
} else if (ev.code === 4004) {
term.write('\r\n\x1b[31m⚠ 服务器不存在\x1b[0m\r\n');
} else if (ev.code !== 1000) {
term.write('\r\n\x1b[33m⚠ 连接断开 (' + ev.code + ')\x1b[0m\r\n');
}
};
ws.onerror = () => {
$d().connected = false;
term.write('\r\n\x1b[31m⚠ WebSocket错误\x1b[0m\r\n');
};
}
// ── Input: terminal → WebSocket ──
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'DATA', data }));
}
});
// ── Resize: terminal → WebSocket ──
term.onResize(({ cols, rows }) => {
$d().cols = cols; $d().rows = rows;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }));
}
});
// Auto-fit on container resize
new ResizeObserver(() => { try { fitAddon.fit(); } catch(e){} })
.observe(document.getElementById('terminalWrap'));
// ── Fullscreen ──
function toggleFullscreen() {
const el = document.getElementById('terminalWrap');
const fs = el.classList.toggle('fs');
$d().fullscreen = fs;
setTimeout(() => fitAddon.fit(), 50);
}
// ── Disconnect ──
function disconnect() {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'CLOSE' }));
ws.close(1000, 'User disconnect');
}
}
// ── Keyboard shortcuts ──
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); toggleFullscreen(); }
if (e.ctrlKey && e.shiftKey && e.key === 'K') { e.preventDefault(); disconnect(); }
});
// ── Load server name for page title ──
(async () => {
try {
const r = await apiFetch(API + '/servers/' + serverId);
if (r?.ok) { const s = await r.json(); $d().serverName = s.name || $d().serverName; document.title = 'Nexus — ' + $d().serverName; }
} catch(e){}
})();
// ── Init ──
connect();
</script>
</body>
</html>