f796ddf0a5
D2: install.php → install.html + FastAPI API
- 新增 server/api/install.py: 6个安装向导API端点(无JWT)
- 新增 web/app/install.html: Alpine.js五步安装向导
- main.py: 条件启动模式(无.env=安装模式,仅/api/install/可用)
- InstallModeMiddleware: 安装模式下非安装路由返回503
- DbSessionMiddleware: 跳过安装API(自管理临时引擎连接)
- 三写一致性: .env + config.php + MySQL settings表同步
D3: WebSSH terminal.html 完善
- 全功能xterm.js终端 + Koko协议 + 自动resize
- Alpine.js + Tailwind CSS v4 统一风格
- 全屏/断开连接/连接状态指示器
文档更新:
- status.md: 第五步→✅, 完成度~95%
- roadmap.md: D2技术债务已解决
206 lines
11 KiB
HTML
206 lines
11 KiB
HTML
<!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">
|
||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
|
||
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
|
||
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
|
||
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
|
||
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
|
||
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
|
||
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
|
||
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
|
||
<div class="border-t border-slate-800 my-2"></div>
|
||
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计日志</a>
|
||
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
|
||
</nav>
|
||
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
|
||
</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="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>
|
||
// ── 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 === 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>
|