feat: redesign admin interface

This commit is contained in:
2026-09-15 06:41:26 +08:00
parent 32daa27712
commit 68e3d0bfce
6 changed files with 73 additions and 896 deletions
+52 -347
View File
@@ -1,353 +1,58 @@
<!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>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Overview - ToNav</title><link rel="stylesheet" href="/static/css/admin.css">
<script>document.documentElement.classList.toggle('dark',localStorage.getItem('tonav-theme')==='dark');</script>
</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="copyMCPToken(this)" style="padding:8px 14px; white-space:nowrap">📋 复制</button>
<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>
<div class="admin-shell">
<aside class="admin-sidebar">
<div class="admin-brand">ToNav</div>
<div class="admin-label">OVERVIEW</div>
<a class="admin-nav active" href="/admin/dashboard">Overview</a>
<div class="admin-label" style="margin-top:22px">SERVICES</div>
<a class="admin-nav" href="/admin/services">Services</a>
<a class="admin-nav" href="/admin/categories">Categories</a>
<div class="admin-label" style="margin-top:22px">SYSTEM</div>
<a class="admin-nav" href="#settings" onclick="showSettings();return false">Settings</a>
<div class="admin-sidebar-foot"><a href="/">← Open navigation</a><br><a href="/admin/logout">Sign out</a></div>
</aside>
<main class="admin-content"><div class="admin-main">
<header class="admin-topbar"><div><h1>Overview</h1><div class="admin-sub">Service directory and system status</div></div><div class="top-actions"><button class="btn" onclick="toggleTheme()">Theme</button><a class="btn" href="/">Open site</a></div></header>
<section class="section"><div class="section-title">System status</div>
<div class="metric-strip"><div class="metric"><div class="metric-value">{{ .service_count }}</div><div class="metric-label">Services</div></div><div class="metric"><div class="metric-value">{{ .category_count }}</div><div class="metric-label">Categories</div></div><div class="metric"><div class="metric-value green">{{ .online_count }}</div><div class="metric-label">Online</div></div><div class="metric"><div class="metric-value red">{{ .offline_count }}</div><div class="metric-label">Offline</div></div></div></section>
<section class="section"><div class="section-title">Quick actions</div><div class="panel action-list">
<a class="action" href="/admin/services"><strong>Services</strong><span>Manage service entries</span></a>
<a class="action" href="/admin/categories"><strong>Categories</strong><span>Organize your directory</span></a>
<a class="action" href="#settings" onclick="showSettings();return false"><strong>Settings</strong><span>Site, backup and MCP</span></a>
<div class="action" onclick="runBackup()"><strong>Backup</strong><span>Run cloud backup now</span></div>
</div></section>
</div></main></div>
<div id="settingsModal" class="modal-overlay"><div class="modal-content">
<div class="modal-head"><h2>System settings</h2><button class="close" onclick="closeSettings()">×</button></div>
<div class="form-group"><label>Site title</label><input id="cfg_site_title" class="input"></div>
<div class="form-group"><label>WebDAV URL</label><input id="cfg_url" class="input"></div>
<div class="form-row"><div class="form-group"><label>Username</label><input id="cfg_user" class="input"></div><div class="form-group"><label>Password</label><input id="cfg_pass" type="password" class="input"></div></div>
<div class="form-group"><label><input type="checkbox" id="cfg_auto_backup"> Daily backup at 03:00</label></div>
<div class="section-title">MCP Server</div><div class="notice"><label><input type="checkbox" id="cfg_mcp_enabled" onchange="toggleMCP()"> Enable MCP endpoint</label><div id="mcpTokenRow" style="display:none;margin-top:12px"><label>Token</label><div class="form-row"><input id="cfg_mcp_token" class="input" readonly><button class="btn" onclick="copyMCPToken(this)">Copy</button><button class="btn" onclick="regenMCPToken()">Regenerate</button></div><div style="margin-top:6px">POST/GET /mcp · changes apply immediately</div></div></div>
<div class="section-title" style="margin-top:20px">Cloud backups</div><div class="panel" style="padding:12px"><div id="backupList" class="admin-sub">Loading...</div></div>
<div class="modal-actions"><button class="btn" onclick="closeSettings()">Close</button><button class="btn btn-primary" onclick="saveSettings()">Save changes</button></div>
</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 copyMCPToken(btn) {
try {
const resp = await fetch('/admin/api/mcp/token');
const res = await resp.json();
if (!res.success) { alert('❌ ' + (res.message || 'Token 未生成')); return; }
const token = res.token;
let copied = false;
try {
await navigator.clipboard.writeText(token);
copied = true;
} catch (e) {
// 非 HTTPS/localhost 环境降级方案
const ta = document.createElement('textarea');
ta.value = token;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus(); ta.select();
try { copied = document.execCommand('copy'); } catch (e2) {}
document.body.removeChild(ta);
}
if (copied) {
const old = btn.textContent;
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.textContent = old; }, 1500);
} else {
prompt('自动复制不可用,请手动复制 Token:', token);
}
} catch(e) { alert('获取 Token 失败'); }
}
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>
function toggleTheme(){const dark=!document.documentElement.classList.contains('dark');document.documentElement.classList.toggle('dark',dark);localStorage.setItem('tonav-theme',dark?'dark':'light')}
function closeSettings(){document.getElementById('settingsModal').style.display='none'}
async function showSettings(){const r=await fetch('/admin/api/settings'),d=await r.json();cfg_site_title.value=d.site_title||'';cfg_url.value=d.webdav_url||'';cfg_user.value=d.webdav_user||'';cfg_pass.value=d.webdav_password||'';cfg_auto_backup.checked=d.auto_backup==='true';document.getElementById('settingsModal').style.display='flex';loadMCPStatus();loadCloudBackups()}
async function loadMCPStatus(){try{const r=await fetch('/admin/api/mcp/status'),d=await r.json();cfg_mcp_enabled.checked=d.enabled;mcpTokenRow.style.display=d.enabled?'block':'none';cfg_mcp_token.value=d.token_masked||'Not generated'}catch(e){}}
async function toggleMCP(){const enabled=cfg_mcp_enabled.checked;try{const r=await fetch('/admin/api/mcp/enabled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})}),d=await r.json();if(!d.success)throw Error(d.message)}catch(e){alert(e.message||'Failed');cfg_mcp_enabled.checked=!enabled;mcpTokenRow.style.display=enabled?'none':'block'}}
async function copyMCPToken(btn){try{const r=await fetch('/admin/api/mcp/token'),d=await r.json();if(!d.success)throw Error(d.message);await navigator.clipboard.writeText(d.token);const old=btn.textContent;btn.textContent='Copied';setTimeout(()=>btn.textContent=old,1200)}catch(e){alert('Copy failed: '+e.message)}}
async function regenMCPToken(){if(!confirm('Regenerate token? The old token will stop working.'))return;const r=await fetch('/admin/api/mcp/token',{method:'POST'}),d=await r.json();if(d.success){alert('New token:\n\n'+d.token);loadMCPStatus()}else alert(d.message)}
async function loadCloudBackups(){const el=backupList;try{const r=await fetch('/admin/api/backup/list'),d=await r.json();if(!d.files?.length){el.textContent='No cloud backups';return}el.innerHTML=d.files.map(f=>`<div style="padding:8px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:10px"><span>${f.name}<small style="display:block;color:var(--faint)">${formatTime(f.mod_time)} · ${formatSize(f.size)}</small></span><span><button class="btn" onclick="restoreBackup('${encodeURIComponent(f.name)}')">Restore</button> <button class="btn btn-danger" onclick="deleteBackup('${encodeURIComponent(f.name)}')">Delete</button></span></div>`).join('')}catch(e){el.textContent='Unable to load backups'}}
function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
function formatTime(s){if(!s)return'';const d=new Date(s);return isNaN(d)?s:d.toLocaleString('zh-CN')}
async function saveSettings(){const data={site_title:cfg_site_title.value,webdav_url:cfg_url.value,webdav_user:cfg_user.value,webdav_password:cfg_pass.value,auto_backup:cfg_auto_backup.checked?'true':'false'};const r=await fetch('/admin/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}),d=await r.json();alert(d.message||'Saved')}
async function runBackup(){if(!confirm('Run cloud backup now?'))return;const r=await fetch('/admin/api/backup/webdav',{method:'POST'}),d=await r.json();alert(d.message||'Backup complete')}
async function restoreBackup(name){name=decodeURIComponent(name);if(!confirm('Restore '+name+'? Current database will be backed up first.'))return;const r=await fetch('/admin/api/backup/restore?name='+encodeURIComponent(name),{method:'POST'}),d=await r.json();alert(d.message||'Done');if(d.success)location.reload()}
async function deleteBackup(name){name=decodeURIComponent(name);if(!confirm('Delete '+name+'?'))return;const r=await fetch('/admin/api/backup/delete?name='+encodeURIComponent(name),{method:'DELETE'}),d=await r.json();if(d.success)loadCloudBackups();else alert(d.message)}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeSettings()});
</script></body></html>