- settings 表新增 mcp_enabled / mcp_token,鉴权中间件动态读库,后台改动即时生效(实测验证:关->503、开->200、换 token 旧401新200,均不重启) - 首次启动自动把 TONAV_MCP_TOKEN 环境变量迁移进数据库(兼容旧部署,systemd 现有 token 无感切换) - 后台新增 API:GET /admin/api/mcp/status(token 掩码)、POST /admin/api/mcp/token(重新生成+自动启用,仅一次明文展示)、POST /admin/api/mcp/enabled - 后台设置弹窗新增 MCP Server 区块:开关 checkbox、token 掩码展示、一键重新生成 - README 同步
322 lines
16 KiB
HTML
322 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ToNav 管理后台</title>
|
||
<style>
|
||
:root { --main-red: #ff4d4f; --primary: #667eea; }
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: "PingFang SC", -apple-system, sans-serif; background: #f0f2f5; min-height: 100vh; padding: 20px; }
|
||
a { text-decoration: none; color: inherit; }
|
||
.container { max-width: 900px; margin: 0 auto; }
|
||
.admin-header { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 25px 30px; border-radius: 20px; margin-bottom: 25px; }
|
||
.header-actions { display: flex; gap: 10px; }
|
||
.btn { padding: 10px 20px; border-radius: 10px; border: none; cursor: pointer; font-weight: 600; display: inline-block; font-size: 14px; transition: all .2s; }
|
||
.btn:hover { transform: translateY(-1px); }
|
||
.btn-light { background: rgba(255,255,255,0.2); color: #fff; }
|
||
.btn-light:hover { background: rgba(255,255,255,0.3); }
|
||
.btn-primary { background: var(--main-red); color: #fff; }
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; }
|
||
.stat-card { background: #fff; border-radius: 16px; padding: 25px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: transform .2s; }
|
||
.stat-card:hover { transform: translateY(-3px); }
|
||
.stat-value { font-size: 36px; font-weight: 700; color: #262626; }
|
||
.stat-label { font-size: 14px; color: #8c8c8c; margin-top: 5px; }
|
||
.stat-online .stat-value { color: #52c41a; }
|
||
.stat-offline .stat-value { color: #ff4d4f; }
|
||
.quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; }
|
||
.action-btn { background: #fff; border-radius: 16px; padding: 30px 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; transition: all .2s; font-size: 15px; font-weight: 500; border: none; }
|
||
.action-btn:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.12); }
|
||
.input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 8px; box-sizing: border-box; margin-bottom: 12px; font-size: 14px; }
|
||
.input:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||
|
||
/* 弹窗 */
|
||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 100; }
|
||
.modal-content { background: #fff; padding: 30px; border-radius: 20px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; }
|
||
|
||
@media (max-width: 640px) {
|
||
body { padding: 12px; }
|
||
.admin-header { flex-direction: column; gap: 15px; text-align: center; padding: 20px; }
|
||
.admin-header h1 { font-size: 20px; }
|
||
.header-actions { flex-wrap: wrap; justify-content: center; }
|
||
.header-actions .btn { padding: 8px 14px; font-size: 13px; }
|
||
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.stat-card { padding: 18px 10px; }
|
||
.stat-value { font-size: 28px; }
|
||
.stat-label { font-size: 12px; }
|
||
.quick-actions { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.action-btn { padding: 20px 10px; font-size: 14px; }
|
||
.modal-content { padding: 20px; border-radius: 16px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="admin-header">
|
||
<h1>🧭 ToNav 管理后台</h1>
|
||
<div class="header-actions">
|
||
<a href="/" class="btn btn-light">查看前台</a>
|
||
<a href="/admin/change-password" class="btn btn-light">修改密码</a>
|
||
<a href="/admin/logout" class="btn btn-primary">退出登录</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{ .service_count }}</div>
|
||
<div class="stat-label">总服务数</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{ .category_count }}</div>
|
||
<div class="stat-label">分类数</div>
|
||
</div>
|
||
<div class="stat-card stat-online">
|
||
<div class="stat-value">{{ .online_count }}</div>
|
||
<div class="stat-label">在线服务</div>
|
||
</div>
|
||
<div class="stat-card stat-offline">
|
||
<div class="stat-value">{{ .offline_count }}</div>
|
||
<div class="stat-label">离线服务</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="quick-actions">
|
||
<a href="/admin/services" class="action-btn">
|
||
<div style="font-size:32px; margin-bottom:10px">📡</div>
|
||
<div>服务管理</div>
|
||
</a>
|
||
<a href="/admin/categories" class="action-btn">
|
||
<div style="font-size:32px; margin-bottom:10px">📂</div>
|
||
<div>分类管理</div>
|
||
</a>
|
||
<div class="action-btn" onclick="runBackup()">
|
||
<div style="font-size:32px; margin-bottom:10px">☁️</div>
|
||
<div>立即云备份</div>
|
||
</div>
|
||
<div class="action-btn" onclick="showSettings()">
|
||
<div style="font-size:32px; margin-bottom:10px">⚙️</div>
|
||
<div>系统设置</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 系统设置弹窗 -->
|
||
<div id="settingsModal" class="modal-overlay">
|
||
<div class="modal-content">
|
||
<h2 style="margin-bottom:20px">⚙️ 系统设置 & 云端备份</h2>
|
||
|
||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||
<h3 style="font-size:14px; margin-bottom:10px">站点配置</h3>
|
||
<label style="font-size:12px; color:#666">站点标题</label>
|
||
<input type="text" id="cfg_site_title" class="input" placeholder="ToNav">
|
||
</div>
|
||
|
||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||
<h3 style="font-size:14px; margin-bottom:10px">WebDAV 配置</h3>
|
||
<label style="font-size:12px; color:#666">WebDAV URL</label>
|
||
<input type="text" id="cfg_url" class="input">
|
||
<label style="font-size:12px; color:#666">用户名</label>
|
||
<input type="text" id="cfg_user" class="input">
|
||
<label style="font-size:12px; color:#666">密码</label>
|
||
<input type="password" id="cfg_pass" class="input">
|
||
</div>
|
||
|
||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||
<h3 style="font-size:14px; margin-bottom:10px">自动备份</h3>
|
||
<div style="display:flex; align-items:center; gap:8px">
|
||
<input type="checkbox" id="cfg_auto_backup" style="width:16px; height:16px">
|
||
<label for="cfg_auto_backup" style="margin:0; font-size:13px">每天凌晨 3:00 自动备份到云端</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||
<h3 style="font-size:14px; margin-bottom:10px">🤖 MCP Server</h3>
|
||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px">
|
||
<input type="checkbox" id="cfg_mcp_enabled" style="width:16px; height:16px" onchange="toggleMCP()">
|
||
<label for="cfg_mcp_enabled" style="margin:0; font-size:13px">启用 MCP 接口(AI 可通过它管理导航页)</label>
|
||
</div>
|
||
<div id="mcpTokenRow" style="display:none">
|
||
<label style="font-size:12px; color:#666">Token</label>
|
||
<div style="display:flex; gap:8px; align-items:center">
|
||
<input type="text" id="cfg_mcp_token" class="input" readonly style="flex:1; background:#fff; color:#555">
|
||
<button class="btn btn-primary" onclick="regenMCPToken()" style="padding:8px 14px; white-space:nowrap">🔄 重新生成</button>
|
||
</div>
|
||
<div style="font-size:11px; color:#999; margin-top:6px">
|
||
端点: <code>POST/GET /mcp</code>(Streamable HTTP + SSE)· 改动即时生效,无需重启
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button>
|
||
|
||
<div style="padding:15px; border:1px solid #eee; border-radius:10px">
|
||
<h3 style="font-size:14px; margin-bottom:10px">📜 云端备份历史</h3>
|
||
<div id="backupList" style="font-size:13px; color:#666">正在获取列表...</div>
|
||
</div>
|
||
|
||
<button class="btn" onclick="document.getElementById('settingsModal').style.display='none'" style="background:#eee; width:100%; margin-top:15px">关闭</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
async function showSettings() {
|
||
const resp = await fetch('/admin/api/settings');
|
||
const data = await resp.json();
|
||
document.getElementById('cfg_site_title').value = data.site_title || '';
|
||
document.getElementById('cfg_url').value = data.webdav_url || '';
|
||
document.getElementById('cfg_user').value = data.webdav_user || '';
|
||
document.getElementById('cfg_pass').value = data.webdav_password || '';
|
||
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
|
||
document.getElementById('settingsModal').style.display = 'flex';
|
||
loadMCPStatus();
|
||
loadCloudBackups();
|
||
}
|
||
|
||
async function loadMCPStatus() {
|
||
try {
|
||
const resp = await fetch('/admin/api/mcp/status');
|
||
const d = await resp.json();
|
||
const cb = document.getElementById('cfg_mcp_enabled');
|
||
const row = document.getElementById('mcpTokenRow');
|
||
cb.checked = d.enabled;
|
||
row.style.display = d.enabled ? 'block' : 'none';
|
||
document.getElementById('cfg_mcp_token').value = d.token_masked || '(未生成,点重新生成创建)';
|
||
} catch(e) {}
|
||
}
|
||
|
||
function toggleMCP() {
|
||
const enabled = document.getElementById('cfg_mcp_enabled').checked;
|
||
document.getElementById('mcpTokenRow').style.display = enabled ? 'block' : 'none';
|
||
fetch('/admin/api/mcp/enabled', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({enabled: enabled})
|
||
}).then(r => r.json()).then(res => {
|
||
if (!res.success) {
|
||
alert(res.message);
|
||
document.getElementById('cfg_mcp_enabled').checked = !enabled;
|
||
document.getElementById('mcpTokenRow').style.display = enabled ? 'none' : 'block';
|
||
}
|
||
});
|
||
}
|
||
|
||
async function regenMCPToken() {
|
||
if (!confirm('确定重新生成 Token?\n\n旧 Token 将立即失效,已接入的 AI 客户端需要更新为新 Token。')) return;
|
||
try {
|
||
const resp = await fetch('/admin/api/mcp/token', {method: 'POST'});
|
||
const res = await resp.json();
|
||
if (res.success) {
|
||
alert('✅ 新 Token 已生成(仅此一次明文展示,请立即复制):\n\n' + res.token);
|
||
loadMCPStatus();
|
||
} else {
|
||
alert('❌ ' + res.message);
|
||
}
|
||
} catch(e) { alert('生成失败'); }
|
||
}
|
||
|
||
async function loadCloudBackups() {
|
||
const listDiv = document.getElementById('backupList');
|
||
try {
|
||
const resp = await fetch('/admin/api/backup/list');
|
||
const data = await resp.json();
|
||
if (data.files && data.files.length > 0) {
|
||
listDiv.innerHTML = data.files.map(f => `
|
||
<div style="padding:10px 0; border-bottom:1px solid #f0f0f0">
|
||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||
<div>
|
||
<div style="font-weight:500; color:#333">${f.name}</div>
|
||
<div style="font-size:11px; color:#999; margin-top:2px">
|
||
${formatTime(f.mod_time)}${f.size ? ' · ' + formatSize(f.size) : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:6px">
|
||
<button onclick="restoreBackup('${f.name}')" style="background:none; border:1px solid #1890ff; color:#1890ff; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:12px">恢复</button>
|
||
<button onclick="deleteBackup('${f.name}')" style="background:none; border:1px solid #ff4d4f; color:#ff4d4f; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:12px">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
listDiv.innerHTML = '暂无云端备份';
|
||
}
|
||
} catch(e) { listDiv.innerHTML = '获取失败'; }
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (!bytes || bytes === 0) return '';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||
}
|
||
|
||
function formatTime(str) {
|
||
if (!str) return '';
|
||
try {
|
||
const d = new Date(str);
|
||
if (isNaN(d.getTime())) return str;
|
||
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||
} catch(e) { return str; }
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const data = {
|
||
site_title: document.getElementById('cfg_site_title').value,
|
||
webdav_url: document.getElementById('cfg_url').value,
|
||
webdav_user: document.getElementById('cfg_user').value,
|
||
webdav_password: document.getElementById('cfg_pass').value,
|
||
auto_backup: document.getElementById('cfg_auto_backup').checked ? 'true' : 'false'
|
||
};
|
||
const resp = await fetch('/admin/api/settings', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(data)
|
||
});
|
||
const res = await resp.json();
|
||
alert(res.message || '保存成功');
|
||
}
|
||
|
||
async function runBackup() {
|
||
if (!confirm('确定执行云端备份?')) return;
|
||
const btn = event.currentTarget;
|
||
const oldContent = btn.innerHTML;
|
||
btn.innerHTML = '<div style="font-size:32px; margin-bottom:10px">⌛</div><div>正在备份...</div>';
|
||
btn.style.pointerEvents = 'none';
|
||
try {
|
||
const resp = await fetch('/admin/api/backup/webdav', {method: 'POST'});
|
||
const res = await resp.json();
|
||
alert(res.message);
|
||
} catch(e) { alert('备份失败'); }
|
||
btn.innerHTML = oldContent;
|
||
btn.style.pointerEvents = '';
|
||
}
|
||
|
||
async function restoreBackup(name) {
|
||
if (!confirm(`⚠️ 确定从「${name}」恢复?\n\n当前数据库会先自动备份,然后被覆盖为所选版本。`)) return;
|
||
try {
|
||
const resp = await fetch(`/admin/api/backup/restore?name=${encodeURIComponent(name)}`, {method: 'POST'});
|
||
const res = await resp.json();
|
||
if (res.success) {
|
||
alert('✅ ' + res.message + '\n\n页面将刷新...');
|
||
location.reload();
|
||
} else {
|
||
alert('❌ ' + res.message);
|
||
}
|
||
} catch(e) { alert('恢复失败'); }
|
||
}
|
||
|
||
async function deleteBackup(name) {
|
||
if (!confirm(`确定删除云端备份「${name}」?此操作不可撤销。`)) return;
|
||
try {
|
||
const resp = await fetch(`/admin/api/backup/delete?name=${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||
const res = await resp.json();
|
||
if (res.success) {
|
||
loadCloudBackups();
|
||
} else {
|
||
alert(res.message);
|
||
}
|
||
} catch(e) { alert('删除失败'); }
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|