mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-03 00:39:28 +08:00
2377 lines
No EOL
76 KiB
HTML
2377 lines
No EOL
76 KiB
HTML
<!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> |