mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-02 16:29:29 +08:00
✨ v1.3.44 更新内容: - 🔧 修复:强制刷新页面后链接点不了的 bug - 修复:DOMContentLoaded 中添加 checkAuth() 调用,自动验证登录态
3788 lines
No EOL
137 KiB
HTML
3788 lines
No EOL
137 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://unpkg.com/codemirror@6.0.1/dist/index.css">
|
||
<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 {
|
||
/* v1.3.40 侧边栏跟随主题色变化,与右侧一致 */
|
||
--bg: #f8fafc;
|
||
--sidebar-bg: #f8fafc; /* light 主题:与 main 区同色 */
|
||
--topbar-bg: #ffffff;
|
||
--card: #ffffff;
|
||
--card-hover: #f1f5f9;
|
||
--border: #e2e8f0;
|
||
--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: #0f172a;
|
||
--text-dim: #64748b;
|
||
--text-muted: #475569;
|
||
--sidebar-text: #475569; /* light: 深灰 */
|
||
--sidebar-text-active: #0f172a; /* light: 接近黑 */
|
||
--sidebar-border: #e2e8f0; /* light: 浅边框 */
|
||
--sidebar-hover-bg: rgba(15,23,42,0.05); /* light: 微深 hover */
|
||
--sidebar-logo-text: #0f172a; /* light: 深色 */
|
||
}
|
||
|
||
/* v1.3.40 暗色主题(点 🌙 按钮启用) */
|
||
:root.dark {
|
||
--bg: #0a0e1a;
|
||
--sidebar-bg: #0a0e1a; /* dark 主题:与 main 区同色 */
|
||
--topbar-bg: #0f172a;
|
||
--card: #1a2332;
|
||
--card-hover: #1e293b;
|
||
--border: #2a3548;
|
||
--text: #e2e8f0;
|
||
--text-dim: #64748b;
|
||
--text-muted: #94a3b8;
|
||
--sidebar-text: #cbd5e1; /* dark: 亮灰 */
|
||
--sidebar-text-active: #ffffff; /* dark: 纯白 */
|
||
--sidebar-border: #1e293b; /* dark: 深色边框 */
|
||
--sidebar-hover-bg: rgba(255,255,255,0.06); /* dark: 微亮 hover */
|
||
--sidebar-logo-text: #ffffff; /* dark: 白色 */
|
||
}
|
||
|
||
/* 主题切换过渡 */
|
||
body, .sidebar, .card, .topbar, input, select, textarea, .modal, .nav-item {
|
||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||
}
|
||
|
||
* { 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;
|
||
}
|
||
|
||
/* v1.3.40 侧边栏背景跟随主题(不再固定深色) */
|
||
.sidebar {
|
||
width: 220px;
|
||
min-height: 100vh;
|
||
background: var(--sidebar-bg); /* light=#f8fafc / dark=#0a0e1a */
|
||
color: var(--sidebar-text);
|
||
border-right: 1px solid var(--sidebar-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* v1.3.35 修复:html/body 兑底背景,避免 flex body 高度不足时露黑/白 */
|
||
html { background: var(--bg, #f8fafc); }
|
||
body { background: var(--bg, #f8fafc); }
|
||
.main { background: var(--bg, #f8fafc); } /* main 也兑底 */
|
||
|
||
.sidebar-logo {
|
||
padding: 20px 20px 16px;
|
||
border-bottom: 1px solid var(--sidebar-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: var(--sidebar-logo-text) !important; /* v1.3.40: 跟随主题 */
|
||
}
|
||
|
||
.sidebar-nav {
|
||
padding: 12px 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
color: var(--sidebar-text) !important; /* v1.3.40: 跟随主题 */
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.nav-item:hover {
|
||
background: var(--sidebar-hover-bg); /* v1.3.40: 跟随主题 */
|
||
color: var(--sidebar-text-active) !important;
|
||
}
|
||
|
||
.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;
|
||
color: var(--text-dim); /* v1.3.40: 跟随主题 */
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding: 16px 12px;
|
||
border-top: 1px solid var(--sidebar-border); /* v1.3.40: 跟随主题 */
|
||
}
|
||
|
||
.sidebar-footer .version {
|
||
font-size: 12px;
|
||
color: var(--text-dim) !important; /* v1.3.40: 跟随主题 */
|
||
}
|
||
|
||
/* 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(--topbar-bg, var(--card));
|
||
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: flex;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
bottom: 0;
|
||
width: 260px;
|
||
z-index: 1000;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.25s ease;
|
||
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
|
||
}
|
||
.sidebar.open { transform: translateX(0); }
|
||
.main { margin-left: 0; }
|
||
.form-row { grid-template-columns: 1fr; }
|
||
.topbar { padding: 0 16px; }
|
||
.topbar h1 { font-size: 16px; }
|
||
.sidebar-backdrop {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 999;
|
||
}
|
||
.sidebar-backdrop.open { display: block; }
|
||
.hamburger-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
margin-right: 12px;
|
||
}
|
||
.hamburger-btn:hover { background: var(--card-hover); }
|
||
.topbar-right .topbar-btn:not(.red):not(#themeToggle) { display: none; }
|
||
/* v1.3.30: 主题切换按钮在 mobile 上仍然可见 */
|
||
#themeToggle { display: inline-flex !important; }
|
||
}
|
||
|
||
@media(min-width: 769px) {
|
||
.hamburger-btn { display: none; }
|
||
.sidebar-backdrop { display: none !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Login Screen (v1.3.9: 独立 div,不再 innerHTML 重写) -->
|
||
<div id="loginScreen" style="display:none;position:fixed;inset:0;align-items:center;justify-content:center;background:var(--bg);z-index:9999;">
|
||
<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>
|
||
|
||
<!-- App Shell (v1.3.9: 包一层 div,登录时隐藏) -->
|
||
<div id="appShell" style="display:flex;width:100%;">
|
||
|
||
<!-- Sidebar Backdrop (v1.3.22 mobile) -->
|
||
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="closeSidebar()"></div>
|
||
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar" id="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="production">
|
||
<span class="emoji">🚀</span> 生产增强
|
||
</a>
|
||
<a class="nav-item" data-page="upgrade">
|
||
<span class="emoji">🔄</span> 系统更新
|
||
</a>
|
||
<a class="nav-item" data-page="security">
|
||
<span class="emoji">🛡️</span> 安全
|
||
</a>
|
||
<a class="nav-item" data-page="software">
|
||
<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.3.40</div>
|
||
<div class="version" style="margin-top:4px;">tpanel.cn</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main -->
|
||
<main class="main">
|
||
<div class="topbar">
|
||
<div class="topbar-left">
|
||
<button class="hamburger-btn" id="hamburgerBtn" onclick="toggleSidebar()" aria-label="菜单">☰</button>
|
||
<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" id="themeToggle" onclick="toggleTheme()" title="切换主题">🌙</button>
|
||
<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">CPU 核心数</div>
|
||
</div>
|
||
<span class="badge blue" id="stat-cpu-cores">-</span>
|
||
</div>
|
||
<div class="service-item">
|
||
<div class="service-info">
|
||
<div class="service-name">CPU 型号</div>
|
||
</div>
|
||
<span class="badge blue" id="stat-cpu-model" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="">-</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>
|
||
<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;flex-wrap:wrap;gap:10px;">
|
||
<h2 style="font-size:18px;font-weight:700;">📁 文件管理</h2>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||
<select id="files-site-select" onchange="switchSiteFiles()" 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>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
|
||
<input type="checkbox" id="adminMode" onchange="toggleAdminMode()">
|
||
<span>👑 管理员模式(任意目录)</span>
|
||
</label>
|
||
<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>
|
||
|
||
<!-- 管理员模式:路径跳转栏 -->
|
||
<div id="adminPathBar" style="display:none;margin-bottom:16px;">
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||
<div style="flex:1;min-width:300px;">
|
||
<input type="text" id="adminPathInput" value="/" style="width:100%;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;" placeholder="输入路径,如 /etc/nginx">
|
||
</div>
|
||
<button class="btn btn-primary" onclick="goToPath()">跳转</button>
|
||
<button class="btn btn-secondary" onclick="goToPath('/')">根目录</button>
|
||
<button class="btn btn-secondary" onclick="goToPath('/opt/tpanel')">面板目录</button>
|
||
<button class="btn btn-secondary" onclick="goToPath('/etc')">系统配置</button>
|
||
<button class="btn btn-secondary" onclick="goToPath('/var/log')">日志目录</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>
|
||
<!-- v1.3.40.1: CodeMirror 6 编辑器容器 -->
|
||
<div id="fileEditorCm" style="width:100%;min-height:400px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--bg);"></div>
|
||
<!-- 兜底 textarea(CM 加载失败时用) -->
|
||
<textarea id="fileEditor" style="display:none;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>
|
||
|
||
<!-- Chmod modal (v1.3.35) -->
|
||
<div class="modal-overlay" id="chmodModal">
|
||
<div class="modal" style="width:480px;">
|
||
<div class="modal-header">
|
||
<span class="modal-title">🔐 修改权限</span>
|
||
<button class="modal-close" onclick="closeChmodDialog()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div style="margin-bottom:14px;font-size:13px;color:var(--text-dim);">
|
||
文件:<code id="chmodFilePath" style="color:var(--text);background:var(--bg-soft);padding:2px 6px;border-radius:4px;font-family:'JetBrains Mono',monospace;"></code>
|
||
</div>
|
||
<div style="margin-bottom:16px;padding:10px 14px;background:var(--bg-soft);border-radius:8px;font-size:13px;">
|
||
当前权限:<strong id="chmodCurrent" style="color:var(--primary);font-family:'JetBrains Mono',monospace;"></strong>
|
||
<span id="chmodCurrentDetail" style="color:var(--text-dim);margin-left:6px;font-family:'JetBrains Mono',monospace;"></span>
|
||
</div>
|
||
|
||
<div style="margin-bottom:8px;font-size:13px;color:var(--text-dim);">快速预设:</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px;">
|
||
<button class="btn btn-secondary" onclick="chmodPreset(755)">755 (目录)</button>
|
||
<button class="btn btn-secondary" onclick="chmodPreset(644)">644 (文件)</button>
|
||
<button class="btn btn-secondary" onclick="chmodPreset(600)">600 (私密)</button>
|
||
<button class="btn btn-secondary" onclick="chmodPreset(700)">700 (脚本)</button>
|
||
<button class="btn btn-secondary" onclick="chmodPreset(775)">775 (组共享)</button>
|
||
<button class="btn btn-secondary" onclick="chmodPreset(664)">664 (组写)</button>
|
||
</div>
|
||
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||
<label style="font-size:13px;color:var(--text-dim);min-width:60px;">自定义:</label>
|
||
<input type="text" id="chmodInput" maxlength="3" pattern="[0-7]{3}"
|
||
style="flex:1;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'JetBrains Mono',monospace;font-size:14px;text-align:center;letter-spacing:2px;"
|
||
placeholder="例如 755" inputmode="numeric">
|
||
</div>
|
||
<div id="chmodPreview" style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-dim);min-height:18px;margin-left:70px;"></div>
|
||
|
||
<div id="chmodAlert" style="margin-top:12px;display:none;"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeChmodDialog()">取消</button>
|
||
<button class="btn btn-primary" id="chmodSubmitBtn" onclick="submitChmod()">应用</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>
|
||
|
||
<!-- Software (v1.3.10 新增) -->
|
||
<div class="page" id="page-software">
|
||
<div class="alert success" id="softwareAlert"></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;">
|
||
<button class="btn btn-secondary" onclick="addSuryRepo()">➕ 添加 Sury PHP 源</button>
|
||
<button class="btn btn-secondary" onclick="loadSoftware()">🔄 刷新状态</button>
|
||
</div>
|
||
</div>
|
||
<p style="color:var(--text-dim);font-size:13px;margin-bottom:20px;">
|
||
一键安装建站必备环境。安装过程中会显示实时进度。
|
||
</p>
|
||
<div id="softwareGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
|
||
<div style="color:var(--text-dim);text-align:center;padding:40px;">加载中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Production / 生产增强 -->
|
||
<div class="page" id="page-production">
|
||
<!-- 防火墙管理 -->
|
||
<div class="form-section">
|
||
<h3>🔥 防火墙管理</h3>
|
||
<div id="fwStatus"><span class="loading">加载中...</span></div>
|
||
<div id="fwControls" style="display:none;margin-top:15px;">
|
||
<div style="margin-bottom:15px;">
|
||
<button class="btn btn-primary" id="fwEnableBtn" onclick="enableFirewall()" style="display:none;">🟢 启用防火墙</button>
|
||
<span id="fwEnabledBadge" style="display:none;background:var(--green);color:#fff;padding:4px 12px;border-radius:6px;font-size:13px;">✅ 防火墙已启用</span>
|
||
</div>
|
||
<div class="form-row" style="max-width:400px;">
|
||
<div class="form-group" style="flex:2;">
|
||
<label>端口号</label>
|
||
<input type="text" id="fwPort" placeholder="例如: 3306">
|
||
</div>
|
||
<div class="form-group" style="flex:1;">
|
||
<label>协议</label>
|
||
<select id="fwProto">
|
||
<option value="tcp">TCP</option>
|
||
<option value="udp">UDP</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="flex:1;">
|
||
<label> </label>
|
||
<button class="btn btn-success" onclick="openPort()">开放端口</button>
|
||
</div>
|
||
</div>
|
||
<div id="fwRulesList" style="margin-top:15px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 面板端口修改 -->
|
||
<div class="form-section">
|
||
<h3>🔌 面板端口修改</h3>
|
||
<div class="form-row" style="max-width:400px;">
|
||
<div class="form-group">
|
||
<label>当前端口</label>
|
||
<input type="text" id="curPanelPort" readonly>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>新端口</label>
|
||
<input type="text" id="newPanelPort" placeholder="1000-65535">
|
||
</div>
|
||
<div class="form-group" style="display:flex;align-items:flex-end;">
|
||
<button class="btn btn-warning" onclick="changePanelPort()">修改端口</button>
|
||
</div>
|
||
</div>
|
||
<div class="alert warning" style="margin-top:15px;font-size:13px;">
|
||
⚠️ 修改端口后需要重新访问新端口的地址,防火墙会自动开放新端口。
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自动备份 -->
|
||
<div class="form-section">
|
||
<h3>💾 自动备份</h3>
|
||
<div class="form-row">
|
||
<div class="form-group" style="max-width:200px;">
|
||
<label>自动备份</label>
|
||
<select id="backupEnabled">
|
||
<option value="1">启用</option>
|
||
<option value="0">禁用</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="max-width:200px;">
|
||
<label>备份频率</label>
|
||
<select id="backupSchedule">
|
||
<option value="hourly">每小时</option>
|
||
<option value="daily">每天</option>
|
||
<option value="weekly">每周</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="max-width:200px;">
|
||
<label>保留天数</label>
|
||
<input type="number" id="backupKeepDays" value="7" min="1" max="365">
|
||
</div>
|
||
<div class="form-group" style="max-width:300px;">
|
||
<label>备份目录</label>
|
||
<input type="text" id="backupDir" value="/backup">
|
||
</div>
|
||
<div class="form-group" style="display:flex;align-items:flex-end;">
|
||
<button class="btn btn-primary" onclick="saveBackupSettings()">保存配置</button>
|
||
<button class="btn btn-success" onclick="runBackupNow()" style="margin-left:10px;">立即备份</button>
|
||
</div>
|
||
</div>
|
||
<div id="backupList" style="margin-top:20px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upgrade (v1.3.39+) -->
|
||
<div class="page" id="page-upgrade">
|
||
<div class="alert error" id="upgradeAlert"></div>
|
||
|
||
<!-- 版本信息 -->
|
||
<div class="panel" style="padding:20px;margin-bottom:20px;">
|
||
<h3 style="margin:0 0 16px 0;font-size:16px;">📦 版本信息</h3>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:20px;">
|
||
<div>
|
||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:4px;">当前版本</div>
|
||
<div id="currentVersion" style="font-size:20px;font-weight:700;">--</div>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:4px;">最新版本</div>
|
||
<div id="latestVersion" style="font-size:20px;font-weight:700;">--</div>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:4px;">状态</div>
|
||
<div id="versionStatus" style="font-size:14px;">--</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border);">
|
||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">更新说明</div>
|
||
<div id="releaseNotes" style="font-size:13px;line-height:1.6;">--</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 手动上传升级 -->
|
||
<div class="panel" style="padding:20px;margin-bottom:20px;">
|
||
<h3 style="margin:0 0 16px 0;font-size:16px;">⬆️ 手动升级</h3>
|
||
<div style="border:2px dashed var(--border);border-radius:12px;padding:40px;text-align:center;" id="uploadDropZone">
|
||
<div style="font-size:48px;margin-bottom:10px;">📦</div>
|
||
<div style="font-size:14px;color:var(--text-dim);margin-bottom:10px;">拖拽安装包到这里,或点击选择文件</div>
|
||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:16px;">支持 tpanel-v*.zip 格式安装包</div>
|
||
<input type="file" id="upgradeFileInput" accept=".zip" style="display:none;" onchange="handleUpgradeFile(this)">
|
||
<button class="btn btn-primary" onclick="document.getElementById('upgradeFileInput').click()">选择安装包</button>
|
||
</div>
|
||
<div id="uploadedPackage" style="display:none;margin-top:16px;padding:16px;background:var(--green-dim);border-radius:8px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||
<div>
|
||
<span style="color:var(--green);font-weight:700;">✅ 安装包已就绪</span>
|
||
<span id="uploadedFileName" style="margin-left:10px;"></span>
|
||
</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-secondary" onclick="cancelUpload()">取消</button>
|
||
<button class="btn btn-primary" onclick="runUpgrade()">🚀 开始升级</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 版本回滚 -->
|
||
<div class="panel" style="padding:20px;">
|
||
<h3 style="margin:0 0 16px 0;font-size:16px;">⏪ 版本回滚</h3>
|
||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">升级前会自动备份当前版本,可以随时回滚</div>
|
||
<div id="backupList">
|
||
<div style="color:var(--text-dim);text-align:center;padding:20px;">暂无备份</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>
|
||
|
||
</div><!-- /appShell v1.3.9 -->
|
||
|
||
<!-- 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>站点类型</label>
|
||
<select id="site-type" onchange="togglePhpVersion()">
|
||
<option value="php" selected>🌐 PHP 动态网站(需要 PHP-FPM)</option>
|
||
<option value="static">📄 纯静态页面(HTML / CSS / JS,不需 PHP)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="site-php-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>
|
||
|
||
<!-- v1.3.34 修改数据库密码 -->
|
||
<div class="modal-overlay" id="changeDbPassModal">
|
||
<div class="modal" style="width:420px;">
|
||
<div class="modal-header">
|
||
<span class="modal-title">🔑 修改数据库密码</span>
|
||
<button class="modal-close" onclick="closeChangeDbPassModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="alert error" id="changeDbPassAlert"></div>
|
||
<p style="margin-bottom:12px;color:var(--text-dim);font-size:13px;">
|
||
数据库:<strong id="changeDbPassName" style="color:var(--text);font-family:'JetBrains Mono',monospace;"></strong>
|
||
</p>
|
||
<div class="form-group">
|
||
<label>新密码(至少 6 位)</label>
|
||
<input type="password" id="changeDbPassNew" placeholder="新密码" onkeydown="if(event.key==='Enter')submitChangeDbPass()">
|
||
</div>
|
||
<p style="color:var(--yellow);font-size:12px;margin-top:8px;">
|
||
⚠️ 修改后请同步更新站点配置文件(如 wp-config.php、.env 等)
|
||
</p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeChangeDbPassModal()">取消</button>
|
||
<button class="btn btn-primary" onclick="submitChangeDbPass()">确认修改</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>
|
||
|
||
<!-- Progress Modal (v1.3.10 通用进度弹窗) -->
|
||
<div class="modal-overlay" id="progressModal">
|
||
<div class="modal" style="width:680px;max-width:90vw;">
|
||
<div class="modal-header">
|
||
<span class="modal-title" id="progressTitle">⏳ 任务进行中...</span>
|
||
<button class="modal-close" onclick="cancelProgress()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="progressStatus" style="margin-bottom:12px;color:var(--text-dim);font-size:13px;">准备中...</div>
|
||
<pre id="progressLog" style="background:#000;color:#22c55e;padding:16px;border-radius:8px;font-family:monospace;font-size:12px;line-height:1.5;max-height:420px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;margin:0;"></pre>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="cancelProgress()">后台运行</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();
|
||
}
|
||
}
|
||
|
||
// 修复 v1.3.9:showLogin/login 改用 display 切换,不再 innerHTML 重写 body
|
||
// 原因:旧版 showLogin() 会 document.body.innerHTML = '...' 把后台骨架(含 .nav-item[data-page])
|
||
// 整个覆盖掉,导致登录成功后 initApp() → setupNav() 找不到 .nav-item,loadDashboard() 静默失败,
|
||
// 页面卡在登录页,必须强制刷新才能看到后台。
|
||
function showLogin() {
|
||
const loginEl = document.getElementById('loginScreen');
|
||
const appEl = document.getElementById('appShell');
|
||
if (loginEl) loginEl.style.display = 'flex';
|
||
if (appEl) appEl.style.display = 'none';
|
||
// 触发重排后清掉输入框的旧值
|
||
setTimeout(() => {
|
||
const u = document.getElementById('loginUser');
|
||
const p = document.getElementById('loginPass');
|
||
const a = document.getElementById('loginAlert');
|
||
if (u) u.value = '';
|
||
if (p) p.value = '';
|
||
if (a) a.style.display = 'none';
|
||
}, 0);
|
||
}
|
||
|
||
function hideLogin() {
|
||
const loginEl = document.getElementById('loginScreen');
|
||
const appEl = document.getElementById('appShell');
|
||
if (loginEl) loginEl.style.display = 'none';
|
||
if (appEl) appEl.style.display = 'flex';
|
||
}
|
||
|
||
// v1.3.30 主题切换
|
||
function toggleTheme() {
|
||
const root = document.documentElement;
|
||
const isDark = root.classList.toggle('dark');
|
||
localStorage.setItem('tpanel_theme', isDark ? 'dark' : 'light');
|
||
const btn = document.getElementById('themeToggle');
|
||
if (btn) btn.textContent = isDark ? '☀️' : '🌙';
|
||
}
|
||
function initTheme() {
|
||
const saved = localStorage.getItem('tpanel_theme');
|
||
// 默认 light(背景白);除非用户明确选过 dark
|
||
const isDark = saved === 'dark';
|
||
if (isDark) document.documentElement.classList.add('dark');
|
||
const btn = document.getElementById('themeToggle');
|
||
if (btn) btn.textContent = isDark ? '☀️' : '🌙';
|
||
}
|
||
// 页面加载时立刻跑(别等 DOMContentLoaded)
|
||
initTheme();
|
||
|
||
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);
|
||
hideLogin(); // 修复 v1.3.9:先显示后台骨架,再 initApp(),否则 setupNav 找不到 .nav-item
|
||
initApp();
|
||
} else {
|
||
const al = document.getElementById('loginAlert');
|
||
if (al) { 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');
|
||
// 修复 v1.3.9:不 reload(reload 会在慢机器上看到一闪而过的后台骨架),直接切到登录页
|
||
showLogin();
|
||
}
|
||
|
||
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() {
|
||
// 防御性:确保 appShell 显示,loginScreen 隐藏(login() 里已做,这里双保险)
|
||
const appEl = document.getElementById('appShell');
|
||
const loginEl = document.getElementById('loginScreen');
|
||
if (appEl) appEl.style.display = 'flex';
|
||
if (loginEl) loginEl.style.display = 'none';
|
||
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:'安全中心', software:'软件市场', settings:'设置', logs:'安全日志', files:'文件管理', cronjobs:'定时任务' };
|
||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||
if (page === 'sites') loadSites();
|
||
if (page === 'databases') loadDbs();
|
||
if (page === 'software') loadSoftware();
|
||
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();
|
||
closeSidebar(); // v1.3.22: 移动端点导航后自动收起侧边栏
|
||
});
|
||
});
|
||
}
|
||
|
||
// v1.3.22: 移动端侧边栏控制
|
||
function toggleSidebar() {
|
||
const sb = document.getElementById('sidebar');
|
||
const bd = document.getElementById('sidebarBackdrop');
|
||
if (sb && bd) {
|
||
sb.classList.toggle('open');
|
||
bd.classList.toggle('open');
|
||
}
|
||
}
|
||
function closeSidebar() {
|
||
const sb = document.getElementById('sidebar');
|
||
const bd = document.getElementById('sidebarBackdrop');
|
||
if (sb) sb.classList.remove('open');
|
||
if (bd) bd.classList.remove('open');
|
||
}
|
||
|
||
// =====================
|
||
// 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-cpu-cores').textContent = d.data.cpu_cores + ' 核';
|
||
const m = document.getElementById('stat-cpu-model');
|
||
if (m) { m.textContent = d.data.cpu_model || '-'; m.title = d.data.cpu_model || ''; }
|
||
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;
|
||
// v1.3.9+ 改进:按核心数判断负载颜色(1 核机器 1.0 算满,4 核机器 4.0 才算满)
|
||
const load1 = parseFloat((d.data.load || '0').split(' ')[0]) || 0;
|
||
const cores = parseInt(d.data.cpu_cores) || 1;
|
||
const loadRatio = load1 / cores;
|
||
let loadCls = 'blue';
|
||
if (loadRatio > 0.8) loadCls = 'red';
|
||
else if (loadRatio > 0.5) loadCls = 'yellow';
|
||
document.getElementById('stat-load').className = 'badge ' + loadCls;
|
||
|
||
// 服务状态
|
||
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 type = document.getElementById('site-type').value;
|
||
const php = type === 'static' ? '8.2' : document.getElementById('site-php').value; // static 站点传什么 php_version 后端会忽略
|
||
if (!domain) { showAlert('siteModalAlert', '请输入域名'); return; }
|
||
const d = await api('/sites', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ domain, name, type, php_version: php })
|
||
});
|
||
if (d.code === 0) {
|
||
closeSiteModal();
|
||
loadSites();
|
||
} else {
|
||
showAlert('siteModalAlert', d.msg);
|
||
}
|
||
}
|
||
|
||
// v1.3.26: 静态页面时隐藏 PHP 版本选择
|
||
function togglePhpVersion() {
|
||
const type = document.getElementById('site-type').value;
|
||
document.getElementById('site-php-group').style.display = type === 'static' ? 'none' : 'block';
|
||
}
|
||
|
||
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) {
|
||
if (!confirm('确定要备份这个站点吗?\n大站点可能需要几分钟。')) return;
|
||
// v1.3.10 修复:加 fetch timeout(默认 fetch 没超时控制,大站点会一直转没反应)
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 5 * 60 * 1000); // 5 分钟超时
|
||
const oldTitle = document.title;
|
||
document.title = '⏳ 备份中...';
|
||
try {
|
||
const r = await fetch(API + '/backups', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ site_id: siteId }),
|
||
signal: ctrl.signal
|
||
});
|
||
clearTimeout(timer);
|
||
document.title = oldTitle;
|
||
const d = await r.json();
|
||
if (d.code === 0) {
|
||
alert('✅ 备份成功!\n文件:' + d.data.path + '\n大小:' + (d.data.size / 1024 / 1024).toFixed(2) + ' MB');
|
||
loadBackups();
|
||
} else {
|
||
alert('❌ 备份失败:' + d.msg);
|
||
}
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
document.title = oldTitle;
|
||
if (e.name === 'AbortError') {
|
||
alert('❌ 备份超时(>5分钟),请到 SSH 看 /var/log/syslog 或手动跑 tar 测试');
|
||
} else {
|
||
alert('❌ 备份异常:' + e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// 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="6" style="text-align:center;padding:40px;color:var(--text-dim);">暂无数据库</td></tr>';
|
||
return;
|
||
}
|
||
// 记录每行 db 的真实密码(供 👁️ 显示)
|
||
window._dbSecrets = {};
|
||
tbody.innerHTML = d.data.map(s => {
|
||
window._dbSecrets[s.id] = s.db_pass || '';
|
||
return `
|
||
<tr>
|
||
<td class="domain" style="cursor:pointer;color:var(--blue);text-decoration:underline" onclick="openPhpMyAdmin(${s.id},'${s.name}')" title="点击打开 phpMyAdmin 管理 ${s.name}">${s.name}</td>
|
||
<td>${s.db_user}</td>
|
||
<td style="font-family:'JetBrains Mono',monospace;">
|
||
<span id="db-pass-${s.id}">••••••••</span>
|
||
<button class="btn-icon" title="显示密码" onclick="toggleDbPass(${s.id})">👁️</button>
|
||
</td>
|
||
<td>${s.charset}</td>
|
||
<td>${s.created_at ? s.created_at.split('T')[0] : '-'}</td>
|
||
<td class="actions">
|
||
<button class="btn-icon" title="打开 phpMyAdmin" onclick="openPhpMyAdmin(${s.id},'${s.name}')">🐘</button>
|
||
<button class="btn-icon" title="修改密码" onclick="openChangeDbPassModal(${s.id},'${s.name}')">🔑</button>
|
||
<button class="btn-icon danger" onclick="deleteDb(${s.id},'${s.name}')">🗑️</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
// 显示/隐藏数据库密码
|
||
function toggleDbPass(dbId) {
|
||
const span = document.getElementById('db-pass-' + dbId);
|
||
if (!span) return;
|
||
const real = window._dbSecrets && window._dbSecrets[dbId];
|
||
if (!real) return;
|
||
if (span.dataset.shown === '1') {
|
||
span.textContent = '••••••••';
|
||
span.dataset.shown = '0';
|
||
} else {
|
||
span.textContent = real;
|
||
span.dataset.shown = '1';
|
||
}
|
||
}
|
||
|
||
// 修改数据库密码 modal
|
||
let _changeDbPassId = null;
|
||
function openChangeDbPassModal(dbId, dbName) {
|
||
_changeDbPassId = dbId;
|
||
document.getElementById('changeDbPassName').textContent = dbName;
|
||
document.getElementById('changeDbPassNew').value = '';
|
||
document.getElementById('changeDbPassAlert').style.display = 'none';
|
||
document.getElementById('changeDbPassModal').classList.add('show');
|
||
}
|
||
function closeChangeDbPassModal() {
|
||
document.getElementById('changeDbPassModal').classList.remove('show');
|
||
_changeDbPassId = null;
|
||
}
|
||
async function submitChangeDbPass() {
|
||
if (!_changeDbPassId) return;
|
||
const newPass = document.getElementById('changeDbPassNew').value;
|
||
if (!newPass || newPass.length < 6) {
|
||
showAlert('changeDbPassAlert', '新密码至少 6 位');
|
||
return;
|
||
}
|
||
const d = await api('/databases/' + _changeDbPassId + '/password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ new_pass: newPass })
|
||
});
|
||
if (d.code === 0) {
|
||
showAlert('changeDbPassAlert', '✅ ' + d.msg, 'success');
|
||
setTimeout(() => { closeChangeDbPassModal(); loadDbs(); }, 800);
|
||
} else {
|
||
showAlert('changeDbPassAlert', '❌ ' + d.msg);
|
||
}
|
||
}
|
||
|
||
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; }
|
||
closeSSLModal();
|
||
// v1.3.25: SSL 申请走任务流(带 SSE 进度框)
|
||
await runTaskWithProgress({
|
||
title: '🔒 申请 SSL 证书',
|
||
startUrl: '/ssl/apply',
|
||
startMethod: 'POST',
|
||
startBody: { site_id: parseInt(siteId) },
|
||
onDone: async (ok) => {
|
||
if (ok) loadSSL();
|
||
}
|
||
});
|
||
}
|
||
|
||
// =====================
|
||
// 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;
|
||
await runTaskWithProgress({
|
||
title: '🛡️ 系统安全更新',
|
||
startUrl: '/security/update',
|
||
startMethod: 'POST',
|
||
onDone: (ok) => { if (ok) { loadSecurity(); loadDashboard(); } }
|
||
});
|
||
}
|
||
|
||
// =====================
|
||
// 通用任务进度弹窗(v1.3.10 新增)
|
||
// =====================
|
||
let _currentTaskES = null;
|
||
async function runTaskWithProgress(opts) {
|
||
const { title, startUrl, startMethod = 'POST', startBody = null, onDone } = opts;
|
||
// 1. 启动任务
|
||
let taskId;
|
||
// v1.3.35: SSE 状态管理(防止 onerror 误调 onDone + 幂等保护)
|
||
let _sseSettled = false;
|
||
let _sseErrorCount = 0;
|
||
let _sseReconnectTimer = null;
|
||
let _onDoneCalled = false;
|
||
// 包装 onDone,幂等调用
|
||
const _fireOnDone = (ok) => {
|
||
if (_onDoneCalled) return;
|
||
_onDoneCalled = true;
|
||
if (onDone) onDone(ok);
|
||
};
|
||
try {
|
||
const r = await fetch(API + startUrl, {
|
||
method: startMethod,
|
||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||
body: startBody ? JSON.stringify(startBody) : null
|
||
});
|
||
const d = await r.json();
|
||
if (d.code !== 0) { alert('启动失败:' + d.msg); return; }
|
||
taskId = d.data?.task_id;
|
||
if (!taskId) { alert('未返回 task_id'); return; }
|
||
} catch (e) {
|
||
alert('启动失败:' + e.message);
|
||
return;
|
||
}
|
||
// 2. 弹窗 + SSE
|
||
document.getElementById('progressTitle').textContent = title;
|
||
document.getElementById('progressStatus').textContent = `任务 ID: ${taskId} - 连接中...`;
|
||
document.getElementById('progressLog').textContent = '';
|
||
document.getElementById('progressModal').classList.add('show');
|
||
if (_currentTaskES) { _currentTaskES.close(); _currentTaskES = null; }
|
||
_currentTaskES = new EventSource(API + `/tasks/${taskId}/stream?token=${encodeURIComponent(token)}`);
|
||
_currentTaskES.onmessage = (e) => {
|
||
let msg;
|
||
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||
if (msg.type === 'status' || msg.type === 'log') {
|
||
if (msg.log) {
|
||
const logEl = document.getElementById('progressLog');
|
||
logEl.textContent += msg.log;
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
}
|
||
if (msg.status) {
|
||
document.getElementById('progressStatus').textContent = `状态: ${msg.status}`;
|
||
}
|
||
} else if (msg.type === 'done') {
|
||
_sseSettled = true;
|
||
_currentTaskES.close();
|
||
_currentTaskES = null;
|
||
document.getElementById('progressStatus').textContent = msg.status === 'success' ? '✅ 完成' : '❌ 失败';
|
||
if (msg.status === 'success') {
|
||
setTimeout(() => {
|
||
document.getElementById('progressModal').classList.remove('show');
|
||
_fireOnDone(true);
|
||
}, 1500);
|
||
} else {
|
||
_fireOnDone(false);
|
||
}
|
||
} else if (msg.type === 'error') {
|
||
_sseSettled = true;
|
||
_currentTaskES.close();
|
||
_currentTaskES = null;
|
||
document.getElementById('progressStatus').textContent = '❌ ' + (msg.msg || '任务错误');
|
||
_fireOnDone(false);
|
||
}
|
||
};
|
||
// v1.3.35 修复:SSE 临时断连是常态(心跳超时/Nginx 缓冲),不能调 onDone(false)
|
||
// 改为:显示警告、尝试重连、仅在重连多次失败且无 done 事件时才判定失败
|
||
// _sseSettled / _sseErrorCount / _sseReconnectTimer 已在函数顶部声明
|
||
_currentTaskES.onerror = () => {
|
||
if (_sseSettled) return; // 已经收到 done,不再处理
|
||
_sseErrorCount++;
|
||
document.getElementById('progressStatus').textContent = `⚠️ 连接波动 (${_sseErrorCount}/3),尝试重连...`;
|
||
// EventSource 自身会重连,这里只是用 status 文字提示用户
|
||
if (_sseErrorCount >= 3 && !_sseReconnectTimer) {
|
||
// 连续 3 次错误,主动查后端任务状态作 fallback
|
||
_sseReconnectTimer = setTimeout(async () => {
|
||
if (_sseSettled) return;
|
||
try {
|
||
const r = await fetch(API + `/tasks/${taskId}`, {
|
||
headers: { 'Authorization': 'Bearer ' + token }
|
||
});
|
||
const d = await r.json();
|
||
if (d.code === 0 && d.data && (d.data.status === 'success' || d.data.status === 'failed')) {
|
||
_sseSettled = true;
|
||
if (_currentTaskES) { _currentTaskES.close(); _currentTaskES = null; }
|
||
document.getElementById('progressStatus').textContent = d.data.status === 'success' ? '✅ 完成' : '❌ 失败';
|
||
if (d.data.status === 'success') {
|
||
setTimeout(() => {
|
||
document.getElementById('progressModal').classList.remove('show');
|
||
_fireOnDone(true);
|
||
}, 500);
|
||
} else {
|
||
_fireOnDone(false);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 真连不上才认定为失败
|
||
_sseSettled = true;
|
||
if (_currentTaskES) { _currentTaskES.close(); _currentTaskES = null; }
|
||
document.getElementById('progressStatus').textContent = '❌ 连接失败';
|
||
_fireOnDone(false);
|
||
}
|
||
}, 2000);
|
||
}
|
||
};
|
||
// (done/error 分支已设置 _sseSettled = true,onerror 会被 guard 拦掉)
|
||
}
|
||
|
||
function cancelProgress() {
|
||
if (_currentTaskES) { _currentTaskES.close(); _currentTaskES = null; }
|
||
document.getElementById('progressModal').classList.remove('show');
|
||
}
|
||
|
||
// =====================
|
||
// 软件市场(v1.3.10 新增)
|
||
// =====================
|
||
async function loadSoftware() {
|
||
const grid = document.getElementById('softwareGrid');
|
||
grid.innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:40px;">加载中...</div>';
|
||
const d = await api('/software/list');
|
||
if (d.code !== 0) { grid.innerHTML = '<div style="color:var(--red);">加载失败: ' + d.msg + '</div>'; return; }
|
||
if (!d.data || d.data.length === 0) {
|
||
grid.innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:40px;">暂无软件</div>';
|
||
return;
|
||
}
|
||
// 按分类分组
|
||
const groups = {};
|
||
for (const s of d.data) {
|
||
if (!groups[s.category]) groups[s.category] = [];
|
||
groups[s.category].push(s);
|
||
}
|
||
let html = '';
|
||
for (const cat in groups) {
|
||
html += `<div style="grid-column:1/-1;font-size:14px;font-weight:700;color:var(--text-muted);margin-top:8px;">${cat}</div>`;
|
||
for (const s of groups[cat]) {
|
||
const status = s.installed
|
||
? '<span class="badge green">✓ 已安装</span>'
|
||
: '<span class="badge yellow">未安装</span>';
|
||
const btn = s.installed
|
||
? `<button class="btn btn-secondary" style="font-size:12px;padding:6px 12px;" onclick="uninstallSoftware('${s.name}')">卸载</button>`
|
||
: `<button class="btn btn-primary" style="font-size:12px;padding:6px 12px;" onclick="installSoftware('${s.name}')">安装</button>`;
|
||
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px;">
|
||
<div>
|
||
<div style="font-size:16px;font-weight:700;margin-bottom:4px;">${s.display_name}</div>
|
||
<div style="color:var(--text-dim);font-size:12px;">${s.version || '点击安装'}</div>
|
||
</div>
|
||
${status}
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:12px;">${btn}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
grid.innerHTML = html;
|
||
}
|
||
|
||
async function installSoftware(name) {
|
||
if (!confirm(`确定要安装 ${name} 吗?\n安装过程中会显示实时进度。`)) return;
|
||
await runTaskWithProgress({
|
||
title: `📦 安装 ${name}`,
|
||
startUrl: `/software/install/${name}`,
|
||
startMethod: 'POST',
|
||
onDone: (ok) => { if (ok) setTimeout(loadSoftware, 1000); }
|
||
});
|
||
}
|
||
|
||
// v1.3.28: 添加 Sury PHP 源(让 Debian 12 能装 PHP 5.6 / 7.0 / 7.4 / 8.0 / 8.1 / 8.3 / 8.4)
|
||
async function addSuryRepo() {
|
||
if (!confirm('添加 Sury 第三方 PHP 源?\n\n适用于 Debian / Ubuntu 系统。\n会从 packages.sury.org 下载 GPG 密钥并 apt update,可能耗时 1-2 分钟。')) return;
|
||
await runTaskWithProgress({
|
||
title: '➕ 添加 Sury PHP 源',
|
||
startUrl: '/system/add-sury-php',
|
||
startMethod: 'POST',
|
||
onDone: (ok) => { if (ok) setTimeout(loadSoftware, 1000); }
|
||
});
|
||
}
|
||
|
||
async function uninstallSoftware(name) {
|
||
if (!confirm(`确定要卸载 ${name} 吗?\n卸载可能导致依赖该软件的网站不可用。`)) return;
|
||
await runTaskWithProgress({
|
||
title: `📦 卸载 ${name}`,
|
||
startUrl: `/software/uninstall/${name}`,
|
||
startMethod: 'POST',
|
||
onDone: (ok) => { if (ok) setTimeout(loadSoftware, 1000); }
|
||
});
|
||
}
|
||
|
||
// =====================
|
||
// phpMyAdmin(v1.3.10 新增 - 装完轮询 Nginx 配好再跳)
|
||
// =====================
|
||
async function _pollPhpMyAdminReady(maxSeconds = 30) {
|
||
// 轮询 status,等 nginx_ok 变 true(on_complete 钩子在装完 apt 后会写配置)
|
||
const start = Date.now();
|
||
while ((Date.now() - start) / 1000 < maxSeconds) {
|
||
const s = await api('/phpmyadmin/status');
|
||
if (s.code === 0 && s.data.installed) return s.data;
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// v1.3.34:点 db_name 或 🐘 按钮调,dbId 用来拿 token 实现真自动登录
|
||
async function openPhpMyAdmin(dbId, dbName) {
|
||
const s = await api('/phpmyadmin/status');
|
||
if (s.code !== 0) { alert('查询状态失败:' + s.msg); return; }
|
||
if (!s.data.installed) {
|
||
if (!confirm('phpMyAdmin 还没安装。\n现在装?\n\n装完后会用 8443 端口、面板当前访问地址,账号用站点 db_user/db_pass 登录。')) return;
|
||
await runTaskWithProgress({
|
||
title: '📦 安装 phpMyAdmin',
|
||
startUrl: '/phpmyadmin/install',
|
||
startMethod: 'POST',
|
||
onDone: async (ok) => {
|
||
if (!ok) return;
|
||
// 装完轮询 status,等后端 on_complete 钩子写好 Nginx 反代
|
||
const ready = await _pollPhpMyAdminReady(30);
|
||
if (!ready) {
|
||
alert('phpMyAdmin 已装但 Nginx 反代还没配好(超时 30s)。\n请去日志页查看详情,或手动跑:\nsudo nginx -t && sudo systemctl reload nginx');
|
||
return;
|
||
}
|
||
_openPmaWithAutoLogin(dbId, dbName);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
// 已装 → 真自动登录
|
||
_openPmaWithAutoLogin(dbId, dbName);
|
||
}
|
||
|
||
// v1.3.34:拿 5 分钟有效 token → 跳 /api/phpmyadmin/signon → bridge.php → phpMyAdmin
|
||
async function _openPmaWithAutoLogin(dbId, dbName) {
|
||
const tr = await api('/phpmyadmin/token/' + dbId);
|
||
if (tr.code !== 0) {
|
||
alert('生成登录凭证失败:' + tr.msg);
|
||
return;
|
||
}
|
||
// URL 拼 token,bridge.php 验 + 种 session + 302
|
||
const url = '/api/phpmyadmin/signon?token=' + encodeURIComponent(tr.data.token) + '&db=' + dbId;
|
||
window.open(url, '_blank');
|
||
}
|
||
|
||
// =====================
|
||
// 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, type) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.className = type ? 'alert show ' + type : 'alert show';
|
||
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" onclick="openChmodDialog('${item.name}', ${item.permissions})" 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('editFilePath').dataset.adminMode = 'false';
|
||
// v1.3.40.1: 用 CodeMirror 显示(兜底用 textarea)
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
_cmInitEditor(d.data, editingFilePath, isDark).catch(e => {
|
||
console.warn('CM init failed, fallback to textarea:', e);
|
||
document.getElementById('fileEditor').style.display = 'block';
|
||
document.getElementById('fileEditorCm').style.display = 'none';
|
||
document.getElementById('fileEditor').value = d.data;
|
||
});
|
||
document.getElementById('fileEditModal').classList.add('show');
|
||
});
|
||
}
|
||
|
||
function closeFileEditModal() { document.getElementById('fileEditModal').classList.remove('show'); }
|
||
|
||
// ==========================
|
||
// Chmod 权限修改(v1.3.35 新增)
|
||
// ==========================
|
||
let chmodTargetName = '';
|
||
|
||
function parsePermMode(mode) {
|
||
// mode 可能是 0o755 (493) 或 '755',统一返回 3 位字符串
|
||
if (typeof mode === 'string') mode = parseInt(mode, 8);
|
||
// 小于 1000 视为 0o755 形式(八进制小表达),否则当十进制 oct 处理
|
||
if (mode < 1000) return mode.toString(8).padStart(3, '0');
|
||
return (mode & 0o777).toString(8).padStart(3, '0');
|
||
}
|
||
|
||
function permToRwx(modeStr) {
|
||
// '755' -> 'rwxr-xr-x'
|
||
const map = { '0': '---', '1': '--x', '2': '-w-', '3': '-wx', '4': 'r--', '5': 'r-x', '6': 'rw-', '7': 'rwx' };
|
||
return modeStr.split('').map(c => map[c] || '---').join('');
|
||
}
|
||
|
||
function openChmodDialog(name, currentPerm) {
|
||
chmodTargetName = name;
|
||
const fullPath = currentPath ? currentPath + '/' + name : name;
|
||
const modeStr = parsePermMode(currentPerm);
|
||
$('chmodFilePath').textContent = fullPath;
|
||
$('chmodCurrent').textContent = modeStr;
|
||
$('chmodCurrentDetail').textContent = '(' + permToRwx(modeStr) + ')';
|
||
$('chmodInput').value = modeStr;
|
||
updateChmodPreview();
|
||
$('chmodAlert').style.display = 'none';
|
||
$('chmodModal').classList.add('show');
|
||
// 焦点放到 input
|
||
setTimeout(() => $('chmodInput').focus(), 50);
|
||
}
|
||
|
||
function closeChmodDialog() {
|
||
$('chmodModal').classList.remove('show');
|
||
}
|
||
|
||
function chmodPreset(mode) {
|
||
$('chmodInput').value = String(mode);
|
||
updateChmodPreview();
|
||
}
|
||
|
||
function updateChmodPreview() {
|
||
const v = ($('chmodInput').value || '').trim();
|
||
if (/^[0-7]{3}$/.test(v)) {
|
||
$('chmodPreview').textContent = `${v} = ${permToRwx(v)} (${v[0]}=所有者 ${v[1]}=组 ${v[2]}=公共)`;
|
||
$('chmodPreview').style.color = 'var(--text-dim)';
|
||
} else if (v === '') {
|
||
$('chmodPreview').textContent = '';
|
||
} else {
|
||
$('chmodPreview').textContent = '格式错误:必须是 3 位 0-7 数字(如 755、644、600)';
|
||
$('chmodPreview').style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
async function submitChmod() {
|
||
const v = ($('chmodInput').value || '').trim();
|
||
if (!/^[0-7]{3}$/.test(v)) {
|
||
showChmodAlert('权限值格式错误,请输入 3 位 0-7 数字', 'error');
|
||
return;
|
||
}
|
||
const path = currentPath ? currentPath + '/' + chmodTargetName : chmodTargetName;
|
||
const btn = $('chmodSubmitBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '应用中...';
|
||
try {
|
||
const r = await fetch(API + '/files/chmod', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
body: JSON.stringify({ site_id: currentSiteId, path: path, mode: v })
|
||
});
|
||
const d = await r.json();
|
||
if (d.code === 0) {
|
||
showChmodAlert(d.msg || '权限已修改', 'success');
|
||
// 1.2 秒后关闭弹窗 + 刷新列表
|
||
setTimeout(() => { closeChmodDialog(); navigateFiles(currentPath); }, 1200);
|
||
} else {
|
||
showChmodAlert(d.msg || '修改失败', 'error');
|
||
}
|
||
} catch (e) {
|
||
showChmodAlert('网络错误:' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '应用';
|
||
}
|
||
}
|
||
|
||
function showChmodAlert(msg, type) {
|
||
const el = $('chmodAlert');
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
el.style.padding = '8px 12px';
|
||
el.style.borderRadius = '6px';
|
||
el.style.fontSize = '13px';
|
||
if (type === 'success') {
|
||
el.style.background = 'rgba(16, 185, 129, 0.15)';
|
||
el.style.color = 'var(--success)';
|
||
} else {
|
||
el.style.background = 'rgba(239, 68, 68, 0.15)';
|
||
el.style.color = 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
// 实时预览(input 事件)
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const input = document.getElementById('chmodInput');
|
||
if (input) input.addEventListener('input', updateChmodPreview);
|
||
});
|
||
|
||
async function saveFile() {
|
||
const content = await _cmGetContent(); // v1.3.40.1: CM 优先,textarea 兜底
|
||
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);
|
||
}
|
||
}
|
||
// v1.3.40.1: 在第二个 saveFile (admin 版) 定义之前保存原版引用
|
||
const _saveFileSite = saveFile;
|
||
|
||
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);
|
||
}
|
||
|
||
|
||
// =====================
|
||
// v1.3.39+ 任意目录浏览(管理员模式)
|
||
// =====================
|
||
|
||
let currentAdminPath = '/';
|
||
let isAdminMode = false;
|
||
|
||
function toggleAdminMode() {
|
||
isAdminMode = document.getElementById('adminMode').checked;
|
||
document.getElementById('adminPathBar').style.display = isAdminMode ? 'block' : 'none';
|
||
if (isAdminMode) {
|
||
document.getElementById('files-site-select').value = '';
|
||
loadAdminFiles(currentAdminPath);
|
||
}
|
||
}
|
||
|
||
function switchSiteFiles() {
|
||
const siteId = document.getElementById('files-site-select').value;
|
||
if (siteId) {
|
||
document.getElementById('adminMode').checked = false;
|
||
isAdminMode = false;
|
||
document.getElementById('adminPathBar').style.display = 'none';
|
||
currentSiteId = siteId;
|
||
refreshFiles();
|
||
}
|
||
}
|
||
|
||
async function goToPath(path = null) {
|
||
if (path === null) {
|
||
path = document.getElementById('adminPathInput').value;
|
||
} else {
|
||
document.getElementById('adminPathInput').value = path;
|
||
}
|
||
currentAdminPath = path;
|
||
await loadAdminFiles(path);
|
||
}
|
||
|
||
async function loadAdminFiles(path) {
|
||
const tbody = document.getElementById('filesTable');
|
||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;">加载中...</td></tr>';
|
||
const d = await api('/admin/files/list?path=' + encodeURIComponent(path));
|
||
if (d.code !== 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--red);">' + d.msg + '</td></tr>';
|
||
return;
|
||
}
|
||
currentAdminPath = d.data.current_path;
|
||
document.getElementById('adminPathInput').value = currentAdminPath;
|
||
renderAdminBreadcrumb(currentAdminPath);
|
||
if (!d.data.data || d.data.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.data.map(f => {
|
||
const icon = f.type === 'dir' ? '📁' : '📄';
|
||
const onclick = f.type === 'dir' ? 'adminNavigateTo(\'' + f.name + '\')' : 'openAdminFile(\'' + f.name + '\')';
|
||
return '<tr><td><span style="cursor:pointer;' + (f.type === 'dir' ? 'color:var(--green);' : '') + '" onclick="' + onclick + '">' + icon + ' ' + f.name + '</span></td><td>' + f.size_str + '</td><td>' + f.modified + '</td><td><code>' + f.perm_str + '</code></td><td class="actions">' + (f.type !== 'dir' ? '<button class="btn-icon" onclick="openAdminFile(\'' + f.name + '\')">✏️</button>' : '') + '<button class="btn-icon danger" onclick="deleteAdminFile(\'' + f.name + '\', \'' + f.type + '\')">🗑️</button></td></tr>';
|
||
}).join('');
|
||
}
|
||
|
||
function renderAdminBreadcrumb(path) {
|
||
const bc = document.getElementById('fileBreadcrumb');
|
||
const parts = path.split('/').filter(p => p);
|
||
let html = '<span style="cursor:pointer;color:var(--green);" onclick="goToPath(\'/\')">/</span>';
|
||
let acc = '';
|
||
parts.forEach(p => {
|
||
acc += '/' + p;
|
||
html += '<span>›</span><span style="cursor:pointer;color:var(--green);" onclick="goToPath(\'' + acc + '\')">' + p + '</span>';
|
||
});
|
||
bc.innerHTML = html;
|
||
}
|
||
|
||
function adminNavigateTo(dirname) {
|
||
const newPath = currentAdminPath.endsWith('/') ? currentAdminPath + dirname : currentAdminPath + '/' + dirname;
|
||
goToPath(newPath);
|
||
}
|
||
|
||
async function openAdminFile(filename) {
|
||
const fullPath = currentAdminPath.endsWith('/') ? currentAdminPath + filename : currentAdminPath + '/' + filename;
|
||
const d = await api('/admin/files/read?path=' + encodeURIComponent(fullPath));
|
||
if (d.code !== 0) { alert(d.msg); return; }
|
||
document.getElementById('editFilePath').textContent = fullPath;
|
||
document.getElementById('editFilePath').dataset.adminMode = 'true';
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
_cmInitEditor(d.data.data, fullPath, isDark).catch(e => {
|
||
console.warn('CM init failed, fallback:', e);
|
||
document.getElementById('fileEditor').style.display = 'block';
|
||
document.getElementById('fileEditorCm').style.display = 'none';
|
||
document.getElementById('fileEditor').value = d.data.data;
|
||
});
|
||
document.getElementById('fileEditModal').classList.add('show');
|
||
}
|
||
|
||
async function deleteAdminFile(filename, type) {
|
||
const what = type === 'dir' ? '目录' : '文件';
|
||
if (!confirm('确定删除' + what + ' ' + filename + '?此操作不可恢复!')) return;
|
||
const fullPath = currentAdminPath.endsWith('/') ? currentAdminPath + filename : currentAdminPath + '/' + filename;
|
||
const d = await api('/admin/files/delete', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ path: fullPath })
|
||
});
|
||
alert(d.msg);
|
||
if (d.code === 0) loadAdminFiles(currentAdminPath);
|
||
}
|
||
|
||
// v1.3.40.1 修复:用 _saveFileSite 局部变量保存原版 saveFile 引用
|
||
// 之前用 _origSaveFile = window.saveFile 拿到的是 admin 版自己,导致死循环
|
||
async function saveFile() {
|
||
const isAdmin = document.getElementById('editFilePath').dataset.adminMode === 'true';
|
||
const path = document.getElementById('editFilePath').textContent;
|
||
const content = await _cmGetContent(); // v1.3.40.1: CM 优先
|
||
if (isAdmin) {
|
||
const d = await api('/admin/files/write', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ path, content })
|
||
});
|
||
alert(d.msg);
|
||
if (d.code === 0) {
|
||
closeFileEditModal();
|
||
loadAdminFiles(currentAdminPath);
|
||
}
|
||
} else {
|
||
// 非 admin 模式:调用 _saveFileSite(admin 文件加载前绑定的原版)
|
||
return _saveFileSite();
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// v1.3.40.1: CodeMirror 6 文件编辑器
|
||
// =====================
|
||
let _cmEditor = null; // CodeMirror EditorView 实例
|
||
let _cmCurrentLang = null; // 当前语言 extension
|
||
let _cmIsDark = false; // 当前主题状态
|
||
|
||
// 按文件后缀返回 CM language extension
|
||
async function _cmGetLang(filepath) {
|
||
const ext = (filepath.split('.').pop() || '').toLowerCase();
|
||
console.log('[v1.3.40.1 CM] get lang for ext:', ext);
|
||
// CM6 包从 CDN 动态 import(首次会下载,按需)
|
||
try {
|
||
if (ext === 'php') {
|
||
const {php} = await import("https://unpkg.com/@codemirror/lang-php@6.0.2/dist/index.js");
|
||
return [php()];
|
||
}
|
||
if (ext === 'html' || ext === 'htm') {
|
||
const {html} = await import("https://unpkg.com/@codemirror/lang-html@6.4.9/dist/index.js");
|
||
return html();
|
||
}
|
||
if (ext === 'js' || ext === 'mjs' || ext === 'ts') {
|
||
const {javascript} = await import("https://unpkg.com/@codemirror/lang-javascript@6.2.2/dist/index.js");
|
||
return javascript();
|
||
}
|
||
if (ext === 'css') {
|
||
const {css} = await import("https://unpkg.com/@codemirror/lang-css@6.3.0/dist/index.js");
|
||
return css();
|
||
}
|
||
if (ext === 'json') {
|
||
const {json} = await import("https://unpkg.com/@codemirror/lang-json@6.0.1/dist/index.js");
|
||
return json();
|
||
}
|
||
if (ext === 'sql') {
|
||
const {sql} = await import("https://unpkg.com/@codemirror/lang-sql@6.8.0/dist/index.js");
|
||
return sql();
|
||
}
|
||
if (ext === 'py') {
|
||
const {python} = await import("https://unpkg.com/@codemirror/lang-python@6.1.6/dist/index.js");
|
||
return python();
|
||
}
|
||
if (ext === 'md' || ext === 'markdown') {
|
||
const {markdown} = await import("https://unpkg.com/@codemirror/lang-markdown@6.3.1/dist/index.js");
|
||
return markdown();
|
||
}
|
||
if (ext === 'xml' || ext === 'conf' || ext === 'ini' || ext === 'log' || ext === 'yaml' || ext === 'yml') {
|
||
// 这些用 StreamLanguageParser + 简易规则,这里 fallback 到纯文本
|
||
}
|
||
} catch (e) {
|
||
console.warn('CM6 lang load failed:', e);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
async function _cmGetTheme(isDark) {
|
||
if (isDark) {
|
||
try {
|
||
const {oneDark} = await import("https://unpkg.com/@codemirror/theme-one-dark@6.1.2/dist/index.js");
|
||
return oneDark;
|
||
} catch (e) {}
|
||
}
|
||
// light: 用 CM 默认主题(无扩展)+ 我们已有的 CSS 变量
|
||
return [];
|
||
}
|
||
|
||
async function _cmInitEditor(content, filepath, isDark) {
|
||
console.log('[v1.3.40.1 CM] init start:', filepath, 'dark:', isDark, 'content_len:', content.length);
|
||
// 销毁旧实例(如果有)
|
||
if (_cmEditor) {
|
||
try { _cmEditor.destroy(); } catch (e) {}
|
||
_cmEditor = null;
|
||
}
|
||
const container = document.getElementById('fileEditorCm');
|
||
container.innerHTML = '';
|
||
// 同步显示 textarea 兜底 + 隐藏 CM(CM 加载失败时降级)
|
||
const {EditorView, basicSetup} = await import("https://unpkg.com/codemirror@6.0.1/dist/index.js");
|
||
const langExt = await _cmGetLang(filepath);
|
||
const themeExt = await _cmGetTheme(isDark);
|
||
_cmEditor = new EditorView({
|
||
doc: content,
|
||
extensions: [
|
||
basicSetup,
|
||
EditorView.theme({
|
||
'&': { fontSize: '13px', height: '400px' },
|
||
'.cm-scroller': { fontFamily: "'JetBrains Mono', monospace", lineHeight: '1.5' },
|
||
'.cm-content': { padding: '12px' }
|
||
}, { dark: isDark }),
|
||
...langExt,
|
||
...themeExt,
|
||
EditorView.lineWrapping
|
||
],
|
||
parent: container
|
||
});
|
||
console.log('[v1.3.40.1 CM] init done');
|
||
}
|
||
|
||
async function _cmGetContent() {
|
||
if (_cmEditor) {
|
||
return _cmEditor.state.doc.toString();
|
||
}
|
||
return document.getElementById('fileEditor').value;
|
||
}
|
||
|
||
// =====================
|
||
// v1.3.39+ 在线升级
|
||
// =====================
|
||
|
||
async function loadUpgradePage() {
|
||
const d = await api('/upgrade/version');
|
||
if (d.code !== 0) return;
|
||
document.getElementById('currentVersion').textContent = d.data.data.current;
|
||
document.getElementById('latestVersion').textContent = d.data.data.latest;
|
||
document.getElementById('releaseNotes').textContent = d.data.data.release_notes;
|
||
if (d.data.data.has_update) {
|
||
document.getElementById('versionStatus').innerHTML = '<span style="color:var(--green);">✨ 有新版本可用</span>';
|
||
} else {
|
||
document.getElementById('versionStatus').innerHTML = '<span style="color:var(--text-dim);">✅ 已是最新版本</span>';
|
||
}
|
||
renderUpgradeBackupList(d.backups);
|
||
}
|
||
|
||
function renderUpgradeBackupList(backups) {
|
||
const container = document.getElementById('backupList');
|
||
if (!backups || backups.length === 0) {
|
||
container.innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:20px;">暂无备份</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = backups.map(b =>
|
||
'<div style="padding:12px 16px;background:var(--card);border-radius:8px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;"><div><div style="font-weight:600;font-size:14px;">' + b.name + '</div><div style="font-size:12px;color:var(--text-dim);">' + b.mtime + ' · ' + b.size + '</div></div><button class="btn btn-secondary" onclick="rollbackTo(\'' + b.path + '\')">⏪ 回滚</button></div>'
|
||
).join('');
|
||
}
|
||
|
||
function handleUpgradeFile(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
if (!file.name.endsWith('.zip')) { alert('只支持 zip 格式'); return; }
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
fetch('/api/upgrade/upload', { method: 'POST', body: formData, credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.code === 0) {
|
||
document.getElementById('uploadedFileName').textContent = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
|
||
document.getElementById('uploadDropZone').style.display = 'none';
|
||
document.getElementById('uploadedPackage').style.display = 'block';
|
||
} else { alert(d.msg); }
|
||
})
|
||
.catch(e => alert('上传失败: ' + e.message));
|
||
}
|
||
|
||
function cancelUpload() {
|
||
document.getElementById('upgradeFileInput').value = '';
|
||
document.getElementById('uploadDropZone').style.display = 'block';
|
||
document.getElementById('uploadedPackage').style.display = 'none';
|
||
}
|
||
|
||
async function runUpgrade() {
|
||
if (!confirm('确定开始升级?升级过程中请勿关闭页面!')) return;
|
||
document.getElementById('upgradeAlert').className = 'alert show';
|
||
document.getElementById('upgradeAlert').textContent = '⏳ 正在升级...';
|
||
document.getElementById('upgradeAlert').style.display = 'block';
|
||
const d = await api('/upgrade/run', { method: 'POST' });
|
||
if (d.code === 0) {
|
||
document.getElementById('upgradeAlert').className = 'alert show success';
|
||
document.getElementById('upgradeAlert').textContent = '✅ ' + d.msg + ' 页面即将刷新...';
|
||
setTimeout(() => window.location.reload(), 3000);
|
||
} else {
|
||
document.getElementById('upgradeAlert').className = 'alert show error';
|
||
document.getElementById('upgradeAlert').textContent = '❌ ' + d.msg;
|
||
}
|
||
}
|
||
|
||
async function rollbackTo(backupFile) {
|
||
if (!confirm('确定回滚到此版本?')) return;
|
||
document.getElementById('upgradeAlert').textContent = '⏳ 正在回滚...';
|
||
document.getElementById('upgradeAlert').style.display = 'block';
|
||
const d = await api('/upgrade/rollback', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ backup_file: backupFile })
|
||
});
|
||
if (d.code === 0) {
|
||
document.getElementById('upgradeAlert').className = 'alert show success';
|
||
document.getElementById('upgradeAlert').textContent = '✅ ' + d.msg;
|
||
setTimeout(() => window.location.reload(), 3000);
|
||
} else {
|
||
document.getElementById('upgradeAlert').className = 'alert show error';
|
||
document.getElementById('upgradeAlert').textContent = '❌ ' + d.msg;
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// Production / 生产增强功能
|
||
// =====================
|
||
|
||
async function loadProduction() {
|
||
const fw = await api('/firewall/status');
|
||
if (fw.code === 0) {
|
||
const status = fw.data;
|
||
document.getElementById('fwStatus').innerHTML = '<div style="font-size:14px;margin-bottom:10px;"><strong>防火墙类型:</strong>' + (status.type || '未检测到') + ' | <strong>状态:</strong>' + (status.enabled ? '<span style="color:var(--green)">已启用</span>' : '<span style="color:var(--red)">未启用</span>') + '</div>';
|
||
document.getElementById('fwControls').style.display = 'block';
|
||
if (status.enabled) {
|
||
document.getElementById('fwEnableBtn').style.display = 'none';
|
||
document.getElementById('fwEnabledBadge').style.display = 'inline-block';
|
||
if (status.rules && status.rules.length > 0) {
|
||
let html = '<div style="margin-top:10px;"><strong>已开放端口:</strong><ul style="margin:5px 0 0 20px;padding:0;">';
|
||
status.rules.forEach(r => {
|
||
html += '<li style="margin:3px 0;">' + r.port + ' (' + r.action + ') - ' + r.from + ' <button class="btn btn-danger" onclick="closePort(\'' + r.port + '\')" style="margin-left:10px;padding:2px 6px;font-size:11px;">关闭</button></li>';
|
||
});
|
||
html += '</ul></div>';
|
||
document.getElementById('fwRulesList').innerHTML = html;
|
||
}
|
||
} else {
|
||
document.getElementById('fwEnableBtn').style.display = 'inline-block';
|
||
document.getElementById('fwEnabledBadge').style.display = 'none';
|
||
}
|
||
} else {
|
||
document.getElementById('fwStatus').innerHTML = '<div style="color:var(--red);">防火墙状态加载失败: ' + (fw.msg || '') + '</div>';
|
||
}
|
||
|
||
const port = await api('/panel/port');
|
||
if (port.code === 0) {
|
||
document.getElementById('curPanelPort').value = port.data.port;
|
||
}
|
||
|
||
await loadBackupSettings();
|
||
await loadBackupList();
|
||
}
|
||
|
||
async function enableFirewall() {
|
||
if (!confirm('确定启用防火墙?将自动开放 22/80/443/8888 端口。')) return;
|
||
const d = await api('/firewall/enable', { method: 'POST' });
|
||
alert(d.msg);
|
||
loadProduction();
|
||
}
|
||
|
||
async function openPort() {
|
||
const port = document.getElementById('fwPort').value.trim();
|
||
const proto = document.getElementById('fwProto').value;
|
||
if (!port) { alert('请输入端口号'); return; }
|
||
const d = await api('/firewall/port', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ port: port, proto: proto, action: 'open' })
|
||
});
|
||
alert(d.msg);
|
||
if (d.code === 0) {
|
||
document.getElementById('fwPort').value = '';
|
||
loadProduction();
|
||
}
|
||
}
|
||
|
||
async function closePort(port) {
|
||
if (!confirm('确定关闭端口 ' + port + '?')) return;
|
||
const d = await api('/firewall/port', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ port: port, proto: 'tcp', action: 'close' })
|
||
});
|
||
alert(d.msg);
|
||
loadProduction();
|
||
}
|
||
|
||
async function changePanelPort() {
|
||
const newPort = document.getElementById('newPanelPort').value.trim();
|
||
if (!newPort) { alert('请输入新端口'); return; }
|
||
if (!confirm('确定将面板端口修改为 ' + newPort + '?修改后需要使用新端口重新访问!')) return;
|
||
const d = await api('/panel/port', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ port: newPort })
|
||
});
|
||
alert(d.msg);
|
||
if (d.code === 0) {
|
||
setTimeout(() => {
|
||
window.location.href = window.location.protocol + '//' + window.location.hostname + ':' + newPort;
|
||
}, 2000);
|
||
}
|
||
}
|
||
|
||
async function loadBackupSettings() {
|
||
const d = await api('/backup/settings');
|
||
if (d.code === 0) {
|
||
const s = d.data;
|
||
document.getElementById('backupEnabled').value = s.enabled ? '1' : '0';
|
||
document.getElementById('backupSchedule').value = s.schedule || 'daily';
|
||
document.getElementById('backupKeepDays').value = s.keep_days || 7;
|
||
document.getElementById('backupDir').value = s.backup_dir || '/backup';
|
||
}
|
||
}
|
||
|
||
async function saveBackupSettings() {
|
||
const enabled = document.getElementById('backupEnabled').value === '1';
|
||
const schedule = document.getElementById('backupSchedule').value;
|
||
const keepDays = document.getElementById('backupKeepDays').value;
|
||
const backupDir = document.getElementById('backupDir').value.trim();
|
||
const d = await api('/backup/settings', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ enabled: enabled, schedule: schedule, keep_days: keepDays, backup_dir: backupDir })
|
||
});
|
||
alert(d.msg);
|
||
loadBackupSettings();
|
||
}
|
||
|
||
async function runBackupNow() {
|
||
if (!confirm('确定立即执行一次备份?')) return;
|
||
const d = await api('/backup/run', { method: 'POST' });
|
||
alert(d.msg);
|
||
setTimeout(loadBackupList, 3000);
|
||
}
|
||
|
||
async function loadBackupList() {
|
||
const d = await api('/backup/list');
|
||
if (d.code === 0) {
|
||
const data = d.data;
|
||
let html = '<div style="margin-bottom:10px;font-size:14px;"><strong>备份目录:</strong>' + data.backup_dir + ' | <strong>总大小:</strong>' + data.total_size + '</div><div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">';
|
||
html += '<div><h4 style="margin:0 0 10px 0;font-size:14px;">📁 站点备份</h4>';
|
||
if (data.sites.length === 0) {
|
||
html += '<div style="color:var(--text-dim);font-size:13px;">暂无备份</div>';
|
||
} else {
|
||
data.sites.forEach(f => {
|
||
html += '<div style="padding:6px 10px;background:var(--card-hover);border-radius:4px;margin-bottom:5px;font-size:13px;display:flex;justify-content:space-between;align-items:center;"><span>' + f.name + ' <span style="color:var(--text-dim)">(' + f.size + ')</span></span><button class="btn btn-danger" onclick="deleteBackup(\'sites\', \'' + f.name + '\')" style="padding:2px 6px;font-size:11px;">删除</button></div>';
|
||
});
|
||
}
|
||
html += '</div>';
|
||
html += '<div><h4 style="margin:0 0 10px 0;font-size:14px;">🗄️ 数据库备份</h4>';
|
||
if (data.databases.length === 0) {
|
||
html += '<div style="color:var(--text-dim);font-size:13px;">暂无备份</div>';
|
||
} else {
|
||
data.databases.forEach(f => {
|
||
html += '<div style="padding:6px 10px;background:var(--card-hover);border-radius:4px;margin-bottom:5px;font-size:13px;display:flex;justify-content:space-between;align-items:center;"><span>' + f.name + ' <span style="color:var(--text-dim)">(' + f.size + ')</span></span><button class="btn btn-danger" onclick="deleteBackup(\'databases\', \'' + f.name + '\')" style="padding:2px 6px;font-size:11px;">删除</button></div>';
|
||
});
|
||
}
|
||
html += '</div></div>';
|
||
document.getElementById('backupList').innerHTML = html;
|
||
}
|
||
}
|
||
|
||
async function deleteBackup(type, filename) {
|
||
if (!confirm('确定删除备份 ' + filename + '?')) return;
|
||
const d = await api('/backup/delete', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ type: type, filename: filename })
|
||
});
|
||
alert(d.msg);
|
||
loadBackupList();
|
||
}
|
||
|
||
|
||
|
||
|
||
checkAuth();
|
||
</script>
|
||
</body>
|
||
</html> |