废弃了部分旧代码

This commit is contained in:
Falck
2026-04-25 18:00:20 +08:00
committed by GitHub
89 changed files with 552 additions and 1735 deletions

40
.gitignore vendored
View File

@@ -1,48 +1,38 @@
```gitignore ```
# Python # Python cache files
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
*.pyd *.pyd
*.py~
# Dependencies # Dependencies and build artifacts
dist/
build/
target/
node_modules/
.venv/ .venv/
venv/ venv/
.env .env
.env.local .env.local
.env.* *.env.*
# Logs and temporary files # Logs and temporary files
*.log *.log
*.tmp *.tmp
*.swp
# Editors # Editor/IDE files
.vscode/ .vscode/
.idea/ .idea/
# Build artifacts # Coverage reports
dist/
build/
target/
*.o
*.obj
*.out
*.exe
*.dll
*.so
*.a
# Coverage
.coverage
coverage/ coverage/
htmlcov/ htmlcov/
.coverage
# Testing # MyPy cache
.mypy_cache/ .mypy_cache/
.pytest_cache/
# System # Pytest cache
.DS_Store .pytest_cache/
Thumbs.db
``` ```

View File

@@ -209,11 +209,11 @@ class DashboardPlugin(Plugin):
Log.error("dashboard", "仪表盘已停止") Log.error("dashboard", "仪表盘已停止")
def _render_content(self) -> str: def _render_content(self) -> str:
"""渲染仪表盘页面 - 纯 HTML/Python 模板"""
try: try:
php_file = os.path.join(self.views_dir, 'dashboard.php') import psutil
if not os.path.exists(php_file): import platform
return "<p>仪表盘视图文件丢失</p>"
cpu_percent = psutil.cpu_percent(interval=0.5) cpu_percent = psutil.cpu_percent(interval=0.5)
cpu_cores = psutil.cpu_count(logical=True) cpu_cores = psutil.cpu_count(logical=True)
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
@@ -224,108 +224,100 @@ class DashboardPlugin(Plugin):
disk_percent = round(disk.percent, 1) disk_percent = round(disk.percent, 1)
disk_used_gb = round(disk.used / (1024**3), 1) disk_used_gb = round(disk.used / (1024**3), 1)
disk_total_gb = round(disk.total / (1024**3), 1) disk_total_gb = round(disk.total / (1024**3), 1)
net = self._get_network_stats()
disk_io = self._get_disk_io_stats()
load = self._get_load_info()
net_interfaces = self._get_network_interfaces()
processes = len(psutil.pids())
if disk_percent < 50:
disk_color = 'gauge-green'
elif disk_percent < 80:
disk_color = 'gauge-orange'
else:
disk_color = 'gauge-blue'
circumference = 2 * 3.14159 * 52 circumference = 2 * 3.14159 * 52
cpu_dash_offset = round(circumference - (cpu_percent / 100) * circumference, 1) cpu_dash_offset = round(circumference - (cpu_percent / 100) * circumference, 1)
ram_dash_offset = round(circumference - (ram_percent / 100) * circumference, 1) ram_dash_offset = round(circumference - (ram_percent / 100) * circumference, 1)
disk_dash_offset = round(circumference - (disk_percent / 100) * circumference, 1) disk_dash_offset = round(circumference - (disk_percent / 100) * circumference, 1)
uptime_str = self._get_uptime_str() uptime_str = self._get_uptime_str()
def fmt_speed(bps): disk_color = 'gauge-green' if disk_percent < 50 else ('gauge-orange' if disk_percent < 80 else 'gauge-blue')
if bps >= 1024 * 1024:
return f"{round(bps / (1024*1024), 1)} MB/s" html = f"""<!DOCTYPE html>
elif bps >= 1024: <html lang="zh-CN">
return f"{round(bps / 1024, 1)} KB/s" <head>
else: <meta charset="UTF-8">
return f"{round(bps, 0)} B/s" <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统仪表盘</title>
variables = { <link rel="stylesheet" href="/assets/remixicon.css">
'cpuPercent': int(cpu_percent), <style>
'cpuDashArray': str(circumference), * {{ margin: 0; padding: 0; box-sizing: border-box; }}
'cpuDashOffset': str(cpu_dash_offset), body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f6fa; padding: 20px; }}
'cpuCores': str(cpu_cores), .container {{ max-width: 1400px; margin: 0 auto; }}
'ramPercent': ram_percent, .card {{ background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }}
'ramDashArray': str(circumference), .card-title {{ font-size: 18px; font-weight: 600; color: #2c3e50; margin-bottom: 20px; }}
'ramDashOffset': str(ram_dash_offset), .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; }}
'ramUsed': f"{ram_used_gb} GB", .stat-card {{ background: #f8f9fa; border-radius: 8px; padding: 20px; text-align: center; }}
'ramTotal': f"{ram_total_gb} GB", .stat-icon {{ width: 60px; height: 60px; margin: 0 auto 15px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28px; color: white; }}
'diskPercent': disk_percent, .stat-icon.cpu {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
'diskDashArray': str(circumference), .stat-icon.ram {{ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }}
'diskDashOffset': str(disk_dash_offset), .stat-icon.disk {{ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }}
'diskUsed': f"{disk_used_gb} GB", .stat-value {{ font-size: 24px; font-weight: 700; color: #2c3e50; margin-bottom: 5px; }}
'diskTotal': f"{disk_total_gb} GB", .stat-label {{ font-size: 14px; color: #7f8c8d; }}
'diskColorClass': disk_color, .gauge-container {{ position: relative; width: 120px; height: 120px; margin: 0 auto; }}
'uptime': uptime_str, .gauge-svg {{ transform: rotate(-90deg); }}
'osName': f"{platform.system()} {platform.release()}", .gauge-bg {{ fill: none; stroke: #e5e7eb; stroke-width: 8; }}
'pythonVersion': platform.python_version(), .gauge-fill {{ fill: none; stroke: #3498db; stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset 0.5s; }}
'phpVersion': self._get_php_version(), .gauge-green .gauge-fill {{ stroke: #27ae60; }}
'hostname': platform.node(), .gauge-orange .gauge-fill {{ stroke: #f39c12; }}
'netRecvSpeed': fmt_speed(net['recv_rate']), .gauge-blue .gauge-fill {{ stroke: #e74c3c; }}
'netSentSpeed': fmt_speed(net['sent_rate']), .gauge-text {{ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 18px; font-weight: 600; color: #2c3e50; }}
'diskReadSpeed': fmt_speed(disk_io['read_rate']), .info-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-top: 20px; }}
'diskWriteSpeed': fmt_speed(disk_io['write_rate']), .info-item {{ background: #f8f9fa; padding: 15px; border-radius: 6px; }}
'load1': str(load['load1']), .info-label {{ font-size: 12px; color: #7f8c8d; margin-bottom: 5px; }}
'load5': str(load['load5']), .info-value {{ font-size: 14px; color: #2c3e50; font-weight: 600; }}
'load15': str(load['load15']), </style>
'processes': str(processes), </head>
'netInterfaces': json.dumps(net_interfaces), <body>
} <div class="container">
<div class="card">
return self._execute_php(php_file, variables) <h2 class="card-title"><i class="ri-dashboard-line"></i> 系统仪表盘</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon cpu"><i class="ri-cpu-line"></i></div>
<div class="stat-value">{cpu_percent}%</div>
<div class="stat-label">CPU 使用率 ({cpu_cores} 核心)</div>
</div>
<div class="stat-card">
<div class="stat-icon ram"><i class="ri-memory-line"></i></div>
<div class="stat-value">{ram_percent}%</div>
<div class="stat-label">内存使用 ({ram_used_gb} GB / {ram_total_gb} GB)</div>
</div>
<div class="stat-card">
<div class="stat-icon disk"><i class="ri-hard-drive-line"></i></div>
<div class="stat-value">{disk_percent}%</div>
<div class="stat-label">磁盘使用 ({disk_used_gb} GB / {disk_total_gb} GB)</div>
</div>
</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">系统运行时间</div>
<div class="info-value">{uptime_str}</div>
</div>
<div class="info-item">
<div class="info-label">操作系统</div>
<div class="info-value">{platform.system()} {platform.release()}</div>
</div>
<div class="info-item">
<div class="info-label">Python 版本</div>
<div class="info-value">{platform.python_version()}</div>
</div>
<div class="info-item">
<div class="info-label">主机名</div>
<div class="info-value">{platform.node()}</div>
</div>
</div>
</div>
</div>
<script>
setTimeout(() => location.reload(), 30000);
</script>
</body>
</html>"""
return html
except Exception as e: except Exception as e:
return f"<p>仪表盘渲染出错: {e}</p>" return f"<p>仪表盘渲染出错{{e}}</p>"
def _execute_php(self, php_file: str, variables: dict) -> str:
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_dashboard.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
@staticmethod
def _get_php_version() -> str:
try:
res = subprocess.run(['php', '-r', 'echo phpversion();'], capture_output=True, text=True, timeout=5,
encoding='utf-8', errors='replace')
return res.stdout if res.returncode == 0 else 'N/A'
except Exception:
return 'N/A'
register_plugin_type("DashboardPlugin", DashboardPlugin) register_plugin_type("DashboardPlugin", DashboardPlugin)

