Files
ToNav-go/templates/admin/dashboard.html
T
openclaw 909387ef52 feat: MCP 管理进后台 UI (v2.1) — 开关/Token 即时生效无需重启
- 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 同步
2026-09-11 14:46:47 +08:00

322 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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>