tpanel/frontend/index.html
2026-06-01 10:15:58 +08:00

2377 lines
No EOL
76 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>T面板 - Linux 网站管理面板</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e1a;
--sidebar-bg: #0f172a;
--card: #1a2332;
--card-hover: #1e293b;
--border: #2a3548;
--green: #22c55e;
--green-dim: rgba(34,197,94,0.15);
--red: #ef4444;
--red-dim: rgba(239,68,68,0.15);
--yellow: #eab308;
--blue: #3b82f6;
--text: #e2e8f0;
--text-dim: #64748b;
--text-muted: #94a3b8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
overflow-x: hidden;
}
/* Sidebar */
.sidebar {
width: 220px;
min-height: 100vh;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
bottom: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-logo .icon {
width: 36px;
height: 36px;
background: var(--green);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.sidebar-logo span {
font-size: 18px;
font-weight: 800;
color: #fff;
}
.sidebar-nav {
padding: 12px 8px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: var(--text-dim);
text-decoration: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 2px;
}
.nav-item:hover {
background: var(--card);
color: var(--text);
}
.nav-item.active {
background: var(--green-dim);
color: var(--green);
}
.nav-item .emoji { font-size: 16px; }
.nav-section {
padding: 16px 12px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.sidebar-footer {
padding: 16px 12px;
border-top: 1px solid var(--border);
}
.sidebar-footer .version {
font-size: 12px;
color: var(--text-dim);
}
/* Main */
.main {
margin-left: 220px;
flex: 1;
min-height: 100vh;
}
/* Topbar */
.topbar {
height: 64px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: var(--sidebar-bg);
position: sticky;
top: 0;
z-index: 10;
}
.topbar-left {
display: flex;
align-items: center;
gap: 20px;
}
.topbar h1 {
font-size: 17px;
font-weight: 700;
color: #fff;
}
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 100px;
font-size: 13px;
font-weight: 500;
}
.status-pill.ok {
background: var(--green-dim);
color: var(--green);
}
.status-pill.warn {
background: var(--red-dim);
color: var(--red);
}
.status-pill .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.topbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.topbar-btn:hover {
background: var(--card-hover);
border-color: var(--text-dim);
}
.topbar-btn.green {
background: var(--green);
color: #fff;
border-color: var(--green);
}
.topbar-btn.green:hover {
background: #16a34a;
}
.topbar-btn.red {
color: var(--red);
}
.topbar-btn.red:hover {
background: var(--red-dim);
}
/* Content */
.content {
padding: 28px;
}
/* Dashboard grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px;
}
.stat-card .label {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.stat-card .value {
font-size: 26px;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
}
.stat-card .sub {
font-size: 12px;
color: var(--text-dim);
}
.stat-card .pct {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
margin-left: 6px;
}
.stat-card .pct.up { background: var(--green-dim); color: var(--green); }
.stat-card .pct.warn { background: var(--red-dim); color: var(--red); }
/* Dashboard panels */
.dashboard-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 28px;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 15px;
font-weight: 700;
color: #fff;
}
.panel-body {
padding: 16px 20px;
}
.panel-body p {
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
}
/* Service status list */
.service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.service-item:last-child { border-bottom: none; }
.service-info {
display: flex;
align-items: center;
gap: 10px;
}
.service-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.service-desc {
font-size: 12px;
color: var(--text-dim);
}
/* Tables */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 10px 12px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
.data-table td {
padding: 12px;
font-size: 13px;
color: var(--text-muted);
border-bottom: 1px solid rgba(42,53,72,0.5);
}
.data-table tr:hover td {
background: var(--card-hover);
}
.data-table .domain {
color: var(--text);
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.data-table .badge {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.badge.green { background: var(--green-dim); color: var(--green); }
.badge.red { background: var(--red-dim); color: var(--red); }
.badge.yellow { background: rgba(234,179,8,0.15); color: var(--yellow); }
.badge.blue { background: rgba(59,130,246,0.15); color: var(--blue); }
.data-table .actions {
display: flex;
gap: 4px;
}
.data-table .btn-icon {
width: 30px;
height: 30px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.15s;
color: var(--text-dim);
}
.data-table .btn-icon:hover {
background: var(--border);
color: var(--text);
}
.data-table .btn-icon.danger:hover {
background: var(--red-dim);
color: var(--red);
}
/* Forms */
.form-section {
margin-bottom: 28px;
}
.form-section h3 {
font-size: 15px;
font-weight: 700;
color: #fff;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
font-family: 'Inter', sans-serif;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--green);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--green);
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.btn-primary {
background: var(--green);
color: #fff;
}
.btn-primary:hover {
background: #16a34a;
}
.btn-secondary {
background: var(--card-hover);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--border);
}
.btn-danger {
background: var(--red-dim);
color: var(--red);
}
.btn-danger:hover {
background: var(--red);
color: #fff;
}
/* Log viewer */
.log-line {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 5px 0;
border-bottom: 1px solid rgba(42,53,72,0.3);
display: flex;
gap: 12px;
}
.log-time { color: var(--text-dim); flex-shrink: 0; }
.log-type { color: var(--green); flex-shrink: 0; width: 80px; }
.log-msg { color: var(--text-muted); word-break: break-all; }
.log-msg.err { color: var(--red); }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(4px);
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
width: 520px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 16px;
font-weight: 700;
color: #fff;
}
.modal-close {
width: 32px;
height: 32px;
border: none;
background: var(--card-hover);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--text-dim);
}
.modal-close:hover { background: var(--red-dim); color: var(--red); }
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Alerts */
.alert {
padding: 12px 16px;
border-radius: 10px;
font-size: 14px;
margin-bottom: 16px;
display: none;
}
.alert.show { display: block; }
.alert.success { background: var(--green-dim); color: var(--green); border: 1px solid rgba(34,197,94,0.3); }
.alert.error { background: var(--red-dim); color: var(--red); border: 1px solid rgba(239,68,68,0.3); }
.alert.warn { background: rgba(234,179,8,0.15); color: var(--yellow); border: 1px solid rgba(234,179,8,0.3); }
/* Empty state */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-dim);
}
.empty-state .icon { font-size: 40px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
/* Security bar */
.security-bar {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.security-bar .sec-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--green-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.security-bar .sec-info { flex: 1; }
.security-bar .sec-title { font-size: 14px; font-weight: 700; color: #fff; margin-bottom: 2px; }
.security-bar .sec-desc { font-size: 12px; color: var(--text-dim); }
/* Tab system */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
.tab {
padding: 8px 18px;
font-size: 14px;
font-weight: 500;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--green); border-bottom-color: var(--green); }
/* Page sections */
.page { display: none; }
.page.active { display: block; }
/* Responsive */
@media(max-width: 1024px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.dashboard-row { grid-template-columns: 1fr; }
}
@media(max-width: 768px) {
.sidebar { display: none; }
.main { margin-left: 0; }
.form-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-logo">
<div class="icon">🌿</div>
<span>T面板</span>
</div>
<nav class="sidebar-nav">
<div class="nav-section">概览</div>
<a class="nav-item active" data-page="dashboard">
<span class="emoji">📊</span> 仪表盘
</a>
<a class="nav-item" data-page="sites">
<span class="emoji">🌐</span> 网站
</a>
<a class="nav-item" data-page="databases">
<span class="emoji">🗄️</span> 数据库
</a>
<a class="nav-item" data-page="ssl">
<span class="emoji">🔐</span> SSL
</a>
<a class="nav-item" data-page="backups">
<span class="emoji">💾</span> 备份
</a>
<div class="nav-section">系统</div>
<a class="nav-item" data-page="cronjobs">
<span class="emoji">⏰</span> 定时任务
</a>
<a class="nav-item" data-page="security">
<span class="emoji">🛡️</span> 安全
</a>
<a class="nav-item" data-page="settings">
<span class="emoji">⚙️</span> 设置
</a>
<a class="nav-item" data-page="files">
<span class="emoji">📁</span> 文件
</a>
<a class="nav-item" data-page="logs">
<span class="emoji">📋</span> 日志
</a>
</nav>
<div class="sidebar-footer">
<div class="version">T面板 v1.0.0</div>
<div class="version" style="margin-top:4px;">tpanel.cn</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="topbar">
<div class="topbar-left">
<h1 id="pageTitle">仪表盘</h1>
</div>
<div class="topbar-right">
<div class="status-pill ok" id="sysStatus">
<div class="dot"></div>
<span>系统正常</span>
</div>
<a href="https://tpanel.cn" target="_blank" class="topbar-btn">官方网站</a>
<button class="topbar-btn red" onclick="logout()">退出</button>
</div>
</div>
<div class="content" id="mainContent">
<!-- Dashboard -->
<div class="page active" id="page-dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="label">📁 网站总数</div>
<div class="value" id="stat-sites">-</div>
<div class="sub" id="stat-sites-sub">个站点</div>
</div>
<div class="stat-card">
<div class="label">🗄️ 数据库</div>
<div class="value" id="stat-dbs">-</div>
<div class="sub">个数据库</div>
</div>
<div class="stat-card">
<div class="label">💾 备份状态</div>
<div class="value" id="stat-backups">-</div>
<div class="sub">份备份</div>
</div>
<div class="stat-card">
<div class="label">🔐 安全更新</div>
<div class="value" id="stat-security">-</div>
<div class="sub" id="stat-security-sub">个待安装</div>
</div>
</div>
<div class="security-bar">
<div class="sec-icon">🛡️</div>
<div class="sec-info">
<div class="sec-title">安全状态:已启用自动更新</div>
<div class="sec-desc">系统每日 03:00 自动执行安全更新</div>
</div>
<button class="btn btn-primary" onclick="runSecurityUpdate()">立即更新</button>
</div>
<div class="dashboard-row">
<div class="panel">
<div class="panel-header">
<span class="panel-title">⚡ 服务状态</span>
</div>
<div class="panel-body">
<div class="service-item">
<div class="service-info">
<span style="font-size:20px;">🌿</span>
<div>
<div class="service-name">Nginx</div>
<div class="service-desc">Web 服务器</div>
</div>
</div>
<span class="badge green" id="svc-nginx">运行中</span>
</div>
<div class="service-item">
<div class="service-info">
<span style="font-size:20px;">🐬</span>
<div>
<div class="service-name">MySQL</div>
<div class="service-desc">数据库服务</div>
</div>
</div>
<span class="badge green" id="svc-mysql">运行中</span>
</div>
<div class="service-item">
<div class="service-info">
<span style="font-size:20px;">🐘</span>
<div>
<div class="service-name">PHP-FPM</div>
<div class="service-desc">PHP 解释器</div>
</div>
</div>
<span class="badge green" id="svc-php">运行中</span>
</div>
<div class="service-item">
<div class="service-info">
<span style="font-size:20px;">🛡️</span>
<div>
<div class="service-name">UFW 防火墙</div>
<div class="service-desc">端口防护</div>
</div>
</div>
<span class="badge green" id="svc-ufw">已启用</span>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">💻 系统资源</span>
</div>
<div class="panel-body" id="sysStats">
<div class="service-item">
<div class="service-info">
<div class="service-name">CPU 负载</div>
</div>
<span class="badge blue" id="stat-cpu">-</span>
</div>
<div class="service-item">
<div class="service-info">
<div class="service-name">内存</div>
</div>
<span class="badge blue" id="stat-mem">-</span>
</div>
<div class="service-item">
<div class="service-info">
<div class="service-name">磁盘</div>
</div>
<span class="badge blue" id="stat-disk">-</span>
</div>
<div class="service-item">
<div class="service-info">
<div class="service-name">负载均值</div>
</div>
<span class="badge" id="stat-load">-</span>
</div>
</div>
</div>
</div>
</div>
<!-- Sites -->
<div class="page" id="page-sites">
<div class="alert error" id="sitesAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">网站列表</h2>
<button class="btn btn-primary" onclick="openSiteModal()">+ 创建站点</button>
</div>
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>域名</th>
<th>PHP</th>
<th>路径</th>
<th>状态</th>
<th>SSL</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="sitesTable"></tbody>
</table>
</div>
</div>
<!-- Databases -->
<div class="page" id="page-databases">
<div class="alert error" id="dbAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">数据库</h2>
<button class="btn btn-primary" onclick="openDbModal()">+ 创建数据库</button>
</div>
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>数据库名</th>
<th>用户</th>
<th>字符集</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="dbsTable"></tbody>
</table>
</div>
</div>
<!-- SSL -->
<div class="page" id="page-ssl">
<div class="alert error" id="sslAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">SSL 证书</h2>
<button class="btn btn-primary" onclick="openSSLModal()">+ 申请证书</button>
</div>
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>域名</th>
<th>证书路径</th>
<th>到期日期</th>
<th>自动续期</th>
<th>操作</th>
</tr>
</thead>
<tbody id="sslTable"></tbody>
</table>
</div>
</div>
<!-- Backups -->
<div class="page" id="page-backups">
<div class="alert error" id="backupAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">备份记录</h2>
<button class="btn btn-primary" onclick="openBackupModal()">+ 立即备份</button>
</div>
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>站点</th>
<th>类型</th>
<th>文件大小</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="backupsTable"></tbody>
</table>
</div>
</div>
<!-- Security -->
<div class="page" id="page-security">
<div class="alert success" id="secAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">安全中心</h2>
<button class="btn btn-primary" onclick="runSecurityUpdate()">🔄 执行安全更新</button>
</div>
<div class="stats-grid" style="grid-template-columns: repeat(3,1fr);margin-bottom:24px;">
<div class="stat-card">
<div class="label">🛡️ 安全更新</div>
<div class="value" id="sec-updatable">-</div>
<div class="sub">个可用更新</div>
</div>
<div class="stat-card">
<div class="label">❌ 近期错误</div>
<div class="value" id="sec-errors">-</div>
<div class="sub">条系统错误</div>
</div>
<div class="stat-card">
<div class="label">🔒 防火墙</div>
<div class="value" id="sec-firewall">-</div>
<div class="sub">状态</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">🛡️ 自动漏洞修复</span>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" id="autoUpdateToggle" checked onchange="toggleAutoUpdate()">
<span style="font-size:13px;color:var(--text-dim);">启用自动修复</span>
</label>
</div>
<div class="panel-body">
<p>T面板每日凌晨 03:00 自动执行 <code>apt-get update && apt-get upgrade -y</code>,修复已知安全漏洞。</p>
<p style="margin-top:8px;">最近执行:<span id="sec-last-run" style="color:var(--green);">-</span></p>
</div>
</div>
</div>
<!-- Files -->
<div class="page" id="page-files">
<div class="alert error" id="filesAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">📁 文件管理</h2>
<div style="display:flex;gap:8px;">
<select id="files-site-select" style="padding:8px 12px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;">
<option value="">-- 选择站点 --</option>
</select>
<button class="btn btn-secondary" onclick="refreshFiles()">↻ 刷新</button>
<button class="btn btn-secondary" onclick="openMkdirModal()">+ 新建目录</button>
<button class="btn btn-secondary" onclick="openUploadModal()">↑ 上传</button>
</div>
</div>
<!-- Breadcrumb -->
<div id="fileBreadcrumb" style="display:flex;align-items:center;gap:6px;margin-bottom:16px;font-size:13px;color:var(--text-dim);">
<span style="cursor:pointer;color:var(--green);" onclick="navigateFiles('')">/</span>
</div>
<!-- File list -->
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th>权限</th>
<th>操作</th>
</tr>
</thead>
<tbody id="filesTable"></tbody>
</table>
</div>
<!-- File editor modal -->
<div class="modal-overlay" id="fileEditModal">
<div class="modal" style="width:800px;max-width:95vw;">
<div class="modal-header">
<span class="modal-title">✏️ 编辑文件</span>
<button class="modal-close" onclick="closeFileEditModal()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px;font-size:13px;color:var(--text-dim);" id="editFilePath"></div>
<textarea id="fileEditor" style="width:100%;min-height:400px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:'JetBrains Mono',monospace;font-size:13px;padding:14px;resize:vertical;box-sizing:border-box;" spellcheck="false"></textarea>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeFileEditModal()">取消</button>
<button class="btn btn-primary" onclick="saveFile()">保存</button>
</div>
</div>
</div>
<!-- Mkdir modal -->
<div class="modal-overlay" id="mkdirModal">
<div class="modal" style="width:440px;">
<div class="modal-header">
<span class="modal-title">📁 新建目录</span>
<button class="modal-close" onclick="closeMkdirModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>目录名称</label>
<input type="text" id="mkdir-name" placeholder="my_folder">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeMkdirModal()">取消</button>
<button class="btn btn-primary" onclick="doMkdir()">创建</button>
</div>
</div>
</div>
<!-- Upload modal -->
<div class="modal-overlay" id="uploadModal">
<div class="modal" style="width:500px;">
<div class="modal-header">
<span class="modal-title">↑ 上传文件</span>
<button class="modal-close" onclick="closeUploadModal()">×</button>
</div>
<div class="modal-body">
<div id="uploadDropzone" style="border:2px dashed var(--border);border-radius:12px;padding:48px;text-align:center;cursor:pointer;" onclick="document.getElementById('fileInput').click()">
<div style="font-size:40px;margin-bottom:12px;">📤</div>
<div style="font-size:15px;color:var(--text-muted);">点击或拖拽文件到这里上传</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:8px;">支持 zip/tar.gz 自动解压</div>
<input type="file" id="fileInput" style="display:none;" onchange="handleFileSelect(this)">
</div>
<div id="uploadProgress" style="display:none;margin-top:16px;">
<div style="font-size:14px;color:var(--text-dim);margin-bottom:8px;" id="uploadFileName">上传中...</div>
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;">
<div id="uploadBar" style="height:100%;background:var(--green);width:0%;transition:width 0.3s;"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeUploadModal()">取消</button>
</div>
</div>
</div>
</div>
<!-- Cron Jobs -->
<div class="page" id="page-cronjobs">
<div class="alert error" id="cronAlert"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:18px;font-weight:700;">⏰ 定时任务</h2>
<button class="btn btn-primary" onclick="openCronModal()">+ 创建任务</button>
</div>
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>任务名称</th>
<th>站点</th>
<th>执行周期</th>
<th>命令</th>
<th>状态</th>
<th>上次执行</th>
<th>操作</th>
</tr>
</thead>
<tbody id="cronTable"></tbody>
</table>
</div>
<!-- Create Cron Modal -->
<div class="modal-overlay" id="cronModal">
<div class="modal" style="width:560px;">
<div class="modal-header">
<span class="modal-title">⏰ 创建定时任务</span>
<button class="modal-close" onclick="closeCronModal()">×</button>
</div>
<div class="modal-body">
<div class="alert error" id="cronModalAlert" style="display:none;"></div>
<div class="form-group">
<label>任务名称</label>
<input type="text" id="cron-name" placeholder="数据库每日备份">
</div>
<div class="form-group">
<label>所属站点</label>
<select id="cron-site"><option value="">-- 选择站点 --</option></select>
</div>
<div class="form-group">
<label>执行周期 (Cron)</label>
<input type="text" id="cron-schedule" placeholder="0 3 * * *" value="0 3 * * *">
<small style="color:var(--text-dim);font-size:12px;margin-top:4px;display:block;">
分 时 日 月 周,如 <code>0 3 * * *</code> = 每天凌晨3点,<code>*/5 * * * *</code> = 每5分钟
</small>
</div>
<div class="form-group">
<label>执行命令</label>
<input type="text" id="cron-command" placeholder="mysqldump -u root -p'' mydb > /backup/db.sql">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCronModal()">取消</button>
<button class="btn btn-primary" onclick="createCron()">创建</button>
</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="page" id="page-settings">
<div class="alert success" id="settingsAlert"></div>
<div class="form-section">
<h3>👤 修改管理员密码</h3>
<div class="form-row">
<div class="form-group">
<label>当前密码</label>
<input type="password" id="curPass" placeholder="当前密码">
</div>
<div class="form-group">
<label>新密码</label>
<input type="password" id="newPass" placeholder="新密码(8位以上)">
</div>
</div>
<button class="btn btn-primary" onclick="changePassword()">确认修改</button>
</div>
<div class="form-section">
<h3>⚙️ 系统设置</h3>
<div class="form-row">
<div class="form-group">
<label>默认 PHP 版本</label>
<select id="cfg-php">
<option value="7.4">PHP 7.4</option>
<option value="8.0">PHP 8.0</option>
<option value="8.1" selected>PHP 8.1</option>
<option value="8.2">PHP 8.2</option>
</select>
</div>
<div class="form-group">
<label>备份保留天数</label>
<input type="number" id="cfg-retention" value="7" min="1">
</div>
</div>
<div class="form-group" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="cfg-auto-ssl" checked>
<label style="margin:0;font-size:14px;color:var(--text-muted);">自动续期 SSL 证书</label>
</div>
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
</div>
<div class="form-section">
<h3>🔗 面板域名绑定</h3>
<div class="form-group">
<label>绑定域名(留空则不限制)</label>
<input type="text" id="cfg-panel-domain" placeholder="panel.your-domain.com" style="font-family:'JetBrains Mono',monospace;">
<small style="color:var(--text-dim);font-size:12px;margin-top:4px;display:block;">
绑定后只能通过该域名访问后台(如留空则不限制访问来源)。绑定后请确保 DNS 已解析到此服务器。
</small>
</div>
<div id="domainStatus" style="font-size:13px;margin-bottom:12px;color:var(--text-dim);"></div>
<button class="btn btn-primary" onclick="saveSettings()">保存域名绑定</button>
<div style="margin-top:12px;padding:12px 16px;background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.3);border-radius:8px;font-size:13px;color:var(--yellow);">
⚠️ 修改域名绑定后需重启面板服务:<code style="background:rgba(234,179,8,0.15);padding:2px 6px;border-radius:4px;">systemctl restart tpanel</code>
</div>
</div>
<div class="form-section">
<h3>🌐 官方信息</h3>
<div class="form-row">
<div class="form-group">
<label>面板官网</label>
<input type="text" value="https://tpanel.cn" readonly>
</div>
<div class="form-group">
<label>作者</label>
<input type="text" value="Zhang Pu" readonly>
</div>
</div>
</div>
</div>
<!-- Logs -->
<div class="page" id="page-logs">
<h2 style="font-size:18px;font-weight:700;margin-bottom:20px;">📋 安全日志</h2>
<div class="panel" id="logContainer" style="max-height:600px;overflow-y:auto;">
<div id="logList"></div>
</div>
</div>
</div>
</main>
<!-- Site Modal -->
<div class="modal-overlay" id="siteModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">🌐 创建站点</span>
<button class="modal-close" onclick="closeSiteModal()">×</button>
</div>
<div class="modal-body">
<div class="alert error" id="siteModalAlert"></div>
<div class="form-group">
<label>域名</label>
<input type="text" id="site-domain" placeholder="example.com">
</div>
<div class="form-group">
<label>站点名称</label>
<input type="text" id="site-name" placeholder="显示名称">
</div>
<div class="form-group">
<label>PHP 版本</label>
<select id="site-php">
<option value="7.4">PHP 7.4</option>
<option value="8.0">PHP 8.0</option>
<option value="8.1" selected>PHP 8.1</option>
<option value="8.2">PHP 8.2</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeSiteModal()">取消</button>
<button class="btn btn-primary" onclick="createSite()">创建</button>
</div>
</div>
</div>
<!-- DB Modal -->
<div class="modal-overlay" id="dbModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">🗄️ 创建数据库</span>
<button class="modal-close" onclick="closeDbModal()">×</button>
</div>
<div class="modal-body">
<div class="alert error" id="dbModalAlert"></div>
<div class="form-group">
<label>所属站点</label>
<select id="db-site"><option value="">-- 选择站点 --</option></select>
</div>
<div class="form-group">
<label>数据库名</label>
<input type="text" id="db-name" placeholder="my_site_db">
</div>
<div class="form-group">
<label>数据库用户</label>
<input type="text" id="db-user" placeholder="db_user">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="db-pass" placeholder="密码">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeDbModal()">取消</button>
<button class="btn btn-primary" onclick="createDb()">创建</button>
</div>
</div>
</div>
<!-- Backup Modal -->
<div class="modal-overlay" id="backupModal">
<div class="modal" style="width:600px;">
<div class="modal-header">
<span class="modal-title">💾 备份站点</span>
<button class="modal-close" onclick="closeBackupModal()">×</button>
</div>
<div class="modal-body" style="padding-top:0;">
<div class="tabs" style="margin-bottom:20px;">
<div class="tab active" id="tab-local" onclick="switchBackupTab('local')">📦 本地备份</div>
<div class="tab" id="tab-remote" onclick="switchBackupTab('remote')">🌐 远程备份</div>
</div>
<div class="alert error" id="backupModalAlert" style="display:none;"></div>
<!-- Local backup tab -->
<div id="backup-tab-local">
<div class="form-group">
<label>选择站点</label>
<select id="backup-site"><option value="">-- 选择站点 --</option></select>
</div>
</div>
<!-- Remote backup tab -->
<div id="backup-tab-remote" style="display:none;">
<div class="form-group">
<label>选择站点</label>
<select id="backup-remote-site" onchange="loadRemoteBackupForm()"><option value="">-- 选择站点 --</option></select>
</div>
<div class="form-group">
<label>远程主机 IP</label>
<input type="text" id="remote-host" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>SSH 用户</label>
<input type="text" id="remote-user" placeholder="root">
</div>
<div class="form-group">
<label>SSH 端口</label>
<input type="number" id="remote-port" value="22" min="1" max="65535">
</div>
<div class="form-group">
<label>远程备份路径</label>
<input type="text" id="remote-path" placeholder="/backup/tblog">
</div>
<div class="form-group">
<label>SSH 密钥路径(可选)</label>
<input type="text" id="remote-key" placeholder="/root/.ssh/id_rsa">
</div>
<div class="form-actions" style="margin-top:8px;">
<button class="btn btn-secondary" onclick="testRemote()">🔗 测试连接</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeBackupModal()">取消</button>
<button class="btn btn-primary" id="backup-confirm-btn" onclick="doLocalBackup()">开始备份</button>
</div>
</div>
</div>
<!-- SSL Modal -->
<div class="modal-overlay" id="sslModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">🔐 申请 SSL 证书</span>
<button class="modal-close" onclick="closeSSLModal()">×</button>
</div>
<div class="modal-body">
<div class="alert error" id="sslModalAlert"></div>
<div class="form-group">
<label>选择站点</label>
<select id="ssl-site"><option value="">-- 选择站点 --</option></select>
</div>
<p style="font-size:13px;color:var(--text-dim);margin-top:8px;">
将通过 Let's Encrypt 自动申请 SSL 证书(HTTP 验证)。
证书有效期 90 天,自动续期。
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeSSLModal()">取消</button>
<button class="btn btn-primary" onclick="applySSL()">申请证书</button>
</div>
</div>
</div>
<script>
const API = '/api';
let token = localStorage.getItem('tpanel_token') || '';
// =====================
// Auth
// =====================
async function checkAuth() {
if (!token) { showLogin(); return; }
const r = await fetch(API + '/auth/check', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (r.ok) {
const d = await r.json();
if (d.code === 0) initApp();
else showLogin();
} else {
showLogin();
}
}
function showLogin() {
document.body.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg);">
<div style="background:var(--card);border:1px solid var(--border);border-radius:20px;padding:40px;width:400px;text-align:center;">
<div style="font-size:48px;margin-bottom:16px;">🌿</div>
<h2 style="font-size:24px;font-weight:800;margin-bottom:8px;">T面板</h2>
<p style="color:var(--text-dim);margin-bottom:32px;">Linux 网站管理面板</p>
<div id="loginAlert" style="margin-bottom:16px;color:var(--red);font-size:14px;display:none;"></div>
<input type="text" id="loginUser" placeholder="用户名" style="width:100%;padding:12px 16px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:15px;margin-bottom:12px;box-sizing:border-box;">
<input type="password" id="loginPass" placeholder="密码" style="width:100%;padding:12px 16px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:15px;margin-bottom:16px;box-sizing:border-box;" onkeydown="if(event.key==='Enter')login()">
<button onclick="login()" style="width:100%;padding:14px;background:var(--green);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;">登录</button>
<p style="color:var(--text-dim);font-size:12px;margin-top:16px;">默认账号 admin / tpanel.cn</p>
</div>
</div>`;
}
async function login() {
const user = document.getElementById('loginUser').value;
const pass = document.getElementById('loginPass').value;
const r = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
const d = await r.json();
if (d.code === 0) {
token = d.token;
localStorage.setItem('tpanel_token', token);
initApp();
} else {
const al = document.getElementById('loginAlert');
al.textContent = d.msg;
al.style.display = 'block';
}
}
async function logout() {
await fetch(API + '/auth/logout', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
});
token = '';
localStorage.removeItem('tpanel_token');
location.reload();
}
function authHeaders() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
async function api(path, opts = {}) {
const r = await fetch(API + path, {
...opts,
headers: { ...authHeaders(), ...(opts.body ? { 'Content-Type': 'application/json' } : {}) }
});
return r.json();
}
// =====================
// App Init
// =====================
function initApp() {
setupNav();
loadDashboard();
}
// =====================
// Navigation
// =====================
function setupNav() {
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
item.addEventListener('click', () => {
const page = item.dataset.page;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
const titles = { dashboard:'仪表盘', sites:'网站管理', databases:'数据库', ssl:'SSL证书', backups:'备份', security:'安全中心', settings:'设置', logs:'安全日志', files:'文件管理', cronjobs:'定时任务' };
document.getElementById('pageTitle').textContent = titles[page] || page;
if (page === 'sites') loadSites();
if (page === 'databases') loadDbs();
if (page === 'backups') loadBackups();
if (page === 'security') loadSecurity();
if (page === 'logs') loadLogs();
if (page === 'files') initFileManager();
if (page === 'ssl') loadSSL();
if (page === 'settings') loadSettingsPage();
if (page === 'cronjobs') loadCronJobs();
});
});
}
// =====================
// Dashboard
// =====================
async function loadDashboard() {
try {
const d = await api('/system/stats');
if (d.code !== 0) return;
document.getElementById('stat-cpu').textContent = d.data.cpu_pct;
document.getElementById('stat-mem').textContent = d.data.mem_used_mb + ' / ' + d.data.mem_total_mb + ' MB';
document.getElementById('stat-disk').textContent = d.data.disk_used + ' / ' + d.data.disk_free;
document.getElementById('stat-load').textContent = d.data.load;
document.getElementById('stat-load').className = 'badge ' + (parseFloat(d.data.load.split(' ')[0]) > 2 ? 'yellow' : 'blue');
// 服务状态
document.getElementById('svc-nginx').textContent = d.data.nginx_running ? '运行中' : '已停止';
document.getElementById('svc-nginx').className = 'badge ' + (d.data.nginx_running ? 'green' : 'red');
document.getElementById('svc-mysql').textContent = d.data.mysql_running ? '运行中' : '已停止';
document.getElementById('svc-mysql').className = 'badge ' + (d.data.mysql_running ? 'green' : 'red');
// 安全状态
const sec = d.data.upgradable_security_packages || 0;
document.getElementById('stat-security').textContent = sec;
document.getElementById('stat-security-sub').textContent = sec > 0 ? '个待安装' : '已是最新';
document.getElementById('stat-security').parentElement.querySelector('.sub').style.color = sec > 0 ? 'var(--yellow)' : 'var(--green)';
} catch(e) { console.error(e); }
// 加载站点数量
try {
const sites = await api('/sites');
document.getElementById('stat-sites').textContent = sites.data ? sites.data.length : 0;
document.getElementById('stat-dbs').textContent = (await api('/databases')).data ? (await api('/databases')).data.length : 0;
const backups = await api('/backups');
document.getElementById('stat-backups').textContent = backups.data ? backups.data.length : 0;
} catch(e) {}
}
// =====================
// Sites
// =====================
async function loadSites() {
const d = await api('/sites');
const tbody = document.getElementById('sitesTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-dim);">暂无站点 · <a href="#" onclick="openSiteModal();return false;" style="color:var(--green);">创建第一个站点</a></td></tr>';
return;
}
tbody.innerHTML = d.data.map(s => `
<tr>
<td class="domain">${s.domain}</td>
<td>PHP ${s.php_version}</td>
<td style="font-size:12px;">${s.site_path}</td>
<td><span class="badge ${s.status === 'running' ? 'green' : 'yellow'}">${s.status === 'running' ? '运行中' : '已停止'}</span></td>
<td><span class="badge ${s.ssl_enabled ? 'green' : 'blue'}">${s.ssl_enabled ? '已启用' : '未启用'}</span></td>
<td>${s.created_at ? s.created_at.split('T')[0] : '-'}</td>
<td class="actions">
<button class="btn-icon" title="备份" onclick="quickBackup(${s.id})">💾</button>
<button class="btn-icon danger" title="删除" onclick="deleteSite(${s.id},'${s.domain}')">🗑️</button>
</td>
</tr>`).join('');
}
function openSiteModal() {
document.getElementById('siteModalAlert').className = 'alert error';
document.getElementById('siteModalAlert').style.display = 'none';
document.getElementById('site-domain').value = '';
document.getElementById('site-name').value = '';
document.getElementById('siteModal').classList.add('show');
}
function closeSiteModal() { document.getElementById('siteModal').classList.remove('show'); }
async function createSite() {
const domain = document.getElementById('site-domain').value.trim();
const name = document.getElementById('site-name').value.trim() || domain;
const php = document.getElementById('site-php').value;
if (!domain) { showAlert('siteModalAlert', '请输入域名'); return; }
const d = await api('/sites', {
method: 'POST',
body: JSON.stringify({ domain, name, php_version: php })
});
if (d.code === 0) {
closeSiteModal();
loadSites();
} else {
showAlert('siteModalAlert', d.msg);
}
}
async function deleteSite(id, domain) {
if (!confirm(`确定删除站点 ${domain}?此操作不可恢复,包括所有文件和数据!`)) return;
const d = await api('/sites/' + id, { method: 'DELETE' });
if (d.code === 0) loadSites();
else alert(d.msg);
}
async function quickBackup(siteId) {
const d = await api('/backups', {
method: 'POST',
body: JSON.stringify({ site_id: siteId })
});
if (d.code === 0) alert('备份成功!文件:' + d.data.path);
else alert('备份失败:' + d.msg);
}
// =====================
// Databases
// =====================
async function loadDbs() {
const d = await api('/databases');
const tbody = document.getElementById('dbsTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">暂无数据库</td></tr>';
return;
}
tbody.innerHTML = d.data.map(s => `
<tr>
<td class="domain">${s.name}</td>
<td>${s.db_user}</td>
<td>${s.charset}</td>
<td>${s.created_at ? s.created_at.split('T')[0] : '-'}</td>
<td class="actions">
<button class="btn-icon danger" onclick="deleteDb(${s.id},'${s.name}')">🗑️</button>
</td>
</tr>`).join('');
}
async function openDbModal() {
document.getElementById('dbModalAlert').style.display = 'none';
// 加载站点列表
const sites = await api('/sites');
const sel = document.getElementById('db-site');
sel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
document.getElementById('dbModal').classList.add('show');
}
function closeDbModal() { document.getElementById('dbModal').classList.remove('show'); }
async function createDb() {
const site_id = document.getElementById('db-site').value;
const name = document.getElementById('db-name').value.trim();
const user = document.getElementById('db-user').value.trim();
const pass = document.getElementById('db-pass').value;
if (!site_id || !name || !user || !pass) { showAlert('dbModalAlert', '请填写所有字段'); return; }
const d = await api('/databases', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(site_id), name, user, pass })
});
if (d.code === 0) { closeDbModal(); loadDbs(); }
else showAlert('dbModalAlert', d.msg);
}
async function deleteDb(id, name) {
if (!confirm(`确定删除数据库 ${name}?`)) return;
const d = await api('/databases/' + id, { method: 'DELETE' });
if (d.code === 0) loadDbs();
}
// =====================
// SSL
// =====================
async function loadSSL() {
const d = await api('/ssl/certs');
const tbody = document.getElementById('sslTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">暂无证书</td></tr>';
return;
}
tbody.innerHTML = d.data.map(s => `
<tr>
<td class="domain">${s.domain}</td>
<td style="font-size:12px;">${s.cert_path}</td>
<td>${s.expire_date || '-'}</td>
<td><span class="badge ${s.auto_renew ? 'green' : 'yellow'}">${s.auto_renew ? '已启用' : '未启用'}</span></td>
<td class="actions">
<button class="btn-icon" onclick="renewSSL(${s.id})">🔄</button>
<button class="btn-icon danger">🗑️</button>
</td>
</tr>`).join('');
}
async function openSSLModal() {
const sites = await api('/sites');
const sel = document.getElementById('ssl-site');
sel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
document.getElementById('sslModal').classList.add('show');
}
function closeSSLModal() { document.getElementById('sslModal').classList.remove('show'); }
async function applySSL() {
const siteId = document.getElementById('ssl-site').value;
if (!siteId) { showAlert('sslModalAlert', '请选择站点'); return; }
const d = await api('/ssl/apply', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(siteId) })
});
if (d.code === 0) { closeSSLModal(); loadSSL(); }
else showAlert('sslModalAlert', d.msg);
}
// =====================
// Backups
// =====================
async function loadBackups() {
const d = await api('/backups');
const tbody = document.getElementById('backupsTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--text-dim);">暂无备份记录</td></tr>';
return;
}
tbody.innerHTML = d.data.map(b => `
<tr>
<td>${b.domain || b.site_id}</td>
<td><span class="badge blue">${b.type}</span></td>
<td>${b.size ? Math.round(b.size/1024) + ' KB' : '-'}</td>
<td><span class="badge ${b.status === 'success' ? 'green' : 'red'}">${b.status}</span></td>
<td>${b.created_at ? b.created_at.replace('T',' ').slice(0,16) : '-'}</td>
<td class="actions">
<button class="btn-icon" onclick="restoreBackup(${b.id})">↩️</button>
<button class="btn-icon danger">🗑️</button>
</td>
</tr>`).join('');
}
let currentBackupTab = 'local';
function switchBackupTab(tab) {
currentBackupTab = tab;
document.getElementById('tab-local').className = tab === 'local' ? 'tab active' : 'tab';
document.getElementById('tab-remote').className = tab === 'remote' ? 'tab active' : 'tab';
document.getElementById('backup-tab-local').style.display = tab === 'local' ? 'block' : 'none';
document.getElementById('backup-tab-remote').style.display = tab === 'remote' ? 'block' : 'none';
document.getElementById('backup-confirm-btn').textContent = tab === 'local' ? '开始备份' : '开始远程备份';
document.getElementById('backup-confirm-btn').onclick = tab === 'local' ? doLocalBackup : doRemoteBackup;
}
async function openBackupModal() {
const sites = await api('/sites');
const sel = document.getElementById('backup-site');
sel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
const rsel = document.getElementById('backup-remote-site');
rsel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
document.getElementById('backupModalAlert').style.display = 'none';
switchBackupTab('local');
document.getElementById('backupModal').classList.add('show');
}
function closeBackupModal() { document.getElementById('backupModal').classList.remove('show'); }
async function doLocalBackup() {
const siteId = document.getElementById('backup-site').value;
if (!siteId) { showAlert('backupModalAlert', '请选择站点'); return; }
const d = await api('/backups', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(siteId) })
});
if (d.code === 0) {
closeBackupModal();
loadBackups();
} else {
showAlert('backupModalAlert', d.msg);
}
}
async function restoreBackup(id) {
if (!confirm('确定恢复此备份?当前文件将被覆盖!')) return;
const d = await api('/backups/' + id + '/restore', { method: 'POST' });
alert(d.msg);
}
// =====================
// Security
// =====================
async function loadSecurity() {
try {
const d = await api('/security/status');
document.getElementById('sec-updatable').textContent = d.data.upgradable_security_packages || 0;
document.getElementById('sec-errors').textContent = d.data.recent_errors || 0;
document.getElementById('sec-firewall').textContent = '已启用';
} catch(e) {}
}
async function runSecurityUpdate() {
if (!confirm('确定执行安全更新?更新期间服务可能短暂中断。')) return;
const al = document.getElementById('secAlert');
al.textContent = '正在执行安全更新,请稍候...';
al.className = 'alert show success';
const d = await api('/security/update', { method: 'POST' });
al.textContent = d.msg;
al.className = 'alert show ' + (d.code === 0 ? 'success' : 'error');
setTimeout(() => { al.className = 'alert'; loadSecurity(); loadDashboard(); }, 3000);
}
// =====================
// Logs
// =====================
async function loadLogs() {
const d = await api('/security/logs');
const container = document.getElementById('logList');
if (!d.data || d.data.length === 0) {
container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-dim);">暂无日志</div>';
return;
}
container.innerHTML = d.data.map(l => `
<div class="log-line">
<span class="log-time">${l.created_at ? l.created_at.slice(0,16) : '-'}</span>
<span class="log-type">${l.event_type}</span>
<span class="log-msg ${l.event_type.includes('fail') || l.event_type.includes('delete') ? 'err' : ''}">${l.details || ''} ${l.ip ? '(' + l.ip + ')' : ''}</span>
</div>`).join('');
}
// =====================
// Settings
// =====================
async function changePassword() {
const cur = document.getElementById('curPass').value;
const neu = document.getElementById('newPass').value;
if (!neu || neu.length < 8) { showAlert('settingsAlert', '新密码至少8位'); return; }
const al = document.getElementById('settingsAlert');
al.textContent = '密码修改功能开发中...';
al.className = 'alert show success';
setTimeout(() => { al.className = 'alert'; }, 3000);
}
async function loadSettingsPage() {
const d = await api('/settings');
if (d.code === 0 && d.data) {
document.getElementById('cfg-panel-domain').value = d.data.panel_domain || '';
document.getElementById('cfg-retention').value = d.data.backup_retention_days || 7;
const dom = d.data.panel_domain;
const statusEl = document.getElementById('domainStatus');
if (dom && dom.trim()) {
statusEl.innerHTML = '✅ 当前绑定:<span style="color:var(--green);font-weight:600;">' + dom + '</span>';
} else {
statusEl.innerHTML = '⚪ 未绑定域名(不限制访问来源)';
}
}
}
async function saveSettings() {
const panel_domain = document.getElementById('cfg-panel-domain').value.trim();
const retention = parseInt(document.getElementById('cfg-retention').value) || 7;
const d = await api('/settings', {
method: 'PUT',
body: JSON.stringify({
panel_domain,
backup_retention_days: retention
})
});
const al = document.getElementById('settingsAlert');
if (d.code === 0) {
al.textContent = '设置已保存!请注意:域名绑定修改需重启面板服务(systemctl restart tpanel)';
al.className = 'alert show success';
} else {
al.textContent = d.msg || '保存失败';
al.className = 'alert show error';
}
setTimeout(() => { al.className = 'alert'; al.style.display = 'none'; }, 5000);
al.style.display = 'block';
}
// =====================
// Utils
// =====================
function showAlert(id, msg) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg;
el.style.display = 'block';
}
function toggleAutoUpdate() {
const al = document.getElementById('secAlert');
al.textContent = '自动更新已' + (document.getElementById('autoUpdateToggle').checked ? '启用' : '禁用');
al.className = 'alert show success';
setTimeout(() => { al.className = 'alert'; }, 2000);
}
// =====================
// File Manager
// =====================
let currentSiteId = null;
let currentPath = '';
async function loadFileSites() {
const sites = await api('/sites');
const sel = document.getElementById('files-site-select');
sel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
sel.addEventListener('change', () => {
currentSiteId = sel.value;
if (currentSiteId) navigateFiles('');
});
}
function navigateFiles(path) {
if (!currentSiteId) { showAlert('filesAlert', '请先选择站点'); return; }
currentPath = path;
loadFileList();
}
async function loadFileList() {
if (!currentSiteId) return;
const r = await fetch(API + '/files/list?site_id=' + currentSiteId + '&path=' + encodeURIComponent(currentPath), { headers: authHeaders() });
const d = await r.json();
if (d.code !== 0) { showAlert('filesAlert', d.msg); return; }
const tbody = document.getElementById('filesTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">目录为空</td></tr>';
return;
}
// Render breadcrumb
const parts = currentPath.split('/').filter(Boolean);
let bread = '<span style="cursor:pointer;color:var(--green);" onclick="navigateFiles('')">/</span>';
let accum = '';
parts.forEach(p => {
accum += p + '/';
bread += `<span style="color:var(--text-dim)"> / </span><span style="cursor:pointer;color:var(--text-muted);" onclick="navigateFiles('${accum.slice(0,-1)}')">${p}</span>`;
});
document.getElementById('fileBreadcrumb').innerHTML = bread;
// Render table
tbody.innerHTML = d.data.map(item => {
const icon = item.type === 'dir' ? '📁' : getFileIcon(item.name);
const size = item.type === 'dir' ? '-' : item.size_str;
const perms = item.perm_str;
const isWriteable = item.writable ? '' : 'readonly';
return `<tr>
<td style="cursor:pointer;" onclick="${item.type === 'dir' ? `navigateFiles('${currentPath ? currentPath + '/' : ''}${item.name}')` : `openFileEditor('${item.name}')`}">
<span style="margin-right:6px;">${icon}</span><span style="font-family:'JetBrains Mono',monospace;font-size:13px;">${item.name}</span>
</td>
<td style="font-size:13px;">${size}</td>
<td style="font-size:13px;">${item.modified}</td>
<td><span class="badge" style="font-family:'JetBrains Mono',monospace;">${perms}</span></td>
<td class="actions">
${item.type === 'file' ? `<button class="btn-icon" onclick="openFileEditor('${item.name}')" title="编辑">✏️</button>` : ''}
<button class="btn-icon danger" onclick="deleteFileItem('${item.name}')" title="删除">🗑️</button>
</td>
</tr>`;
}).join('');
}
function getFileIcon(name) {
const ext = name.split('.').pop().toLowerCase();
const icons = {
php:'🐘','html':'🌐','htm':'🌐','css':'🎨','js':'📜',
json:'📋','md':'📝','txt':'📄','log':'📋',
jpg:'🖼️','jpeg':'🖼️','png':'🖼️','gif':'🖼️','webp':'🖼️','svg':'🖼️',
zip:'📦','tar':'📦','gz':'📦','bz2':'📦',
pdf':'📕','doc':'📘','docx':'📘','xls':'📗','xlsx':'📗',
sql:'🗄️','xml':'📋','yml':'⚙️','yaml':'⚙️','conf':'⚙️',
};
return icons[ext] || '📄';
}
let editingFileName = '';
let editingFilePath = '';
function openFileEditor(name) {
editingFileName = name;
editingFilePath = currentPath ? currentPath + '/' + name : name;
fetch(API + '/files/read?site_id=' + currentSiteId + '&path=' + encodeURIComponent(editingFilePath), { headers: authHeaders() })
.then(r => r.json())
.then(d => {
if (d.code !== 0) { showAlert('filesAlert', d.msg); return; }
document.getElementById('editFilePath').textContent = editingFilePath;
document.getElementById('fileEditor').value = d.data;
document.getElementById('fileEditModal').classList.add('show');
});
}
function closeFileEditModal() { document.getElementById('fileEditModal').classList.remove('show'); }
async function saveFile() {
const content = document.getElementById('fileEditor').value;
const d = await api('/files/write', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(currentSiteId), path: editingFilePath, content })
});
if (d.code === 0) {
closeFileEditModal();
loadFileList();
} else {
showAlert('filesAlert', d.msg);
}
}
async function deleteFileItem(name) {
if (!confirm('确定删除 ' + name + '?此操作不可恢复!')) return;
const filePath = currentPath ? currentPath + '/' + name : name;
const d = await api('/files/delete', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(currentSiteId), path: filePath })
});
if (d.code === 0) loadFileList();
else showAlert('filesAlert', d.msg);
}
function openMkdirModal() {
if (!currentSiteId) { showAlert('filesAlert', '请先选择站点'); return; }
document.getElementById('mkdirModal').classList.add('show');
}
function closeMkdirModal() { document.getElementById('mkdirModal').classList.remove('show'); }
async function doMkdir() {
const name = document.getElementById('mkdir-name').value.trim();
if (!name) { showAlert('filesAlert', '请输入目录名称'); return; }
const d = await api('/files/mkdir', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(currentSiteId), path: currentPath, name })
});
if (d.code === 0) { closeMkdirModal(); loadFileList(); }
else showAlert('filesAlert', d.msg);
}
function openUploadModal() {
if (!currentSiteId) { showAlert('filesAlert', '请先选择站点'); return; }
document.getElementById('uploadModal').classList.add('show');
}
function closeUploadModal() { document.getElementById('uploadModal').classList.remove('show'); }
function handleFileSelect(input) {
if (!input.files || !input.files[0]) return;
doUpload(input.files[0]);
input.value = '';
}
async function doUpload(file) {
const formData = new FormData();
formData.append('site_id', currentSiteId);
formData.append('path', currentPath);
formData.append('file', file);
document.getElementById('uploadProgress').style.display = 'block';
document.getElementById('uploadFileName').textContent = '上传中:' + file.name;
document.getElementById('uploadBar').style.width = '30%';
const r = await fetch(API + '/files/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const d = await r.json();
document.getElementById('uploadBar').style.width = '100%';
setTimeout(() => {
document.getElementById('uploadProgress').style.display = 'none';
document.getElementById('uploadBar').style.width = '0%';
closeUploadModal();
if (d.code === 0) {
showAlert('filesAlert', d.msg);
loadFileList();
} else {
showAlert('filesAlert', d.msg);
}
}, 500);
}
function refreshFiles() { loadFileList(); }
function initFileManager() {
loadFileSites();
document.getElementById('files-site-select').value && (currentSiteId = document.getElementById('files-site-select').value);
}
// =====================
// SSL UI
// =====================
async function loadSSL() {
const d = await api('/ssl/certs');
const tbody = document.getElementById('sslTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">暂无证书 <button class="btn btn-secondary" style="margin-left:12px;" onclick="openSSLModal()">+ 申请证书</button></td></tr>';
return;
}
tbody.innerHTML = d.data.map(s => {
const days = s.days_left;
const dayClass = days < 0 ? 'red' : days <= 7 ? 'yellow' : 'green';
const expire = days < 0 ? '已过期' : days === 0 ? '今日到期' : '剩余 ' + days + ' 天';
return `<tr>
<td class="domain">${s.domain}</td>
<td style="font-size:12px;">${s.cert_path}</td>
<td><span class="badge ${dayClass}">${expire}</span></td>
<td><span class="badge ${s.auto_renew ? 'green' : 'yellow'}">${s.auto_renew ? '已启用' : '未启用'}</span></td>
<td class="actions">
<button class="btn-icon" onclick="renewSingleSSL(${s.id})" title="续期">🔄</button>
<button class="btn-icon" onclick="deploySSL('${s.domain}')" title="部署HTTPS">🔐</button>
<button class="btn-icon danger" onclick="deleteSSL(${s.id})">🗑️</button>
</td>
</tr>`;
}).join('');
// Update security page SSL status
if (d.status) {
const sec = d.status;
document.getElementById('sec-updatable').textContent = sec.total + ' 张证书';
}
}
async function renewSingleSSL(id) {
const d = await api('/ssl/renew/' + id, { method: 'POST' });
alert(d.msg);
loadSSL();
}
async function deploySSL(domain) {
const d = await api('/ssl/deploy/' + domain, { method: 'POST' });
alert(d.msg);
loadSSL();
}
async function deleteSSL(id) {
if (!confirm('确定删除此证书?')) return;
// TODO: add delete API
alert('删除功能开发中');
}
// =====================
// Cron / Auto tasks
// =====================
async function runCronTasks() {
if (!confirm('确定执行定时任务(安全更新 + SSL 续期)?')) return;
const d = await api('/cron/run', { method: 'POST' });
alert(d.msg);
loadSecurity();
loadDashboard();
}
// =====================
// Cron Jobs UI
// =====================
let cronSiteOptions = '';
async function loadCronJobs() {
const d = await api('/cron/jobs');
const sites = await api('/sites');
const tbody = document.getElementById('cronTable');
// Build site options for modals
cronSiteOptions = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-dim);">暂无定时任务 <button class="btn btn-secondary" style="margin-left:12px;" onclick="openCronModal()">+ 创建</button></td></tr>';
return;
}
tbody.innerHTML = d.data.map(j => {
const enabled = j.enabled;
const lastRun = j.last_run ? j.last_run.slice(0, 16).replace('T', ' ') : '-';
return `<tr>
<td style="font-weight:600;">${j.name}</td>
<td><span class="badge blue">${j.domain || '系统'}</span></td>
<td><code style="font-family:'JetBrains Mono',monospace;background:var(--green-dim);padding:2px 8px;border-radius:4px;">${j.schedule}</code></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--text-dim);" title="${j.command}">${j.command}</td>
<td><span class="badge ${enabled ? 'green' : 'yellow'}">${enabled ? '启用' : '禁用'}</span></td>
<td style="font-size:12px;color:var(--text-dim);">${lastRun}</td>
<td class="actions">
<button class="btn-icon" onclick="runCronNow(${j.id})" title="立即执行">▶️</button>
<button class="btn-icon" onclick="toggleCron(${j.id}, ${!enabled})" title="${enabled ? '暂停' : '启用'}">${enabled ? '⏸️' : '▶️'}</button>
<button class="btn-icon danger" onclick="deleteCron(${j.id})" title="删除">🗑️</button>
</td>
</tr>`;
}).join('');
}
function openCronModal() {
document.getElementById('cronModalAlert').style.display = 'none';
document.getElementById('cron-name').value = '';
document.getElementById('cron-site').innerHTML = cronSiteOptions;
document.getElementById('cron-schedule').value = '0 3 * * *';
document.getElementById('cron-command').value = '';
document.getElementById('cronModal').classList.add('show');
}
function closeCronModal() { document.getElementById('cronModal').classList.remove('show'); }
async function createCron() {
const name = document.getElementById('cron-name').value.trim();
const site_id = parseInt(document.getElementById('cron-site').value);
const schedule = document.getElementById('cron-schedule').value.trim();
const command = document.getElementById('cron-command').value.trim();
if (!name || !site_id || !schedule || !command) {
const al = document.getElementById('cronModalAlert');
al.textContent = '请填写所有字段';
al.style.display = 'block';
return;
}
const d = await api('/cron/jobs', {
method: 'POST',
body: JSON.stringify({ site_id, name, schedule, command })
});
if (d.code === 0) {
closeCronModal();
loadCronJobs();
} else {
const al = document.getElementById('cronModalAlert');
al.textContent = d.msg;
al.style.display = 'block';
}
}
async function runCronNow(id) {
if (!confirm('确定立即执行此任务?')) return;
const d = await api('/cron/jobs/' + id + '/run', { method: 'POST' });
alert(d.msg);
loadCronJobs();
}
async function toggleCron(id, enabled) {
const d = await api('/cron/jobs/' + id + '/toggle', {
method: 'POST',
body: JSON.stringify({ enabled })
});
if (d.code === 0) loadCronJobs();
else alert(d.msg);
}
async function deleteCron(id) {
if (!confirm('确定删除此定时任务?')) return;
const d = await api('/cron/jobs/' + id, { method: 'DELETE' });
if (d.code === 0) loadCronJobs();
else alert(d.msg);
}
// =====================
// Remote Backup
// =====================
let backupSiteOptions = '';
async function loadBackupStats() {
const d = await api('/backups/stats');
if (d.code === 0 && d.data) {
document.getElementById('stat-backups').textContent = d.data.total_backups + ' 份';
}
}
async function openRemoteBackupModal() {
const sites = await api('/sites');
const sel = document.getElementById('backup-remote-site');
sel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
document.getElementById('backupModal').classList.add('show');
}
async function doRemoteBackup() {
const site_id = parseInt(document.getElementById('backup-remote-site').value);
const host = document.getElementById('remote-host').value.trim();
const user = document.getElementById('remote-user').value.trim();
const port = parseInt(document.getElementById('remote-port').value) || 22;
const path = document.getElementById('remote-path').value.trim();
const key = document.getElementById('remote-key').value.trim();
if (!site_id || !host || !user || !path) {
showAlert('backupModalAlert', '请填写所有必填字段');
return;
}
closeBackupModal();
const al = document.getElementById('backupAlert');
al.textContent = '正在执行远程备份,请稍候...';
al.className = 'alert show success';
document.getElementById('backupAlert').style.display = 'block';
const d = await api('/backups/remote', {
method: 'POST',
body: JSON.stringify({ site_id, remote_host: host, remote_user: user, remote_port: port, remote_path: path, key_path: key })
});
al.textContent = d.msg;
al.className = 'alert show ' + (d.code === 0 ? 'success' : 'error');
setTimeout(() => { al.className = 'alert'; al.style.display = 'none'; }, 5000);
loadBackups();
}
async function testRemote() {
const host = document.getElementById('remote-host').value.trim();
const port = parseInt(document.getElementById('remote-port').value) || 22;
const user = document.getElementById('remote-user').value.trim();
const key = document.getElementById('remote-key').value.trim();
const d = await api('/backups/remote/test', {
method: 'POST',
body: JSON.stringify({ host, port, user, key_path: key })
});
alert(d.msg);
}
// =====================
// Start
// =====================
checkAuth();
</script>
</body>
</html>