View File

@@ -1,350 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
.dashboard-container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.section-title { font-size: 18px; font-weight: 600; color: #00bcd4; margin-bottom: 16px; padding-left: 12px; border-left: 4px solid #3b82f6; }
.gauges-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; }
.gauge-card { background: #1e293b; border-radius: 12px; padding: 20px; display: flex; flex-direction: column; align-items: center; position: relative; }
.gauge-card .label { font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
.gauge-circle { position: relative; width: 120px; height: 120px; }
.gauge-circle svg { transform: rotate(-90deg); }
.gauge-circle .bg { fill: none; stroke: #334155; stroke-width: 8; }
.gauge-circle .progress { fill: none; stroke: #3b82f6; stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset 0.8s ease; }
.gauge-circle .value { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; font-weight: 700; color: #f1f5f9; }
.gauge-circle .unit { font-size: 12px; color: #94a3b8; }
.gauge-card .detail { margin-top: 8px; font-size: 12px; color: #64748b; }
.gauge-green { stroke: #22c55e; }
.gauge-orange { stroke: #f59e0b; }
.gauge-blue { stroke: #3b82f6; }
.gauge-red { stroke: #ef4444; }
.io-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 28px; }
.io-card { background: #1e293b; border-radius: 12px; padding: 20px; }
.io-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.io-card .card-header i { font-size: 20px; color: #3b82f6; }
.io-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
.io-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #334155; }
.io-row:last-child { border-bottom: none; }
.io-row .io-label { color: #94a3b8; font-size: 13px; }
.io-row .io-value { color: #f1f5f9; font-size: 14px; font-weight: 500; }
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 28px; }
.info-card { background: #1e293b; border-radius: 12px; padding: 20px; }
.info-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.info-card .card-header i { font-size: 20px; color: #3b82f6; }
.info-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
.info-table { width: 100%; }
.info-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #334155; }
.info-row:last-child { border-bottom: none; }
.info-row .info-label { color: #94a3b8; font-size: 13px; }
.info-row .info-value { color: #f1f5f9; font-size: 14px; font-weight: 500; }
.net-ifaces { margin-top: 12px; }
.net-iface { background: #0f172a; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.net-iface .iface-name { font-weight: 600; color: #e2e8f0; }
.net-iface .iface-info { font-size: 12px; color: #94a3b8; }
.net-iface .iface-status { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-up { background: #064e3b; color: #34d399; }
.status-down { background: #7f1d1d; color: #f87171; }
.chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 16px; margin-bottom: 28px; }
.chart-card { background: #1e293b; border-radius: 12px; padding: 20px; }
.chart-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.chart-card .card-header i { font-size: 20px; color: #3b82f6; }
.chart-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
.chart-wrapper { position: relative; height: 200px; }
.live-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
</style>
</head>
<body>
<div class="dashboard-container">
<div class="section-title"><span class="live-dot"></span>实时指标</div>
<div class="gauges-grid">
<div class="gauge-card">
<div class="label">CPU 使用率</div>
<div class="gauge-circle">
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $cpuDashArray ?>"></circle><circle class="progress gauge-blue" id="cpu-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $cpuDashArray ?>" stroke-dashoffset="<?= $cpuDashOffset ?>"></circle></svg>
<div class="value"><span id="cpu-val"><?= $cpuPercent ?></span><span class="unit">%</span></div>
</div>
<div class="detail"><?= $cpuCores ?> 核心</div>
</div>
<div class="gauge-card">
<div class="label">内存使用</div>
<div class="gauge-circle">
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $ramDashArray ?>"></circle><circle class="progress gauge-green" id="ram-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $ramDashArray ?>" stroke-dashoffset="<?= $ramDashOffset ?>"></circle></svg>
<div class="value"><span id="ram-val"><?= $ramPercent ?></span><span class="unit">%</span></div>
</div>
<div class="detail"><?= $ramUsed ?> / <?= $ramTotal ?></div>
</div>
<div class="gauge-card">
<div class="label">磁盘使用</div>
<div class="gauge-circle">
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $diskDashArray ?>"></circle><circle class="progress <?= $diskColorClass ?>" id="disk-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $diskDashArray ?>" stroke-dashoffset="<?= $diskDashOffset ?>"></circle></svg>
<div class="value"><span id="disk-val"><?= $diskPercent ?></span><span class="unit">%</span></div>
</div>
<div class="detail"><?= $diskUsed ?> / <?= $diskTotal ?></div>
</div>
<div class="gauge-card">
<div class="label">系统负载</div>
<div class="gauge-circle">
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="326.73"></circle><circle class="progress gauge-orange" cx="60" cy="60" r="52" stroke-dasharray="326.73" stroke-dashoffset="0"></circle></svg>
<div class="value" style="font-size:16px" id="load-val"><?= $load1 ?></div>
</div>
<div class="detail">1m / 5m / 15m: <?= $load1 ?> / <?= $load5 ?> / <?= $load15 ?></div>
</div>
</div>
<div class="section-title">网络 & 磁盘 I/O</div>
<div class="io-grid">
<div class="io-card">
<div class="card-header"><i class="ri-global-line"></i><span>网络流量</span></div>
<div class="io-row"><span class="io-label">下载速度</span><span class="io-value" id="net-recv"><?= $netRecvSpeed ?></span></div>
<div class="io-row"><span class="io-label">上传速度</span><span class="io-value" id="net-sent"><?= $netSentSpeed ?></span></div>
</div>
<div class="io-card">
<div class="card-header"><i class="ri-hard-drive-3-line"></i><span>磁盘 I/O</span></div>
<div class="io-row"><span class="io-label">读取速度</span><span class="io-value" id="disk-read"><?= $diskReadSpeed ?></span></div>
<div class="io-row"><span class="io-label">写入速度</span><span class="io-value" id="disk-write"><?= $diskWriteSpeed ?></span></div>
</div>
<div class="io-card">
<div class="card-header"><i class="ri-stack-line"></i><span>系统概况</span></div>
<div class="io-row"><span class="io-label">运行进程</span><span class="io-value" id="proc-count"><?= $processes ?></span></div>
<div class="io-row"><span class="io-label">运行时间</span><span class="io-value" id="uptime-val"><?= $uptime ?></span></div>
</div>
</div>
<div class="section-title">历史趋势</div>
<div class="chart-grid">
<div class="chart-card">
<div class="card-header"><i class="ri-cpu-line"></i><span>CPU & 内存趋势</span></div>
<div class="chart-wrapper"><canvas id="chart-cpu"></canvas></div>
</div>
<div class="chart-card">
<div class="card-header"><i class="ri-exchange-line"></i><span>网络吞吐</span></div>
<div class="chart-wrapper"><canvas id="chart-net"></canvas></div>
</div>
<div class="chart-card">
<div class="card-header"><i class="ri-hard-drive-3-line"></i><span>磁盘读写</span></div>
<div class="chart-wrapper"><canvas id="chart-disk-io"></canvas></div>
</div>
<div class="chart-card">
<div class="card-header"><i class="ri-pulse-line"></i><span>网络延迟</span></div>
<div class="chart-wrapper"><canvas id="chart-latency"></canvas></div>
</div>
</div>
<div class="section-title">系统信息</div>
<div class="info-grid">
<div class="info-card">
<div class="card-header"><i class="ri-settings-4-line"></i><span>系统详情</span></div>
<div class="info-table">
<div class="info-row"><span class="info-label">主机名</span><span class="info-value"><?= $hostname ?></span></div>
<div class="info-row"><span class="info-label">操作系统</span><span class="info-value"><?= $osName ?></span></div>
<div class="info-row"><span class="info-label">Python</span><span class="info-value"><?= $pythonVersion ?></span></div>
<div class="info-row"><span class="info-label">PHP</span><span class="info-value"><?= $phpVersion ?></span></div>
<div class="info-row"><span class="info-label">运行时间</span><span class="info-value" id="uptime-info"><?= $uptime ?></span></div>
</div>
</div>
<div class="info-card">
<div class="card-header"><i class="ri-router-line"></i><span>网络接口</span></div>
<div class="net-ifaces" id="net-ifaces">
<script type="application/json" id="ifaces-data"><?= htmlspecialchars($netInterfaces, ENT_QUOTES, 'UTF-8') ?></script>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
(function(){
const $ = id => document.getElementById(id);
const circumference = 2 * Math.PI * 52;
Chart.defaults.color = '#94a3b8';
Chart.defaults.borderColor = '#334155';
Chart.defaults.font.size = 11;
const fmtBytes = v => {
if (v >= 1048576) return (v/1048576).toFixed(1) + ' MB/s';
if (v >= 1024) return (v/1024).toFixed(1) + ' KB/s';
return Math.round(v) + ' B/s';
};
const cpuChart = new Chart($('chart-cpu'), {
type: 'line',
data: { labels: [], datasets: [
{ label: 'CPU %', data: [], borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.4, fill: true, pointRadius: 0 },
{ label: '内存 %', data: [], borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)', tension: 0.4, fill: true, pointRadius: 0 }
]},
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 1500, easing: 'easeInOutCubic' },
plugins: { legend: { display: true } },
scales: { x: { display: false }, y: { min: 0, max: 100, grid: { color: '#334155' } } }
}
});
const netChart = new Chart($('chart-net'), {
type: 'line',
data: { labels: [], datasets: [
{ label: '下载', data: [], borderColor: '#06b6d4', tension: 0.4, fill: false, pointRadius: 0 },
{ label: '上传', data: [], borderColor: '#f59e0b', tension: 0.4, fill: false, pointRadius: 0 }
]},
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 1500, easing: 'easeInOutCubic' },
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtBytes(ctx.raw) } } },
scales: { x: { display: false }, y: { grid: { color: '#334155' }, ticks: { callback: v => fmtBytes(v) } } }
}
});
const diskIoChart = new Chart($('chart-disk-io'), {
type: 'line',
data: { labels: [], datasets: [
{ label: '读取', data: [], borderColor: '#8b5cf6', tension: 0.4, fill: false, pointRadius: 0 },
{ label: '写入', data: [], borderColor: '#ec4899', tension: 0.4, fill: false, pointRadius: 0 }
]},
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 1500, easing: 'easeInOutCubic' },
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtBytes(ctx.raw) } } },
scales: { x: { display: false }, y: { grid: { color: '#334155' }, ticks: { callback: v => fmtBytes(v) } } }
}
});
const latencyChart = new Chart($('chart-latency'), {
type: 'line',
data: { labels: [], datasets: [
{ label: '延迟 ms', data: [], borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.1)', tension: 0.4, fill: true, pointRadius: 0 }
]},
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 1500, easing: 'easeInOutCubic' },
plugins: { legend: { display: true } },
scales: { x: { display: false }, y: { grid: { color: '#334155' }, beginAtZero: true } }
}
});
// 加载历史
const MAX_POINTS = 10;
// 初始化空图表
[cpuChart, netChart, diskIoChart, latencyChart].forEach(c => {
for (let i = 0; i < MAX_POINTS; i++) c.data.labels.push('');
});
fetch('/api/dashboard/history').then(r => r.json()).then(hist => {
const data = {
cpu: hist.cpu, ram: hist.ram,
net_recv: hist.net_recv, net_sent: hist.net_sent,
disk_read: hist.disk_read, disk_write: hist.disk_write,
latency: hist.latency || []
};
const start = Math.max(0, data.cpu.length - MAX_POINTS);
const slice = data.cpu.slice(start);
cpuChart.data.datasets[0].data = data.cpu.slice(start);
cpuChart.data.datasets[1].data = data.ram.slice(start);
netChart.data.datasets[0].data = data.net_recv.slice(start);
netChart.data.datasets[1].data = data.net_sent.slice(start);
diskIoChart.data.datasets[0].data = data.disk_read.slice(start);
diskIoChart.data.datasets[1].data = data.disk_write.slice(start);
latencyChart.data.datasets[0].data = data.latency.slice(start);
// 不足10个补默认值
const pad = (chart, vals) => {
const diff = MAX_POINTS - chart.data.datasets[0].data.length;
for (let i = 0; i < diff; i++) {
chart.data.datasets[0].data.push(vals[0]);
if (chart.data.datasets[1]) chart.data.datasets[1].data.push(vals[1] ?? vals[0]);
}
};
pad(cpuChart, [50, 50]);
pad(netChart, [0, 0]);
pad(diskIoChart, [0, 0]);
pad(latencyChart, [0]);
cpuChart.update(); netChart.update(); diskIoChart.update(); latencyChart.update();
}).catch(() => {
// 加载失败也补默认
[cpuChart, netChart, diskIoChart, latencyChart].forEach(c => {
c.data.datasets.forEach(ds => {
while (ds.data.length < MAX_POINTS) ds.data.push(0);
});
c.update();
});
});
// 渲染网络接口
try {
const el = $('ifaces-data');
if (el) {
const ifaces = JSON.parse(el.textContent);
const container = $('net-ifaces');
if (ifaces.length === 0) {
container.innerHTML = '<div class="net-iface"><div class="iface-info">暂无网络接口</div></div>';
} else {
let html = '';
ifaces.forEach(iface => {
html += `<div class="net-iface"><div><div class="iface-name">${iface.name}</div><div class="iface-info">${iface.ip}</div></div><span class="iface-status ${iface.is_up ? 'status-up' : 'status-down'}">${iface.is_up ? 'UP' : 'DOWN'}</span></div>`;
});
container.innerHTML = html;
}
}
} catch(e) {}
// 定时刷新
setInterval(() => {
fetch('/api/dashboard/stats').then(r => r.json()).then(d => {
const setGauge = (id, pct) => {
const el = $(id);
if (el) el.setAttribute('stroke-dashoffset', circumference - (pct/100)*circumference);
};
setGauge('cpu-gauge', d.cpu.percent);
setGauge('ram-gauge', d.ram.percent);
setGauge('disk-gauge', d.disk.percent);
$('cpu-val').textContent = d.cpu.percent;
$('ram-val').textContent = d.ram.percent;
$('disk-val').textContent = d.disk.percent;
$('load-val').textContent = d.load.load1;
$('net-recv').textContent = fmtBytes(d.network.recv_rate);
$('net-sent').textContent = fmtBytes(d.network.sent_rate);
$('disk-read').textContent = fmtBytes(d.disk_io.read_rate);
$('disk-write').textContent = fmtBytes(d.disk_io.write_rate);
$('proc-count').textContent = d.processes;
$('uptime-val').textContent = d.uptime;
$('uptime-info').textContent = d.uptime;
// 刷新固定10个点数据向左平滑滚动
const pushChart = (chart, v1, v2) => {
// 移除最左边旧数据
chart.data.datasets[0].data.shift();
// 新数据从右边加入
chart.data.datasets[0].data.push(v1);
if (chart.data.datasets[1]) {
chart.data.datasets[1].data.shift();
chart.data.datasets[1].data.push(v2);
}
// 触发 Chart.js 内置过渡动画
chart.update('default');
};
pushChart(cpuChart, d.cpu.percent, d.ram.percent);
pushChart(netChart, d.network.recv_rate, d.network.sent_rate);
pushChart(diskIoChart, d.disk_io.read_rate, d.disk_io.write_rate);
// 网络延迟图
latencyChart.data.datasets[0].data.shift();
latencyChart.data.datasets[0].data.push(d.latency || 0);
latencyChart.update('default');
}).catch(() => {});
}, 2000);
})();
</script>
</body>
</html>

View File

@@ -551,55 +551,279 @@ class LogTerminalPlugin(Plugin):
return logs[-limit:] return logs[-limit:]
def _render_logs(self) -> str: def _render_logs(self) -> str:
"""渲染日志查看界面""" """渲染日志查看界面 - 纯 HTML/Python 模板"""
try: try:
php_file = os.path.join(self.views_dir, 'logs.php') logs = self._get_logs(limit=100)
if not os.path.exists(php_file): log_rows = ""
return "<p>日志视图文件丢失</p>" for log in logs:
return self._execute_php(php_file, {}) 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: except Exception as e:
return f"<p>日志视图渲染出错: {e}</p>" return f"<p>日志视图渲染出错{e}</p>"
def _render_terminal(self) -> str: def _render_terminal(self) -> str:
"""渲染终端界面""" """渲染终端界面 - 纯 HTML/Python 模板"""
try: try:
php_file = os.path.join(self.views_dir, 'terminal.php') html = """<!DOCTYPE html>
if not os.path.exists(php_file): <html lang="zh-CN">
return "<p>终端视图文件丢失</p>" <head>
return self._execute_php(php_file, {}) <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: except Exception as e:
return f"<p>终端视图渲染出错: {e}</p>" 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
register_plugin_type("LogTerminalPlugin", LogTerminalPlugin) 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>

View File

@@ -104,46 +104,187 @@ class PkgManagerPlugin(Plugin):
# ==================== 页面渲染 ==================== # ==================== 页面渲染 ====================
def _packages_content(self) -> str: def _packages_content(self) -> str:
return self._render_php_view('packages.php', {'pageTitle': '插件管理'}) """渲染插件管理页面 - 纯 HTML/Python 模板"""
try:
# 获取已安装的插件列表
plugins = self._get_installed_plugins()
plugin_rows = ""
for pkg_name, info in plugins.items():
status_class = "success" if info.get('enabled', False) else "secondary"
status_text = "已启用" if info.get('enabled', False) else "已禁用"
plugin_rows += f"""
<tr>
<td>{pkg_name}</td>
<td>{info.get('version', '未知')}</td>
<td>{info.get('author', '未知')}</td>
<td><span class="badge badge-{status_class}">{status_text}</span></td>
<td>
<button class="btn btn-sm btn-primary" onclick="togglePlugin('{pkg_name}')">切换状态</button>
<button class="btn btn-sm btn-danger" onclick="uninstallPlugin('{pkg_name}')">卸载</button>
</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-danger {{ background: #e74c3c; color: white; }}
.btn-danger:hover {{ background: #c0392b; }}
.btn-sm {{ padding: 4px 8px; font-size: 12px; }}
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; }}
tr:hover {{ background: #f8f9fa; }}
.badge {{ padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }}
.badge-success {{ background: #d5f5e3; color: #27ae60; }}
.badge-secondary {{ background: #e5e7eb; color: #6b7280; }}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2 class="card-title"><i class="ri-plug-line"></i> 插件管理</h2>
<button class="btn btn-primary" onclick="location.href='/store'"><i class="ri-store-line"></i> 前往商店</button>
</div>
<table>
<thead>
<tr>
<th>插件名称</th>
<th>版本</th>
<th>作者</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{plugin_rows}
</tbody>
</table>
</div>
</div>
<script>
function togglePlugin(name) {{
fetch('/api/plugins/toggle', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{plugin: name}})
}}).then(() => location.reload());
}}
function uninstallPlugin(name) {{
if (confirm('确定要卸载 ' + name + ' 吗?')) {{
fetch('/api/plugins/uninstall', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{plugin: name}})
}}).then(() => location.reload());
}}
}}
</script>
</body>
</html>"""
return html
except Exception as e:
return f"<p>插件管理页面渲染出错:{{e}}</p>"
def _store_content(self) -> str: def _store_content(self) -> str:
return self._render_php_view('store.php', {'pageTitle': '插件商店'}) """渲染插件商店页面 - 纯 HTML/Python 模板"""
def _render_php_view(self, view_name: str, variables: dict) -> str:
import subprocess
views_dir = os.path.join(os.path.dirname(__file__), 'views')
php_file = os.path.join(views_dir, view_name)
if not os.path.exists(php_file):
return f"<h1>错误: 找不到 {view_name}</h1>"
php_vars = ""
for key, value in variables.items():
if isinstance(value, str):
php_vars += f"${key} = '{value}';\n"
else:
php_vars += f"${key} = {json.dumps(value)};\n"
with open(php_file, 'r', encoding='utf-8') as f:
php_content = f.read()
tmp_file = os.path.join(views_dir, '.temp_pkg.php')
try: try:
with open(tmp_file, 'w', encoding='utf-8') as f: # 获取可用插件列表
f.write(f"<?php\n{php_vars}\n?>\n{php_content}") available = self._get_available_plugins()
installed = self._get_installed_plugins()
plugin_cards = ""
for pkg_name, info in available.items():
is_installed = pkg_name in installed
action_btn = f'<button class="btn btn-success" onclick="installPlugin(\'{pkg_name}\')">安装</button>' if not is_installed else '<button class="btn btn-secondary" disabled>已安装</button>'
plugin_cards += f"""
<div class="plugin-card">
<div class="plugin-icon"><i class="ri-plug-line"></i></div>
<h3>{info.get('name', pkg_name)}</h3>
<p class="plugin-desc">{info.get('description', '暂无描述')}</p>
<div class="plugin-meta">
<span>版本:{info.get('version', '未知')}</span>
<span>作者:{info.get('author', '未知')}</span>
</div>
<div class="plugin-actions">
{action_btn}
</div>
</div>"""
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 {{ 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-success {{ background: #27ae60; color: white; }}
.btn-success:hover {{ background: #229954; }}
.btn-secondary {{ background: #95a5a6; color: white; cursor: not-allowed; }}
.plugins-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }}
.plugin-card {{ background: #f8f9fa; border-radius: 8px; padding: 20px; transition: transform 0.3s; }}
.plugin-card:hover {{ transform: translateY(-5px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }}
.plugin-icon {{ width: 48px; height: 48px; background: #3498db; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-size: 24px; margin-bottom: 15px; }}
.plugin-card h3 {{ font-size: 16px; color: #2c3e50; margin-bottom: 10px; }}
.plugin-desc {{ color: #7f8c8d; font-size: 14px; margin-bottom: 15px; line-height: 1.5; }}
.plugin-meta {{ display: flex; justify-content: space-between; font-size: 12px; color: #95a5a6; margin-bottom: 15px; }}
.plugin-actions {{ display: flex; gap: 10px; }}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2 class="card-title"><i class="ri-store-line"></i> 插件商店</h2>
</div>
<div class="plugins-grid">
{plugin_cards}
</div>
</div>
</div>
<script>
function installPlugin(name) {{
fetch('/api/plugins/install', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{plugin: name}})
}}).then(r => r.json()).then(data => {{
if (data.success) {{
alert('安装成功!');
location.reload();
}} else {{
alert('安装失败:' + data.error);
}}
}});
}}
</script>
</body>
</html>"""
return html
except Exception as e:
return f"<p>插件商店页面渲染出错:{{e}}</p>"
result = subprocess.run(
["php", "-f", tmp_file],
capture_output=True, text=True, timeout=10, cwd=views_dir,
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:
pass
# ==================== API 处理 ==================== # ==================== API 处理 ====================

View File

@@ -1,337 +0,0 @@
<div class="packages-page" x-data="packagesApp()" x-init="init()">
<style>
.packages-page { display: flex; height: calc(100vh - 40px); }
.pkg-sidebar {
width: 300px; min-width: 300px; background: #fff; border-right: 1px solid #e8ecf0;
display: flex; flex-direction: column;
}
.pkg-sidebar-header { padding: 20px; border-bottom: 1px solid #f0f0f0; }
.pkg-sidebar-header h3 { font-size: 16px; font-weight: 600; color: #1a1a2e; }
.pkg-search { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; }
.pkg-search input {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 8px; font-size: 13px; outline: none; box-sizing: border-box;
}
.pkg-search input:focus { border-color: #4a90d9; }
.pkg-list { flex: 1; overflow-y: auto; }
.pkg-item {
padding: 14px 16px; cursor: pointer; border-bottom: 1px solid #f8f8f8;
transition: background 0.15s;
}
.pkg-item:hover { background: #f8f9fa; }
.pkg-item.active { background: #eef4fb; border-left: 3px solid #4a90d9; }
.pkg-item-name { font-size: 14px; font-weight: 500; color: #333; }
.pkg-item-desc { font-size: 12px; color: #999; margin-top: 4px; }
.pkg-item-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 6px; align-items: center; }
.pkg-item-status { color: #2ecc71; }
.pkg-content { flex: 1; overflow-y: auto; padding: 24px 32px; background: #f9fafb; }
.pkg-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; font-size: 15px; }
.pkg-config-header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-start; }
.pkg-config-header h2 { font-size: 22px; font-weight: 600; color: #1a1a2e; }
.pkg-config-header p { color: #888; font-size: 14px; margin-top: 4px; }
.pkg-info-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.pkg-info-tag {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
background: #fff; border-radius: 8px; font-size: 13px; color: #555; border: 1px solid #e8ecf0;
}
.pkg-info-tag i { font-size: 16px; }
.pkg-info-tag .count {
background: #4a90d9; color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 11px;
}
.config-section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.config-section h4 { font-size: 15px; font-weight: 600; color: #1a1a2e; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
.config-field { margin-bottom: 20px; }
.config-field label { display: block; font-size: 13px; font-weight: 500; color: #333; margin-bottom: 6px; }
.config-field .desc { font-size: 12px; color: #999; margin-bottom: 8px; }
.config-field input[type="text"],
.config-field input[type="number"],
.config-field textarea,
.config-field select {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 8px; font-size: 13px; outline: none; transition: border-color 0.2s;
box-sizing: border-box;
}
.config-field input:focus, .config-field select:focus, .config-field textarea:focus { border-color: #4a90d9; }
.config-field textarea { min-height: 80px; resize: vertical; }
.toggle { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
.toggle input { display: none; }
.toggle-slider { width: 44px; height: 24px; background: #ddd; border-radius: 12px; position: relative; transition: background 0.2s; }
.toggle-slider::after {
content: ''; position: absolute; width: 20px; height: 20px; background: #fff;
border-radius: 50%; top: 2px; left: 2px; transition: transform 0.2s;
}
.toggle input:checked + .toggle-slider { background: #4a90d9; }
.toggle input:checked + .toggle-slider::after { transform: translateX(20px); }
.radio-group, .checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; }
.radio-option, .checkbox-option {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
border: 1px solid #e0e0e0; border-radius: 8px; cursor: pointer;
font-size: 13px; transition: all 0.15s;
}
.radio-option:hover, .checkbox-option:hover { border-color: #4a90d9; background: #f0f5fc; }
.radio-option.selected, .checkbox-option.selected { border-color: #4a90d9; background: #eef4fb; color: #4a90d9; }
.action-btns { display: flex; gap: 12px; margin-top: 8px; }
.save-btn { padding: 10px 24px; background: #4a90d9; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; }
.save-btn:hover { background: #3a7bc8; }
.save-btn:disabled { background: #ccc; cursor: not-allowed; }
.uninstall-btn { padding: 10px 24px; background: #fff; color: #e74c3c; border: 1px solid #e74c3c; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.uninstall-btn:hover { background: #fee; }
.status-msg { padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
.status-msg.success { background: #e8f8ef; color: #2ecc71; }
.status-msg.error { background: #fde8e8; color: #e74c3c; }
</style>
<!-- 左栏:已安装插件列表 -->
<div class="pkg-sidebar">
<div class="pkg-sidebar-header"><h3>已安装插件</h3></div>
<div class="pkg-search">
<input type="text" placeholder="搜索插件..." x-model="searchQuery" />
</div>
<div class="pkg-list">
<template x-for="plugin in filteredPlugins" :key="plugin.name">
<div class="pkg-item" :class="{ active: selectedPlugin?.name === plugin.name }"
@click="selectPlugin(plugin)">
<div class="pkg-item-name" x-text="plugin.metadata.name || plugin.name"></div>
<div class="pkg-item-desc" x-text="plugin.metadata.description || '暂无描述'"></div>
<div class="pkg-item-meta">
<span x-text="'v' + (plugin.metadata.version || '?')"></span>
<span style="color:#888;" x-text="'by ' + plugin.author"></span>
<span x-show="plugin.has_config" style="color:#4a90d9;">⚙️</span>
</div>
</div>
</template>
</div>
</div>
<!-- 右栏:配置面板 -->
<div class="pkg-content">
<template x-if="!selectedPlugin">
<div class="pkg-empty"> 选择一个插件以查看配置</div>
</template>
<template x-if="selectedPlugin">
<div>
<div class="pkg-config-header">
<div>
<h2 x-text="selectedPlugin.metadata.name || selectedPlugin.name"></h2>
<p x-text="selectedPlugin.metadata.description"></p>
</div>
</div>
<!-- 信息栏:依赖、页面、事件(只在有数据时显示) -->
<div class="pkg-info-bar">
<div class="pkg-info-tag" x-show="pluginDeps.length > 0">
<i class="ri-plug-line"></i>
<span>依赖:</span>
<template x-for="dep in pluginDeps" :key="dep">
<span class="count" x-text="dep"></span>
</template>
</div>
<div class="pkg-info-tag" x-show="pluginPages.length > 0">
<i class="ri-pages-line"></i>
<span>页面:</span>
<template x-for="pg in pluginPages" :key="pg.path">
<span class="count" x-text="pg.path"></span>
</template>
</div>
<div class="pkg-info-tag" x-show="pluginEvents.length > 0">
<i class="ri-flashlight-line"></i>
<span>事件:</span>
<template x-for="evt in pluginEvents" :key="evt">
<span class="count" x-text="evt"></span>
</template>
</div>
</div>
<!-- 配置表单 -->
<div x-show="configSchema && Object.keys(configSchema).length > 0">
<div class="config-section">
<h4>⚙️ 配置</h4>
<template x-for="[key, field] in sortedConfigFields" :key="key">
<div class="config-field" x-show="isFieldVisible(key, field)">
<label x-text="field.name || key"></label>
<div class="desc" x-text="field.description"></div>
<template x-if="field.type === 'string'">
<input type="text" x-model="configValues[key]" />
</template>
<template x-if="field.type === 'number'">
<input type="number" x-model.number="configValues[key]" :min="field.min ?? 0" :max="field.max ?? 99999" />
</template>
<template x-if="field.type === 'boolean'">
<label class="toggle">
<input type="checkbox" x-model="configValues[key]" />
<span class="toggle-slider"></span>
<span x-text="configValues[key] ? '已开启' : '已关闭'"></span>
</label>
</template>
<template x-if="field.type === 'select'">
<div class="radio-group">
<template x-for="opt in field.options" :key="opt.value">
<div class="radio-option" :class="{ selected: configValues[key] === opt.value }"
@click="configValues[key] = opt.value">
<span x-text="opt.label"></span>
</div>
</template>
</div>
</template>
<template x-if="field.type === 'list'">
<div class="checkbox-group">
<template x-for="opt in field.options" :key="opt.value">
<div class="checkbox-option" :class="{ selected: (configValues[key] || []).includes(opt.value) }"
@click="toggleListValue(key, opt.value)">
<span x-text="opt.label"></span>
</div>
</template>
</div>
</template>
<template x-if="field.type === 'textarea'">
<textarea x-model="configValues[key]"></textarea>
</template>
</div>
</template>
</div>
<div class="action-btns">
<button class="save-btn" @click="saveConfig()" :disabled="saving">
<span x-show="!saving">💾 保存配置</span>
<span x-show="saving">保存中...</span>
</button>
<button class="uninstall-btn" @click="uninstallPlugin()">
🗑️ 卸载插件
</button>
</div>
<div class="status-msg" :class="saveStatus.type" x-show="saveStatus.msg" x-text="saveStatus.msg"></div>
</div>
<div x-show="!configSchema || Object.keys(configSchema).length === 0" class="config-section">
<p style="color:#999;">该插件没有可配置的选项</p>
<div class="action-btns">
<button class="uninstall-btn" @click="uninstallPlugin()">🗑️ 卸载插件</button>
</div>
</div>
</div>
</template>
</div>
<script>
function packagesApp() {
return {
plugins: [],
searchQuery: '',
selectedPlugin: null,
configSchema: {},
configValues: {},
pluginDeps: [],
pluginPages: [],
pluginEvents: [],
saving: false,
saveStatus: { type: '', msg: '' },
init() { this.loadPlugins(); },
async loadPlugins() {
const res = await fetch('/api/plugins');
this.plugins = await res.json();
},
get filteredPlugins() {
if (!this.searchQuery) return this.plugins;
const q = this.searchQuery.toLowerCase();
return this.plugins.filter(p =>
(p.metadata.name || '').toLowerCase().includes(q) ||
(p.metadata.description || '').toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q)
);
},
get sortedConfigFields() {
if (!this.configSchema) return [];
return Object.entries(this.configSchema).sort((a, b) => (a[1].order || 99) - (b[1].order || 99));
},
async selectPlugin(plugin) {
this.selectedPlugin = plugin;
this.configSchema = {};
this.configValues = {};
this.pluginDeps = [];
this.pluginPages = [];
this.pluginEvents = [];
if (plugin.has_config) {
const res = await fetch(`/api/plugins/${plugin.name}/config`);
const data = await res.json();
this.configSchema = data.schema || {};
this.configValues = data.current || {};
}
const infoRes = await fetch(`/api/plugins/${plugin.name}/info`);
const info = await infoRes.json();
this.pluginDeps = info.dependencies || [];
this.pluginPages = info.pages || [];
this.pluginEvents = info.events || [];
},
isFieldVisible(key, field) {
if (field.show_when) {
return this.configValues[field.show_when.field] === field.show_when.value;
}
return true;
},
toggleListValue(key, value) {
if (!this.configValues[key]) this.configValues[key] = [];
const idx = this.configValues[key].indexOf(value);
if (idx >= 0) this.configValues[key].splice(idx, 1);
else this.configValues[key].push(value);
},
async saveConfig() {
this.saving = true;
this.saveStatus = {};
try {
const res = await fetch(`/api/plugins/${this.selectedPlugin.name}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.configValues)
});
if (res.ok) {
this.saveStatus = { type: 'success', msg: '✅ 配置已保存' };
} else {
this.saveStatus = { type: 'error', msg: '❌ 保存失败' };
}
} catch (e) {
this.saveStatus = { type: 'error', msg: '❌ 网络错误' };
}
this.saving = false;
setTimeout(() => { this.saveStatus.msg = ''; }, 3000);
},
async uninstallPlugin() {
if (!confirm('确定要卸载 ' + (this.selectedPlugin.metadata.name || this.selectedPlugin.name) + ' 吗?\n卸载后需要重启 FutureOSS 才能完全生效。')) return;
try {
const res = await fetch(`/api/plugins/${this.selectedPlugin.name}/uninstall`, { method: 'POST' });
const data = await res.json();
if (data.ok) {
alert('✅ 已卸载,请重启 FutureOSS');
this.loadPlugins();
this.selectedPlugin = null;
} else {
alert('❌ 卸载失败: ' + (data.error || '未知错误'));
}
} catch (e) { alert('❌ 网络错误'); }
}
};
}
</script>
</div>

View File

@@ -1,197 +0,0 @@
<div class="store-page" x-data="storeApp()" x-init="init()">
<style>
.store-page { display: flex; height: calc(100vh - 40px); }
.store-sidebar {
width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e8ecf0;
display: flex; flex-direction: column; padding: 20px 0;
}
.store-sidebar-title { font-size: 14px; font-weight: 600; color: #1a1a2e; padding: 0 20px 12px; border-bottom: 1px solid #f0f0f0; }
.store-filter { padding: 10px 20px; cursor: pointer; font-size: 13px; color: #555; transition: all 0.15s; }
.store-filter:hover { background: #f8f9fa; }
.store-filter.active { background: #eef4fb; color: #4a90d9; font-weight: 500; border-right: 3px solid #4a90d9; }
.store-filter .count { float: right; background: #f0f0f0; border-radius: 10px; padding: 1px 8px; font-size: 11px; }
.store-filter.active .count { background: #4a90d9; color: #fff; }
.store-main { flex: 1; overflow-y: auto; padding: 24px 32px; background: #f9fafb; }
.store-header { margin-bottom: 24px; }
.store-header h2 { font-size: 26px; font-weight: 600; color: #1a1a2e; }
.store-header p { color: #888; font-size: 14px; margin-top: 4px; }
.store-search { margin-bottom: 20px; }
.store-search input {
width: 100%; max-width: 400px; padding: 10px 16px; border: 1px solid #e0e0e0;
border-radius: 10px; font-size: 14px; outline: none; box-sizing: border-box;
}
.store-search input:focus { border-color: #4a90d9; }
.store-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.store-card { background: #fff; border-radius: 14px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 12px; }
.store-card-header { display: flex; justify-content: space-between; align-items: flex-start; }
.store-card-name { font-size: 16px; font-weight: 600; color: #1a1a2e; }
.store-card-version { font-size: 12px; color: #999; background: #f0f0f0; padding: 2px 8px; border-radius: 10px; }
.store-card-desc { font-size: 13px; color: #666; flex: 1; line-height: 1.5; }
.store-card-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.store-card-tag { font-size: 11px; padding: 3px 8px; background: #f0f5fc; color: #4a90d9; border-radius: 6px; }
.store-card-tag.installed { background: #e8f8ef; color: #2ecc71; }
.install-btn {
padding: 8px 18px; border: none; border-radius: 8px; font-size: 13px;
font-weight: 500; cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.install-btn.install { background: #4a90d9; color: #fff; }
.install-btn.install:hover { background: #3a7bc8; }
.install-btn.installed { background: #e8f8ef; color: #2ecc71; cursor: default; }
.install-btn:disabled { background: #ccc; cursor: not-allowed; }
.store-empty { text-align: center; padding: 60px 20px; color: #999; }
.store-empty i { font-size: 48px; margin-bottom: 12px; display: block; }
.store-loading { text-align: center; padding: 80px 20px; color: #666; }
.store-loading i { font-size: 36px; animation: spin 1s linear infinite; display: block; margin-bottom: 16px; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<!-- 左栏:分类 -->
<div class="store-sidebar">
<div class="store-sidebar-title">分类</div>
<div class="store-filter" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">
全部插件 <span class="count" x-text="plugins.length"></span>
</div>
<div class="store-filter" :class="{ active: activeFilter === 'available' }" @click="activeFilter = 'available'">
可安装 <span class="count" x-text="plugins.filter(p => !p.is_installed).length"></span>
</div>
<div class="store-filter" :class="{ active: activeFilter === 'installed' }" @click="activeFilter = 'installed'">
已安装 <span class="count" x-text="plugins.filter(p => p.is_installed).length"></span>
</div>
<div class="store-filter" :class="{ active: activeFilter === 'configurable' }" @click="activeFilter = 'configurable'">
可配置 <span class="count" x-text="plugins.filter(p => p.has_config).length"></span>
</div>
</div>
<!-- 右栏:插件卡片列表 -->
<div class="store-main">
<div class="store-header">
<h2>插件商店</h2>
<p>浏览并安装插件来扩展功能</p>
</div>
<!-- 加载中状态 -->
<div class="store-loading" x-show="!loaded && !loadError">
<i class="ri-loader-4-line"></i>
<p>正在加载插件列表...</p>
</div>
<!-- 加载失败状态 -->
<div class="store-empty" x-show="loadError">
<i class="ri-error-warning-line"></i>
<p>加载失败,请稍后重试</p>
</div>
<div class="store-search" x-show="loaded && !loadError">
<input type="text" placeholder="搜索插件名称或描述..." x-model="searchQuery" />
</div>
<div class="store-grid" x-show="loaded && !loadError && filteredPlugins.length > 0">
<template x-for="plugin in filteredPlugins" :key="plugin.full_name">
<div class="store-card">
<div class="store-card-header">
<div>
<div class="store-card-name" x-text="plugin.metadata.name || plugin.name"></div>
<div class="store-card-version" x-text="(plugin.metadata.version ? 'v' + plugin.metadata.version : '') + (plugin.author ? ' · ' + plugin.author : '')"></div>
</div>
<button class="install-btn" :class="plugin.is_installed ? 'installed' : 'install'"
@click="!plugin.is_installed && installPlugin(plugin)"
:disabled="loading">
<span x-show="!plugin.is_installed && !loading">📦 安装</span>
<span x-show="plugin.is_installed"> 已安装</span>
<span x-show="loading">...</span>
</button>
</div>
<div class="store-card-desc" x-text="plugin.metadata.description || '暂无描述'"></div>
<div class="store-card-tags">
<template x-for="dep in (plugin.dependencies || [])" :key="dep">
<span class="store-card-tag" x-text="'🔌 ' + dep"></span>
</template>
<span class="store-card-tag" x-show="plugin.has_config">⚙️ 可配置</span>
</div>
</div>
</template>
</div>
<div class="store-empty" x-show="loaded && !loadError && filteredPlugins.length === 0">
<i class="ri-store-2-line"></i>
<p x-text="plugins.length === 0 ? '无法连接 Gitee API请检查网络或配置' : '没有找到匹配的插件'"></p>
</div>
</div>
<script>
function storeApp() {
return {
plugins: [],
searchQuery: '',
activeFilter: 'all',
loading: false,
loaded: false,
loadError: false,
init() { this.loadPlugins(); },
async loadPlugins() {
this.loaded = false;
this.loadError = false;
try {
const res = await fetch('/api/store/remote');
if (!res.ok) throw new Error('API 返回错误');
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
this.plugins = data;
} else {
this.loadError = true;
}
} catch (e) {
console.error('获取远程插件失败:', e);
this.loadError = true;
}
this.loaded = true;
},
get filteredPlugins() {
let list = this.plugins;
if (this.activeFilter === 'available') list = list.filter(p => !p.is_installed);
else if (this.activeFilter === 'installed') list = list.filter(p => p.is_installed);
else if (this.activeFilter === 'configurable') list = list.filter(p => p.has_config);
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(p =>
(p.metadata.name || '').toLowerCase().includes(q) ||
(p.metadata.description || '').toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q)
);
}
return list;
},
async installPlugin(plugin) {
this.loading = true;
try {
const res = await fetch('/api/store/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: plugin.name, author: plugin.author })
});
const data = await res.json();
if (data.ok) {
plugin.is_installed = true;
alert('✅ 安装成功,请重启 FutureOSS 以启用插件');
} else {
alert('❌ 安装失败: ' + (data.error || '未知错误'));
}
} catch (e) { alert('❌ 网络错误'); }
this.loading = false;
}
};
}
</script>
</div>

View File

@@ -1,26 +0,0 @@
<?php
/**
* FutureOSS WebUI 配置文件
*/
return [
// 数据库配置
'database' => [
'host' => 'localhost',
'port' => 3306,
'username' => 'root',
'password' => '',
'dbname' => 'futureoss',
'charset' => 'utf8mb4'
],
// 应用配置
'app' => [
'title' => 'FutureOSS',
'theme' => 'dark',
'version' => '1.0.0'
],
// 其他插件可以添加配置
'plugins' => []
];

View File

@@ -1,115 +0,0 @@
<?php
/**
* 数据库连接类
* 提供 MySQL 数据库连接和基础查询功能
*/
class Database {
private static $instance = null;
private $connection;
private $config;
private function __construct() {
$configFile = __DIR__ . '/../config/config.php';
if (!file_exists($configFile)) {
throw new Exception('配置文件不存在: ' . $configFile);
}
$this->config = include $configFile;
$dbConfig = $this->config['database'];
try {
$dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['dbname']};charset={$dbConfig['charset']}";
$this->connection = new PDO($dsn, $dbConfig['username'], $dbConfig['password']);
$this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
// 数据库连接失败时记录日志但不阻止页面加载
error_log('[FutureOSS WebUI] 数据库连接失败: ' . $e->getMessage());
$this->connection = null;
}
}
/**
* 获取单例实例
*/
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 获取数据库连接
*/
public function getConnection() {
return $this->connection;
}
/**
* 检查数据库是否可用
*/
public function isConnected() {
return $this->connection !== null;
}
/**
* 执行查询
*/
public function query($sql, $params = []) {
if (!$this->isConnected()) {
return false;
}
try {
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
return $stmt;
} catch (PDOException $e) {
error_log('[FutureOSS WebUI] 数据库查询错误: ' . $e->getMessage());
return false;
}
}
/**
* 获取所有结果
*/
public function fetchAll($sql, $params = []) {
$stmt = $this->query($sql, $params);
return $stmt ? $stmt->fetchAll() : [];
}
/**
* 获取单条结果
*/
public function fetchOne($sql, $params = []) {
$stmt = $this->query($sql, $params);
return $stmt ? $stmt->fetch() : null;
}
/**
* 插入数据并返回 ID
*/
public function insert($sql, $params = []) {
$stmt = $this->query($sql, $params);
return $stmt ? $this->connection->lastInsertId() : false;
}
/**
* 防止 SQL 注入
*/
public function escape($value) {
if (!$this->isConnected()) {
return addslashes($value);
}
return $this->connection->quote($value);
}
// 防止克隆
private function __clone() {}
public function __wakeup() {
throw new Exception("Cannot unserialize singleton");
}
}