tpanel/frontend/index_v13401.html
root 79981cde4a Release v1.3.44
✨ v1.3.44 更新内容:
- 🔧 修复:强制刷新页面后链接点不了的 bug
- 修复:DOMContentLoaded 中添加 checkAuth() 调用,自动验证登录态
2026-06-28 18:16:03 +08:00

3788 lines
No EOL
137 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>T面板 - Linux 网站管理面板</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://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>&nbsp;</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>