### User query:

这次提交的标题

### Changes made to the code/files:

Title: Remove PHP dependencies and refactor UI rendering to pure HTML templates

Key features implemented:
- Refactored dashboard plugin to remove PHP dependency and implement pure HTML/CSS/JS template rendering
- Updated log-terminal plugin to replace PHP-based UI with native Python HTML template generation
- Modified package manager plugin to eliminate PHP view files and use direct HTML string construction
- Removed all PHP view template files across dashboard, log-terminal, and package manager plugins
- Updated .gitignore to include additional build artifacts and environment files
- Enhanced dashboard with real-time metrics, system information, and network statistics without external PHP processing

The overall change migrates the system from requiring PHP for UI rendering to using pure Python-based HTML template generation, simplifying deployment and removing external dependencies.
This commit is contained in:
qwen.ai[bot]
2026-04-25 09:55:28 +00:00
parent 40888ff61a
commit 27a1eb8a3c
89 changed files with 552 additions and 1735 deletions

View File

@@ -551,55 +551,279 @@ class LogTerminalPlugin(Plugin):
return logs[-limit:]
def _render_logs(self) -> str:
"""渲染日志查看界面"""
"""渲染日志查看界面 - 纯 HTML/Python 模板"""
try:
php_file = os.path.join(self.views_dir, 'logs.php')
if not os.path.exists(php_file):
return "<p>日志视图文件丢失</p>"
return self._execute_php(php_file, {})
logs = self._get_logs(limit=100)
log_rows = ""
for log in logs:
level_class = {
'info': 'log-info',
'warn': 'log-warn',
'error': 'log-error',
'ok': 'log-ok',
'tip': 'log-tip'
}.get(log['level'], 'log-info')
log_rows += f"""
<tr class="{level_class}">
<td>{log['timestamp']}</td>
<td><span class="badge badge-{log['level']}">{log['level']}</span></td>
<td>{log['tag']}</td>
<td>{log['message']}</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统日志</title>
<link rel="stylesheet" href="/assets/remixicon.css">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f6fa; padding: 20px; }}
.container {{ max-width: 1400px; margin: 0 auto; }}
.card {{ background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }}
.card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }}
.card-title {{ font-size: 18px; font-weight: 600; color: #2c3e50; }}
.btn {{ padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s; }}
.btn-primary {{ background: #3498db; color: white; }}
.btn-primary:hover {{ background: #2980b9; }}
.btn-success {{ background: #27ae60; color: white; }}
.btn-success:hover {{ background: #229954; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ecf0f1; }}
th {{ background: #f8f9fa; font-weight: 600; color: #2c3e50; position: sticky; top: 0; }}
tr:hover {{ background: #f8f9fa; }}
.log-info {{ border-left: 3px solid #3498db; }}
.log-warn {{ border-left: 3px solid #f39c12; }}
.log-error {{ border-left: 3px solid #e74c3c; }}
.log-ok {{ border-left: 3px solid #27ae60; }}
.log-tip {{ border-left: 3px solid #9b59b6; }}
.badge {{ padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }}
.badge-info {{ background: #d6eaf8; color: #3498db; }}
.badge-warn {{ background: #fdebd0; color: #f39c12; }}
.badge-error {{ background: #fadbd8; color: #e74c3c; }}
.badge-ok {{ background: #d5f5e3; color: #27ae60; }}
.badge-tip {{ background: #ebdef0; color: #9b59b6; }}
.log-table-container {{ max-height: 600px; overflow-y: auto; }}
.refresh-indicator {{ font-size: 12px; color: #7f8c8d; }}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2 class="card-title"><i class="ri-file-list-3-line"></i> 系统日志</h2>
<div>
<button class="btn btn-primary" onclick="loadLogs()"><i class="ri-refresh-line"></i> 刷新</button>
<button class="btn btn-success" onclick="clearLogs()"><i class="ri-delete-bin-line"></i> 清空</button>
</div>
</div>
<div class="log-table-container">
<table>
<thead>
<tr>
<th>时间</th>
<th>级别</th>
<th>标签</th>
<th>消息</th>
</tr>
</thead>
<tbody id="log-body">
{log_rows}
</tbody>
</table>
</div>
<p class="refresh-indicator">最后更新:{logs[-1]['timestamp'] if logs else '无数据'}</p>
</div>
</div>
<script>
function loadLogs() {{
fetch('/api/logs/get?limit=100')
.then(r => r.json())
.then(data => {{
if (data.success) {{
location.reload();
}}
}});
}}
function clearLogs() {{
if (confirm('确定要清空日志吗?')) {{
fetch('/api/logs/clear', {{ method: 'POST' }})
.then(r => r.json())
.then(data => {{
if (data.success) {{
location.reload();
}}
}});
}}
}}
// 自动刷新
setTimeout(loadLogs, 30000);
</script>
</body>
</html>"""
return html
except Exception as e:
return f"<p>日志视图渲染出错: {e}</p>"
return f"<p>日志视图渲染出错{e}</p>"
def _render_terminal(self) -> str:
"""渲染终端界面"""
"""渲染终端界面 - 纯 HTML/Python 模板"""
try:
php_file = os.path.join(self.views_dir, 'terminal.php')
if not os.path.exists(php_file):
return "<p>终端视图文件丢失</p>"
return self._execute_php(php_file, {})
html = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSH 终端</title>
<link rel="stylesheet" href="/assets/remixicon.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; height: 100vh; display: flex; flex-direction: column; }
.container { max-width: 1400px; margin: 0 auto; width: 100%; flex: 1; display: flex; flex-direction: column; }
.card { background: #16213e; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); padding: 20px; margin-bottom: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.card-title { font-size: 18px; font-weight: 600; color: #fff; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s; }
.btn-primary { background: #0f3460; color: #e94560; }
.btn-primary:hover { background: #1a4a7a; }
.btn-danger { background: #e94560; color: white; }
.btn-danger:hover { background: #c0394d; }
.terminal-container { flex: 1; background: #0f0f1a; border-radius: 8px; padding: 15px; font-family: 'Courier New', monospace; font-size: 14px; overflow: hidden; display: flex; flex-direction: column; }
.terminal-output { flex: 1; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; color: #0f0; }
.terminal-input { display: flex; margin-top: 10px; }
.terminal-input input { flex: 1; background: #1a1a2e; border: 1px solid #0f3460; color: #0f0; padding: 10px; font-family: 'Courier New', monospace; font-size: 14px; border-radius: 4px; outline: none; }
.terminal-input input:focus { border-color: #e94560; }
.status-bar { display: flex; justify-content: space-between; padding: 10px; background: #16213e; border-radius: 6px; margin-bottom: 15px; }
.status-item { display: flex; align-items: center; gap: 8px; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-connected { background: #27ae60; }
.status-disconnected { background: #e74c3c; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1a1a2e; }
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2 class="card-title"><i class="ri-terminal-box-line"></i> SSH 终端</h2>
<div>
<button class="btn btn-primary" id="connectBtn" onclick="connectTerminal()">连接</button>
<button class="btn btn-danger" id="disconnectBtn" onclick="disconnectTerminal()" style="display:none;">断开</button>
</div>
</div>
<div class="status-bar">
<div class="status-item">
<span class="status-dot status-disconnected" id="statusDot"></span>
<span id="statusText">未连接</span>
</div>
<div class="status-item">
<span>会话 ID: <strong id="sessionId">-</strong></span>
</div>
</div>
</div>
<div class="terminal-container">
<div class="terminal-output" id="terminalOutput">欢迎使用 SSH 终端!点击"连接"按钮开始...</div>
<div class="terminal-input">
<input type="text" id="commandInput" placeholder="输入命令..." disabled onkeypress="handleKeyPress(event)">
</div>
</div>
</div>
<script>
let sessionId = null;
const output = document.getElementById('terminalOutput');
const input = document.getElementById('commandInput');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const sessionIdEl = document.getElementById('sessionId');
function connectTerminal() {
output.textContent = '正在连接...';
fetch('/api/terminal/connect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({port: 8022, auto_install: true})
})
.then(r => r.json())
.then(data => {
if (data.success) {
sessionId = data.session_id;
sessionIdEl.textContent = sessionId;
statusDot.className = 'status-dot status-connected';
statusText.textContent = '已连接';
input.disabled = false;
connectBtn.style.display = 'none';
disconnectBtn.style.display = 'inline-block';
output.textContent = 'SSH 终端已连接。输入命令开始使用...
';
input.focus();
} else {
output.textContent = '连接失败:' + data.error;
}
})
.catch(e => {
output.textContent = '连接错误:' + e.message;
});
}
function disconnectTerminal() {
if (!sessionId) return;
fetch('/api/terminal/disconnect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_id: sessionId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
sessionId = null;
sessionIdEl.textContent = '-';
statusDot.className = 'status-dot status-disconnected';
statusText.textContent = '未连接';
input.disabled = true;
connectBtn.style.display = 'inline-block';
disconnectBtn.style.display = 'none';
output.textContent += '
会话已断开。';
}
});
}
function sendCommand(cmd) {
if (!sessionId) return;
fetch('/api/terminal/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_id: sessionId, command: cmd})
})
.then(r => r.json())
.then(data => {
if (data.success) {
output.textContent += '$ ' + cmd + '
' + data.output;
output.scrollTop = output.scrollHeight;
} else {
output.textContent += '
命令执行失败:' + data.error;
}
});
}
function handleKeyPress(e) {
if (e.key === 'Enter') {
sendCommand(input.value);
input.value = '';
}
}
</script>
</body>
</html>"""
return html
except Exception as e:
return f"<p>终端视图渲染出错: {e}</p>"
def _execute_php(self, php_file: str, variables: dict) -> str:
"""执行 PHP 文件"""
php_vars = ""
for key, value in variables.items():
if isinstance(value, str):
escaped = value.replace('\\', '\\\\').replace("'", "\\'").replace("\n", "\\n")
php_vars += f"${key} = '{escaped}';\n"
else:
php_vars += f"${key} = {value};\n"
with open(php_file, 'r', encoding='utf-8') as f:
php_content = f.read()
tmp_file = os.path.join(os.path.dirname(php_file), '.temp_lt.php')
try:
with open(tmp_file, 'w', encoding='utf-8') as f:
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
result = subprocess.run(
["php", "-f", tmp_file],
capture_output=True, text=True, timeout=10,
encoding='utf-8', errors='replace'
)
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
finally:
try:
if os.path.exists(tmp_file):
os.unlink(tmp_file)
except Exception:
pass
return f"<p>终端视图渲染出错{e}</p>"
register_plugin_type("LogTerminalPlugin", LogTerminalPlugin)

View File

@@ -1,217 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
.log-container { max-width: 1400px; margin: 0 auto; padding: 20px; height: calc(100vh - 100px); display: flex; flex-direction: column; }
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.log-title { font-size: 18px; font-weight: 600; color: #00bcd4; display: flex; align-items: center; gap: 10px; }
.log-title i { font-size: 24px; }
.live-indicator { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; background: #064e3b; border-radius: 12px; font-size: 12px; color: #34d399; }
.live-dot { width: 8px; height: 8px; background: #34d399; border-radius: 50%; animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
.log-controls { display: flex; gap: 10px; align-items: center; }
.log-btn { padding: 6px 14px; background: #3b82f6; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
.log-btn:hover { background: #2563eb; }
.log-btn.paused { background: #f59e0b; }
.log-btn.paused:hover { background: #d97706; }
.log-filters { display: flex; gap: 8px; margin-bottom: 12px; }
.filter-btn { padding: 4px 12px; border-radius: 16px; font-size: 12px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; transition: all 0.2s; }
.filter-btn:hover { background: #334155; }
.filter-btn.active { background: #3b82f6; border-color: #3b82f6; color: white; }
.log-content { flex: 1; overflow-y: auto; background: #0f172a; border-radius: 10px; padding: 16px; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; }
.log-content::-webkit-scrollbar { width: 8px; }
.log-content::-webkit-scrollbar-track { background: #1e293b; border-radius: 4px; }
.log-content::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
.log-content::-webkit-scrollbar-thumb:hover { background: #64748b; }
.log-entry { padding: 4px 0; border-bottom: 1px solid #1e293b; animation: fadeIn 0.3s ease-in; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
.log-entry:last-child { border-bottom: none; }
.log-timestamp { color: #64748b; margin-right: 8px; }
.log-level { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 8px; display: inline-block; min-width: 50px; text-align: center; }
.log-tag { color: #3b82f6; margin-right: 8px; font-weight: 500; }
.log-message { color: #e2e8f0; }
.log-level.info { background: #1e3a8a; color: #60a5fa; }
.log-level.ok { background: #064e3b; color: #34d399; }
.log-level.warn { background: #78350f; color: #fbbf24; }
.log-level.error { background: #7f1d1d; color: #f87171; }
.log-level.tip { background: #1e3a5f; color: #38bdf8; }
.empty-state { text-align: center; padding: 60px 20px; color: #64748b; }
.empty-state i { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
.empty-state p { font-size: 14px; }
</style>
</head>
<body>
<div class="log-container">
<div class="log-header">
<div class="log-title">
<i class="ri-file-list-3-line"></i>
<span>系统日志</span>
<span class="live-indicator" id="live-indicator">
<span class="live-dot"></span>
实时同步
</span>
</div>
<div class="log-controls">
<button class="log-btn" id="clear-btn" onclick="clearLogs()">
<i class="ri-delete-bin-line"></i>
清空
</button>
<button class="log-btn" id="pause-btn" onclick="togglePause()">
<i class="ri-pause-line" id="pause-icon"></i>
<span id="pause-text">暂停</span>
</button>
</div>
</div>
<div class="log-filters">
<button class="filter-btn active" data-level="all" onclick="setFilter('all')">全部</button>
<button class="filter-btn" data-level="info" onclick="setFilter('info')">信息</button>
<button class="filter-btn" data-level="ok" onclick="setFilter('ok')">成功</button>
<button class="filter-btn" data-level="warn" onclick="setFilter('warn')">警告</button>
<button class="filter-btn" data-level="error" onclick="setFilter('error')">错误</button>
<button class="filter-btn" data-level="tip" onclick="setFilter('tip')">提示</button>
</div>
<div class="log-content" id="log-content">
<div class="empty-state" id="empty-state">
<i class="ri-file-list-3-line"></i>
<p>正在加载日志...</p>
</div>
</div>
</div>
<script>
let isPaused = false;
let currentFilter = 'all';
let syncInterval = null;
function setFilter(level) {
currentFilter = level;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.level === level) {
btn.classList.add('active');
}
});
filterLogs();
}
function togglePause() {
isPaused = !isPaused;
const pauseBtn = document.getElementById('pause-btn');
const pauseIcon = document.getElementById('pause-icon');
const pauseText = document.getElementById('pause-text');
const indicator = document.getElementById('live-indicator');
if (isPaused) {
pauseBtn.classList.add('paused');
pauseIcon.className = 'ri-play-line';
pauseText.textContent = '继续';
indicator.style.opacity = '0.5';
} else {
pauseBtn.classList.remove('paused');
pauseIcon.className = 'ri-pause-line';
pauseText.textContent = '暂停';
indicator.style.opacity = '1';
fetchLogs();
}
}
function clearLogs() {
const content = document.getElementById('log-content');
content.innerHTML = '<div class="empty-state"><i class="ri-file-list-3-line"></i><p>日志已清空</p></div>';
}
async function fetchLogs() {
if (isPaused) return;
try {
// 先尝试从缓冲区获取
const response = await fetch('/api/logs/get?limit=100&source=buffer');
const data = await response.json();
if (data.success) {
// 如果缓冲区为空,尝试从系统日志读取
if (!data.logs || data.logs.length === 0) {
const fileResponse = await fetch('/api/logs/get?limit=100&source=file');
const fileData = await fileResponse.json();
if (fileData.success) {
renderLogs(fileData.logs || []);
}
} else {
renderLogs(data.logs);
}
}
} catch (error) {
console.error('获取日志失败:', error);
// 错误时也要显示状态
const content = document.getElementById('log-content');
const emptyState = document.getElementById('empty-state');
emptyState.style.display = 'block';
emptyState.innerHTML = '<i class="ri-error-warning-line"></i><p>获取日志失败</p><p style="font-size: 12px; margin-top: 8px; opacity: 0.7;">' + error.message + '</p>';
}
}
function renderLogs(logs) {
const content = document.getElementById('log-content');
const emptyState = document.getElementById('empty-state');
if (logs.length === 0) {
emptyState.style.display = 'block';
emptyState.innerHTML = '<i class="ri-file-list-3-line"></i><p>暂无日志</p>';
return;
}
emptyState.style.display = 'none';
const filteredLogs = currentFilter === 'all'
? logs
: logs.filter(log => log.level === currentFilter);
const html = filteredLogs.map(log => `
<div class="log-entry" data-level="${log.level}">
<span class="log-timestamp">${log.timestamp}</span>
<span class="log-level ${log.level}">${log.level.toUpperCase()}</span>
<span class="log-tag">[${log.tag}]</span>
<span class="log-message">${escapeHtml(log.message)}</span>
</div>
`).join('');
content.innerHTML = html;
content.scrollTop = content.scrollHeight;
}
function filterLogs() {
const entries = document.querySelectorAll('.log-entry');
entries.forEach(entry => {
if (currentFilter === 'all' || entry.dataset.level === currentFilter) {
entry.style.display = 'block';
} else {
entry.style.display = 'none';
}
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
fetchLogs();
syncInterval = setInterval(fetchLogs, 2000); // 每2秒同步一次
});
</script>
</body>
</html>

View File

@@ -1,288 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
.terminal-container { max-width: 1400px; margin: 0 auto; padding: 20px; height: calc(100vh - 100px); display: flex; flex-direction: column; }
.terminal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.terminal-title { font-size: 18px; font-weight: 600; color: #00bcd4; display: flex; align-items: center; gap: 10px; }
.terminal-title i { font-size: 24px; }
.terminal-controls { display: flex; gap: 10px; }
.term-btn { padding: 6px 14px; background: #3b82f6; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
.term-btn:hover { background: #2563eb; }
.term-btn.connecting { background: #f59e0b; cursor: not-allowed; }
.term-btn.disconnect { background: #ef4444; }
.term-btn.disconnect:hover { background: #dc2626; }
.terminal-status { display: flex; align-items: center; gap: 8px; padding: 4px 12px; background: #1e293b; border-radius: 12px; font-size: 12px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
.status-dot.connected { background: #34d399; animation: pulse 2s infinite; }
.status-dot.disconnected { background: #f87171; }
.status-dot.connecting { background: #fbbf24; animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
.terminal-wrapper { flex: 1; background: #0f172a; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; }
.terminal-info { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #1e293b; margin-bottom: 12px; }
.info-item { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 6px; }
.info-item i { color: #3b82f6; }
.terminal-output { flex: 1; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #e2e8f0; margin-bottom: 12px; }
.terminal-output::-webkit-scrollbar { width: 8px; }
.terminal-output::-webkit-scrollbar-track { background: #1e293b; border-radius: 4px; }
.terminal-output::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
.terminal-output::-webkit-scrollbar-thumb:hover { background: #64748b; }
.terminal-line { padding: 2px 0; }
.terminal-line.command { color: #34d399; }
.terminal-line.output { color: #e2e8f0; }
.terminal-line.error { color: #f87171; }
.terminal-line.info { color: #60a5fa; }
.terminal-line.success { color: #34d399; }
.terminal-line.warning { color: #fbbf24; }
.terminal-input-wrapper { display: flex; gap: 8px; align-items: center; padding: 8px; background: #1e293b; border-radius: 6px; }
.terminal-prompt { color: #34d399; font-weight: 600; white-space: nowrap; }
.terminal-input { flex: 1; background: transparent; border: none; color: #e2e8f0; font-family: 'Courier New', monospace; font-size: 13px; outline: none; }
.terminal-input::placeholder { color: #64748b; }
.ssh-config { background: #1e293b; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.config-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.config-row:last-child { margin-bottom: 0; }
.config-label { font-size: 13px; color: #94a3b8; min-width: 100px; }
.config-input { flex: 1; padding: 6px 12px; background: #0f172a; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-size: 13px; }
.config-input:focus { outline: none; border-color: #3b82f6; }
.config-checkbox { display: flex; align-items: center; gap: 8px; color: #e2e8f0; font-size: 13px; cursor: pointer; }
.config-checkbox input[type="checkbox"] { cursor: pointer; }
.empty-state { text-align: center; padding: 60px 20px; color: #64748b; }
.empty-state i { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
.empty-state p { font-size: 14px; margin-top: 8px; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="terminal-container">
<div class="terminal-header">
<div class="terminal-title">
<i class="ri-terminal-box-line"></i>
<span>SSH 终端</span>
</div>
<div class="terminal-controls">
<div class="terminal-status">
<span class="status-dot disconnected" id="status-dot"></span>
<span id="status-text">未连接</span>
</div>
<button class="term-btn" id="connect-btn" onclick="connectSSH()">
<i class="ri-plug-line"></i>
连接
</button>
<button class="term-btn disconnect" id="disconnect-btn" onclick="disconnectSSH()" style="display: none;">
<i class="ri-close-line"></i>
断开
</button>
<button class="term-btn" id="clear-btn" onclick="clearTerminal()">
<i class="ri-delete-bin-line"></i>
清空
</button>
</div>
</div>
<div class="ssh-config" id="ssh-config">
<div class="config-row">
<span class="config-label"><i class="ri-settings-3-line"></i> SSH 端口:</span>
<input type="number" class="config-input" id="ssh-port" value="8022" min="1" max="65535">
</div>
<div class="config-row">
<label class="config-checkbox">
<input type="checkbox" id="auto-install" checked>
自动安装 SSH 服务
</label>
</div>
</div>
<div class="terminal-wrapper">
<div class="terminal-info">
<div class="info-item">
<i class="ri-server-line"></i>
<span>端口: <strong id="info-port">8022</strong></span>
</div>
<div class="info-item">
<i class="ri-time-line"></i>
<span>运行时间: <strong id="info-uptime">-</strong></span>
</div>
</div>
<div class="terminal-output" id="terminal-output">
<div class="empty-state" id="empty-state">
<i class="ri-terminal-box-line"></i>
<p>点击"连接"按钮开始 SSH 终端会话</p>
<p style="font-size: 12px; margin-top: 8px; opacity: 0.7;">支持自动安装 SSH 服务</p>
</div>
</div>
<div class="terminal-input-wrapper" id="input-wrapper" style="display: none;">
<span class="terminal-prompt">$</span>
<input type="text" class="terminal-input" id="terminal-input" placeholder="输入命令..." onkeypress="handleKeyPress(event)">
</div>
</div>
</div>
<script>
let sessionId = null;
let isConnected = false;
function updateStatus(status) {
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const inputWrapper = document.getElementById('input-wrapper');
const sshConfig = document.getElementById('ssh-config');
dot.className = 'status-dot ' + status;
if (status === 'connected') {
text.textContent = '已连接';
connectBtn.style.display = 'none';
disconnectBtn.style.display = 'flex';
inputWrapper.style.display = 'flex';
sshConfig.style.display = 'none';
isConnected = true;
} else if (status === 'connecting') {
text.textContent = '连接中...';
connectBtn.classList.add('connecting');
connectBtn.innerHTML = '<span class="spinner"></span> 连接中';
} else {
text.textContent = '未连接';
connectBtn.style.display = 'flex';
connectBtn.classList.remove('connecting');
connectBtn.innerHTML = '<i class="ri-plug-line"></i> 连接';
disconnectBtn.style.display = 'none';
inputWrapper.style.display = 'none';
sshConfig.style.display = 'block';
isConnected = false;
}
}
async function connectSSH() {
const port = document.getElementById('ssh-port').value;
const autoInstall = document.getElementById('auto-install').checked;
updateStatus('connecting');
appendLine('info', '正在初始化 SSH 连接...');
appendLine('info', `目标端口: ${port}`);
if (autoInstall) {
appendLine('info', '自动安装 SSH: 已启用');
appendLine('tip', '智能检测 SSH 服务状态...');
}
try {
const response = await fetch('/api/terminal/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port: parseInt(port), auto_install: autoInstall })
});
const data = await response.json();
if (data.success) {
sessionId = data.session_id;
document.getElementById('info-port').textContent = port;
document.getElementById('info-uptime').textContent = '刚刚';
updateStatus('connected');
appendLine('success', `✓ SSH 终端已连接 (会话 #${sessionId})`);
appendLine('output', '输入命令开始操作...');
appendLine('output', '');
document.getElementById('terminal-input').focus();
} else {
updateStatus('disconnected');
appendLine('error', `✗ 连接失败: ${data.error}`);
}
} catch (error) {
updateStatus('disconnected');
appendLine('error', `✗ 连接异常: ${error.message}`);
}
}
async function disconnectSSH() {
if (!sessionId) return;
try {
await fetch('/api/terminal/disconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId })
});
} catch (error) {
console.error('断开连接失败:', error);
}
sessionId = null;
updateStatus('disconnected');
appendLine('warning', 'SSH 终端已断开');
}
async function sendCommand(command) {
if (!sessionId || !command.trim()) return;
appendLine('command', `$ ${command}`);
try {
const response = await fetch('/api/terminal/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, command: command })
});
const data = await response.json();
if (data.success && data.output) {
const lines = data.output.split('\n');
lines.forEach(line => {
if (line.trim()) {
appendLine('output', line);
}
});
}
} catch (error) {
appendLine('error', `执行命令失败: ${error.message}`);
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
const input = document.getElementById('terminal-input');
const command = input.value.trim();
if (command) {
sendCommand(command);
input.value = '';
}
}
}
function appendLine(type, text) {
const output = document.getElementById('terminal-output');
const emptyState = document.getElementById('empty-state');
if (emptyState) {
emptyState.style.display = 'none';
}
const line = document.createElement('div');
line.className = `terminal-line ${type}`;
line.textContent = text;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function clearTerminal() {
const output = document.getElementById('terminal-output');
output.innerHTML = '<div class="empty-state"><i class="ri-terminal-box-line"></i><p>终端已清空</p></div>';
}
</script>
</body>
</html>