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

3120 lines
No EOL
107 KiB
HTML
Raw 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://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.30 默认改亮色主题(背景白、侧边栏深色保证对比) */
--bg: #f8fafc;
--sidebar-bg: #0f172a;
--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;
}
/* v1.3.30 暗色主题(点 🌙 按钮启用) */
:root.dark {
--bg: #0a0e1a;
--sidebar-bg: #0f172a;
--topbar-bg: #0f172a;
--card: #1a2332;
--card-hover: #1e293b;
--border: #2a3548;
--text: #e2e8f0;
--text-dim: #64748b;
--text-muted: #94a3b8;
}
/* 主题切换过渡 */
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;
}
/* Sidebar - v1.3.32 (kept, 始终白色背景不随主题变化) */
.sidebar {
width: 220px;
min-height: 100vh;
background: var(--sidebar-bg, #0f172a); /* v1.3.35: 默认暗色(light 主题也是暗色 sidebar,v1.3.30+ 设计) */
color: #e2e8f0; /* v1.3.35: 暗色 sidebar 上的默认文字色(子元素单独控制) */
border-right: 1px solid var(--border, #e2e8f0);
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 #e2e8f0;
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: #ffffff !important; /* v1.3.35: 暗色 sidebar 上的 logo 文字 */
}
.sidebar-nav {
padding: 12px 8px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: #cbd5e1 !important; /* v1.3.35 修复:暗色 sidebar 上是亮灰,不在白底了 */
text-decoration: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 2px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.08); /* v1.3.35: 暗色 sidebar 上的 hover 背景 */
color: #ffffff !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: #64748b; /* v1.3.35: 暗色 sidebar 上的分组标签颜色 */
text-transform: uppercase;
letter-spacing: 0.08em;
}
.sidebar-footer {
padding: 16px 12px;
border-top: 1px solid rgba(255,255,255,0.1); /* v1.3.35: 暗色 sidebar 上的边框 */
}
.sidebar-footer .version {
font-size: 12px;
color: #94a3b8 !important; /* v1.3.35: 暗色 sidebar 上的版本号文字 */
}
/* 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="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.39</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;">
<h2 style="font-size:18px;font-weight:700;">📁 文件管理</h2>
<div style="display:flex;gap:8px;">
<select id="files-site-select" style="padding:8px 12px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;">
<option value="">-- 选择站点 --</option>
</select>
<button class="btn btn-secondary" onclick="refreshFiles()">↻ 刷新</button>
<button class="btn btn-secondary" onclick="openMkdirModal()">+ 新建目录</button>
<button class="btn btn-secondary" onclick="openUploadModal()">↑ 上传</button>
</div>
</div>
<!-- Breadcrumb -->
<div id="fileBreadcrumb" style="display:flex;align-items:center;gap:6px;margin-bottom:16px;font-size:13px;color:var(--text-dim);">
<span style="cursor:pointer;color:var(--green);" onclick="navigateFiles('')">/</span>
</div>
<!-- File list -->
<div class="panel" style="padding:0;overflow:hidden;">
<table class="data-table">
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th>权限</th>
<th>操作</th>
</tr>
</thead>
<tbody id="filesTable"></tbody>
</table>
</div>
<!-- File editor modal -->
<div class="modal-overlay" id="fileEditModal">
<div class="modal" style="width:800px;max-width:95vw;">
<div class="modal-header">
<span class="modal-title">✏️ 编辑文件</span>
<button class="modal-close" onclick="closeFileEditModal()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px;font-size:13px;color:var(--text-dim);" id="editFilePath"></div>
<textarea id="fileEditor" style="width:100%;min-height:400px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:'JetBrains Mono',monospace;font-size:13px;padding:14px;resize:vertical;box-sizing:border-box;" spellcheck="false"></textarea>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeFileEditModal()">取消</button>
<button class="btn btn-primary" onclick="saveFile()">保存</button>
</div>
</div>
</div>
<!-- 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>
<!-- 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('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 = document.getElementById('fileEditor').value;
const d = await api('/files/write', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(currentSiteId), path: editingFilePath, content })
});
if (d.code === 0) {
closeFileEditModal();
loadFileList();
} else {
showAlert('filesAlert', d.msg);
}
}
async function deleteFileItem(name) {
if (!confirm('确定删除 ' + name + '?此操作不可恢复!')) return;
const filePath = currentPath ? currentPath + '/' + name : name;
const d = await api('/files/delete', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(currentSiteId), path: filePath })
});
if (d.code === 0) loadFileList();
else showAlert('filesAlert', d.msg);
}
function openMkdirModal() {
if (!currentSiteId) { showAlert('filesAlert', '请先选择站点'); return; }
document.getElementById('mkdirModal').classList.add('show');
}
function closeMkdirModal() { document.getElementById('mkdirModal').classList.remove('show'); }
async function doMkdir() {
const name = document.getElementById('mkdir-name').value.trim();
if (!name) { showAlert('filesAlert', '请输入目录名称'); return; }
const d = await api('/files/mkdir', {
method: 'POST',
body: JSON.stringify({ site_id: parseInt(currentSiteId), path: currentPath, name })
});
if (d.code === 0) { closeMkdirModal(); loadFileList(); }
else showAlert('filesAlert', d.msg);
}
function openUploadModal() {
if (!currentSiteId) { showAlert('filesAlert', '请先选择站点'); return; }
document.getElementById('uploadModal').classList.add('show');
}
function closeUploadModal() { document.getElementById('uploadModal').classList.remove('show'); }
function handleFileSelect(input) {
if (!input.files || !input.files[0]) return;
doUpload(input.files[0]);
input.value = '';
}
async function doUpload(file) {
const formData = new FormData();
formData.append('site_id', currentSiteId);
formData.append('path', currentPath);
formData.append('file', file);
document.getElementById('uploadProgress').style.display = 'block';
document.getElementById('uploadFileName').textContent = '上传中:' + file.name;
document.getElementById('uploadBar').style.width = '30%';
const r = await fetch(API + '/files/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const d = await r.json();
document.getElementById('uploadBar').style.width = '100%';
setTimeout(() => {
document.getElementById('uploadProgress').style.display = 'none';
document.getElementById('uploadBar').style.width = '0%';
closeUploadModal();
if (d.code === 0) {
showAlert('filesAlert', d.msg);
loadFileList();
} else {
showAlert('filesAlert', d.msg);
}
}, 500);
}
function refreshFiles() { loadFileList(); }
function initFileManager() {
loadFileSites();
document.getElementById('files-site-select').value && (currentSiteId = document.getElementById('files-site-select').value);
}
// =====================
// SSL UI
// =====================
async function loadSSL() {
const d = await api('/ssl/certs');
const tbody = document.getElementById('sslTable');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-dim);">暂无证书 <button class="btn btn-secondary" style="margin-left:12px;" onclick="openSSLModal()">+ 申请证书</button></td></tr>';
return;
}
tbody.innerHTML = d.data.map(s => {
const days = s.days_left;
const dayClass = days < 0 ? 'red' : days <= 7 ? 'yellow' : 'green';
const expire = days < 0 ? '已过期' : days === 0 ? '今日到期' : '剩余 ' + days + ' 天';
return `<tr>
<td class="domain">${s.domain}</td>
<td style="font-size:12px;">${s.cert_path}</td>
<td><span class="badge ${dayClass}">${expire}</span></td>
<td><span class="badge ${s.auto_renew ? 'green' : 'yellow'}">${s.auto_renew ? '已启用' : '未启用'}</span></td>
<td class="actions">
<button class="btn-icon" onclick="renewSingleSSL(${s.id})" title="续期">🔄</button>
<button class="btn-icon" onclick="deploySSL('${s.domain}')" title="部署HTTPS">🔐</button>
<button class="btn-icon danger" onclick="deleteSSL(${s.id})">🗑️</button>
</td>
</tr>`;
}).join('');
// Update security page SSL status
if (d.status) {
const sec = d.status;
document.getElementById('sec-updatable').textContent = sec.total + ' 张证书';
}
}
async function renewSingleSSL(id) {
const d = await api('/ssl/renew/' + id, { method: 'POST' });
alert(d.msg);
loadSSL();
}
async function deploySSL(domain) {
const d = await api('/ssl/deploy/' + domain, { method: 'POST' });
alert(d.msg);
loadSSL();
}
async function deleteSSL(id) {
if (!confirm('确定删除此证书?')) return;
// TODO: add delete API
alert('删除功能开发中');
}
// =====================
// Cron / Auto tasks
// =====================
async function runCronTasks() {
if (!confirm('确定执行定时任务(安全更新 + SSL 续期)?')) return;
const d = await api('/cron/run', { method: 'POST' });
alert(d.msg);
loadSecurity();
loadDashboard();
}
// =====================
// Cron Jobs UI
// =====================
let cronSiteOptions = '';
async function loadCronJobs() {
const d = await api('/cron/jobs');
const sites = await api('/sites');
const tbody = document.getElementById('cronTable');
// Build site options for modals
cronSiteOptions = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
if (!d.data || d.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-dim);">暂无定时任务 <button class="btn btn-secondary" style="margin-left:12px;" onclick="openCronModal()">+ 创建</button></td></tr>';
return;
}
tbody.innerHTML = d.data.map(j => {
const enabled = j.enabled;
const lastRun = j.last_run ? j.last_run.slice(0, 16).replace('T', ' ') : '-';
return `<tr>
<td style="font-weight:600;">${j.name}</td>
<td><span class="badge blue">${j.domain || '系统'}</span></td>
<td><code style="font-family:'JetBrains Mono',monospace;background:var(--green-dim);padding:2px 8px;border-radius:4px;">${j.schedule}</code></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--text-dim);" title="${j.command}">${j.command}</td>
<td><span class="badge ${enabled ? 'green' : 'yellow'}">${enabled ? '启用' : '禁用'}</span></td>
<td style="font-size:12px;color:var(--text-dim);">${lastRun}</td>
<td class="actions">
<button class="btn-icon" onclick="runCronNow(${j.id})" title="立即执行">▶️</button>
<button class="btn-icon" onclick="toggleCron(${j.id}, ${!enabled})" title="${enabled ? '暂停' : '启用'}">${enabled ? '⏸️' : '▶️'}</button>
<button class="btn-icon danger" onclick="deleteCron(${j.id})" title="删除">🗑️</button>
</td>
</tr>`;
}).join('');
}
function openCronModal() {
document.getElementById('cronModalAlert').style.display = 'none';
document.getElementById('cron-name').value = '';
document.getElementById('cron-site').innerHTML = cronSiteOptions;
document.getElementById('cron-schedule').value = '0 3 * * *';
document.getElementById('cron-command').value = '';
document.getElementById('cronModal').classList.add('show');
}
function closeCronModal() { document.getElementById('cronModal').classList.remove('show'); }
async function createCron() {
const name = document.getElementById('cron-name').value.trim();
const site_id = parseInt(document.getElementById('cron-site').value);
const schedule = document.getElementById('cron-schedule').value.trim();
const command = document.getElementById('cron-command').value.trim();
if (!name || !site_id || !schedule || !command) {
const al = document.getElementById('cronModalAlert');
al.textContent = '请填写所有字段';
al.style.display = 'block';
return;
}
const d = await api('/cron/jobs', {
method: 'POST',
body: JSON.stringify({ site_id, name, schedule, command })
});
if (d.code === 0) {
closeCronModal();
loadCronJobs();
} else {
const al = document.getElementById('cronModalAlert');
al.textContent = d.msg;
al.style.display = 'block';
}
}
async function runCronNow(id) {
if (!confirm('确定立即执行此任务?')) return;
const d = await api('/cron/jobs/' + id + '/run', { method: 'POST' });
alert(d.msg);
loadCronJobs();
}
async function toggleCron(id, enabled) {
const d = await api('/cron/jobs/' + id + '/toggle', {
method: 'POST',
body: JSON.stringify({ enabled })
});
if (d.code === 0) loadCronJobs();
else alert(d.msg);
}
async function deleteCron(id) {
if (!confirm('确定删除此定时任务?')) return;
const d = await api('/cron/jobs/' + id, { method: 'DELETE' });
if (d.code === 0) loadCronJobs();
else alert(d.msg);
}
// =====================
// Remote Backup
// =====================
let backupSiteOptions = '';
async function loadBackupStats() {
const d = await api('/backups/stats');
if (d.code === 0 && d.data) {
document.getElementById('stat-backups').textContent = d.data.total_backups + ' 份';
}
}
async function openRemoteBackupModal() {
const sites = await api('/sites');
const sel = document.getElementById('backup-remote-site');
sel.innerHTML = '<option value="">-- 选择站点 --</option>' +
(sites.data || []).map(s => `<option value="${s.id}">${s.domain}</option>`).join('');
document.getElementById('backupModal').classList.add('show');
}
async function doRemoteBackup() {
const site_id = parseInt(document.getElementById('backup-remote-site').value);
const host = document.getElementById('remote-host').value.trim();
const user = document.getElementById('remote-user').value.trim();
const port = parseInt(document.getElementById('remote-port').value) || 22;
const path = document.getElementById('remote-path').value.trim();
const key = document.getElementById('remote-key').value.trim();
if (!site_id || !host || !user || !path) {
showAlert('backupModalAlert', '请填写所有必填字段');
return;
}
closeBackupModal();
const al = document.getElementById('backupAlert');
al.textContent = '正在执行远程备份,请稍候...';
al.className = 'alert show success';
document.getElementById('backupAlert').style.display = 'block';
const d = await api('/backups/remote', {
method: 'POST',
body: JSON.stringify({ site_id, remote_host: host, remote_user: user, remote_port: port, remote_path: path, key_path: key })
});
al.textContent = d.msg;
al.className = 'alert show ' + (d.code === 0 ? 'success' : 'error');
setTimeout(() => { al.className = 'alert'; al.style.display = 'none'; }, 5000);
loadBackups();
}
async function testRemote() {
const host = document.getElementById('remote-host').value.trim();
const port = parseInt(document.getElementById('remote-port').value) || 22;
const user = document.getElementById('remote-user').value.trim();
const key = document.getElementById('remote-key').value.trim();
const d = await api('/backups/remote/test', {
method: 'POST',
body: JSON.stringify({ host, port, user, key_path: key })
});
alert(d.msg);
}
// =====================
// Start
// =====================
checkAuth();
</script>
</body>
</html>