feat: ToNav-go v1.0.0 - 内部服务导航系统
功能: - 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配 - 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt) - 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测) - 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理 技术栈: Go + Gin + GORM + SQLite
This commit is contained in:
176
templates/admin/categories.html
Normal file
176
templates/admin/categories.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!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; padding: 20px; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
.container { max-width: 900px; margin: 0 auto; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 25px; border-radius: 15px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: bold; display: inline-block; font-size: 14px; transition: all .2s; }
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn-primary { background: var(--main-red); color: #fff; }
|
||||
.btn-back { background: #eee; color: #333; }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
.list-card { background: #fff; border-radius: 15px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.item { padding: 15px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; transition: background .15s; }
|
||||
.item:hover { background: #fafafa; }
|
||||
.item:last-child { border-bottom: none; }
|
||||
.item-info { display: flex; align-items: center; gap: 12px; }
|
||||
.item-order { background: #f0f0f0; border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #999; }
|
||||
.item-name { font-weight: 600; font-size: 16px; }
|
||||
.item-actions { display: flex; gap: 8px; }
|
||||
.item-actions button { background: none; border: none; cursor: pointer; padding: 5px 10px; border-radius: 6px; font-size: 13px; transition: background .15s; }
|
||||
.btn-edit { color: var(--primary); }
|
||||
.btn-edit:hover { background: rgba(102,126,234,0.1); }
|
||||
.btn-del { color: var(--main-red); }
|
||||
.btn-del:hover { background: rgba(255,77,79,0.1); }
|
||||
.empty { text-align: center; padding: 40px; color: #999; }
|
||||
|
||||
/* 弹窗 */
|
||||
.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: 450px; }
|
||||
.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-actions { display: flex; gap: 10px; margin-top: 15px; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 12px; }
|
||||
.header { flex-direction: column; gap: 12px; text-align: center; padding: 15px; }
|
||||
.header h1 { font-size: 20px; }
|
||||
.header-actions { width: 100%; }
|
||||
.header-actions .btn { flex: 1; text-align: center; }
|
||||
.item { flex-direction: column; align-items: flex-start; gap: 10px; padding: 12px; }
|
||||
.item-actions { align-self: flex-end; }
|
||||
.modal-content { padding: 20px; border-radius: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📂 分类管理</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 新增分类</button>
|
||||
<a href="/admin/dashboard" class="btn btn-back">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-card" id="list">
|
||||
<div class="empty">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<div id="modal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<h2 id="modalTitle" style="margin-bottom:20px">新增分类</h2>
|
||||
<input type="hidden" id="editId">
|
||||
<label style="font-size:13px; color:#666; margin-bottom:4px; display:block">分类名称</label>
|
||||
<input type="text" id="catName" class="input" placeholder="如:内网服务" autofocus>
|
||||
<label style="font-size:13px; color:#666; margin-bottom:4px; display:block">排序(数字越大越靠前)</label>
|
||||
<input type="number" id="catOrder" class="input" placeholder="0" value="0">
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:#eee" onclick="closeModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="save()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function load() {
|
||||
const res = await fetch('/admin/api/categories');
|
||||
const data = await res.json();
|
||||
const list = document.getElementById('list');
|
||||
if (!data.data || data.data.length === 0) {
|
||||
list.innerHTML = '<div class="empty">暂无分类,点击上方"+ 新增分类"添加</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = data.data.map(c => `
|
||||
<div class="item">
|
||||
<div class="item-info">
|
||||
<span class="item-order">${c.sort_order}</span>
|
||||
<span class="item-name">${escapeHTML(c.name)}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn-edit" onclick='edit(${JSON.stringify(c)})'>编辑</button>
|
||||
<button class="btn-del" onclick="del(${c.id}, '${escapeHTML(c.name)}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openModal(id, name, order) {
|
||||
document.getElementById('editId').value = id || '';
|
||||
document.getElementById('catName').value = name || '';
|
||||
document.getElementById('catOrder').value = order || 0;
|
||||
document.getElementById('modalTitle').textContent = id ? '编辑分类' : '新增分类';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
document.getElementById('catName').focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function edit(c) {
|
||||
openModal(c.id, c.name, c.sort_order);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const id = document.getElementById('editId').value;
|
||||
const name = document.getElementById('catName').value.trim();
|
||||
const order = parseInt(document.getElementById('catOrder').value) || 0;
|
||||
|
||||
if (!name) { alert('分类名称不能为空'); return; }
|
||||
|
||||
const body = { name, sort_order: order };
|
||||
if (id) body.id = parseInt(id);
|
||||
|
||||
const url = id ? `/admin/api/categories/${id}` : '/admin/api/categories';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.success) {
|
||||
closeModal();
|
||||
load();
|
||||
} else {
|
||||
alert(res.message || '保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function del(id, name) {
|
||||
if (!confirm(`确定删除分类「${name}」?`)) return;
|
||||
const resp = await fetch('/admin/api/categories/' + id, { method: 'DELETE' });
|
||||
const res = await resp.json();
|
||||
if (res.success) {
|
||||
load();
|
||||
} else {
|
||||
alert(res.message || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ESC 关闭弹窗
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
45
templates/admin/change_password.html
Normal file
45
templates/admin/change_password.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!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; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "PingFang SC", -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.card { background: #fff; padding: 40px 30px; border-radius: 20px; width: 100%; max-width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
|
||||
.card h2 { text-align: center; margin-bottom: 20px; font-size: 22px; }
|
||||
.input { width: 100%; padding: 12px; margin: 8px 0 16px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 15px; -webkit-appearance: none; }
|
||||
.input:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||
.btn { width: 100%; padding: 13px; background: #ff4d4f; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; margin-top: 10px; font-size: 16px; -webkit-appearance: none; }
|
||||
.btn:active { transform: scale(0.98); }
|
||||
.error-box { background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; padding: 10px; border-radius: 8px; margin-bottom: 16px; text-align: center; font-size: 14px; }
|
||||
.alert { background: #e6f7ff; border: 1px solid #91d5ff; color: #1890ff; padding: 10px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; line-height: 1.5; }
|
||||
label { font-size: 14px; color: #333; font-weight: 500; }
|
||||
@media (max-width: 420px) {
|
||||
.card { padding: 30px 20px; border-radius: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>🔐 修改密码</h2>
|
||||
<div class="alert">⚠️ 首次登录请先修改初始密码以启用管理功能。</div>
|
||||
|
||||
{{ if .error }}
|
||||
<div class="error-box">{{ .error }}</div>
|
||||
{{ end }}
|
||||
|
||||
<form method="POST">
|
||||
<label>当前密码</label>
|
||||
<input type="password" name="old_password" placeholder="输入当前密码" class="input" required autocomplete="current-password">
|
||||
<label>新密码</label>
|
||||
<input type="password" name="new_password" placeholder="至少6位" class="input" required minlength="6" autocomplete="new-password">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" name="confirm_password" placeholder="再次输入新密码" class="input" required autocomplete="new-password">
|
||||
<button type="submit" class="btn">提交修改</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
260
templates/admin/dashboard.html
Normal file
260
templates/admin/dashboard.html
Normal file
@@ -0,0 +1,260 @@
|
||||
<!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>
|
||||
|
||||
<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';
|
||||
loadCloudBackups();
|
||||
}
|
||||
|
||||
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>
|
||||
40
templates/admin/login.html
Normal file
40
templates/admin/login.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!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; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "PingFang SC", -apple-system, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.login-card { background: #fff; border-radius: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); max-width: 400px; width: 100%; padding: 40px 30px; }
|
||||
.login-card h1 { text-align: center; margin-bottom: 30px; font-size: 28px; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 14px; color: #333; font-weight: 500; }
|
||||
.input { width: 100%; padding: 12px; border: 1px solid #d9d9d9; border-radius: 10px; box-sizing: border-box; margin-bottom: 18px; font-size: 15px; -webkit-appearance: none; }
|
||||
.input:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 2px rgba(102,126,234,0.2); }
|
||||
.btn { width: 100%; padding: 13px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; background: var(--main-red); color: #fff; font-size: 16px; -webkit-appearance: none; }
|
||||
.btn:active { transform: scale(0.98); }
|
||||
.error-box { background: #fff2f0; border: 1px solid #ffccc7; color: #ff4d4f; padding: 10px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-size: 14px; }
|
||||
@media (max-width: 420px) {
|
||||
.login-card { padding: 30px 20px; border-radius: 16px; }
|
||||
.login-card h1 { font-size: 24px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>🧭 ToNav</h1>
|
||||
{{ if .error }}
|
||||
<div class="error-box">{{ .error }}</div>
|
||||
{{ end }}
|
||||
<form method="POST">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" class="input" required autofocus autocomplete="username">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" class="input" required autocomplete="current-password">
|
||||
<button type="submit" class="btn">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
289
templates/admin/services.html
Normal file
289
templates/admin/services.html
Normal file
@@ -0,0 +1,289 @@
|
||||
<!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; padding: 20px; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 25px; border-radius: 15px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: bold; display: inline-block; font-size: 14px; transition: all .2s; }
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn-primary { background: var(--main-red); color: #fff; }
|
||||
.btn-back { background: #eee; color: #333; }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
.table-card { background: #fff; border-radius: 15px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #fafafa; font-size: 13px; color: #999; text-align: left; padding: 12px 15px; font-weight: 500; }
|
||||
td { padding: 12px 15px; border-bottom: 1px solid #f5f5f5; font-size: 14px; }
|
||||
tr:hover td { background: #fafafa; }
|
||||
.status-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.status-online { background: #f6ffed; color: #52c41a; }
|
||||
.status-offline { background: #fff2f0; color: #ff4d4f; }
|
||||
.btn-edit { color: var(--primary); background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
||||
.btn-edit:hover { background: rgba(102,126,234,0.1); }
|
||||
.btn-del { color: var(--main-red); background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; }
|
||||
.btn-del:hover { background: rgba(255,77,79,0.1); }
|
||||
.empty { text-align: center; padding: 40px; color: #999; }
|
||||
.icon-cell { font-size: 24px; }
|
||||
|
||||
/* 弹窗 */
|
||||
.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: 550px; max-height: 85vh; overflow-y: auto; }
|
||||
.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); }
|
||||
select.input { appearance: none; -webkit-appearance: none; background: #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat right 12px center/12px; }
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row > div { flex: 1; }
|
||||
label { font-size: 13px; color: #666; margin-bottom: 4px; display: block; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.checkbox-row input[type="checkbox"] { width: 16px; height: 16px; }
|
||||
.modal-actions { display: flex; gap: 10px; margin-top: 15px; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 12px; }
|
||||
.header { flex-direction: column; gap: 12px; text-align: center; padding: 15px; }
|
||||
.header h1 { font-size: 20px; }
|
||||
.header-actions { width: 100%; }
|
||||
.header-actions .btn { flex: 1; text-align: center; }
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
/* 手机端隐藏状态和排序列 */
|
||||
th:nth-child(3), td:nth-child(3),
|
||||
th:nth-child(4), td:nth-child(4),
|
||||
th:nth-child(5), td:nth-child(5) { display: none; }
|
||||
th, td { padding: 10px 8px; font-size: 13px; }
|
||||
.icon-cell { font-size: 20px; }
|
||||
.modal-content { padding: 20px; border-radius: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📡 服务管理</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 新增服务</button>
|
||||
<a href="/admin/dashboard" class="btn btn-back">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>图标</th>
|
||||
<th>名称</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th>排序</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="list"></tbody>
|
||||
</table>
|
||||
<div id="emptyMsg" class="empty" style="display:none">暂无服务,点击上方"+ 新增服务"添加</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<div id="modal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<h2 id="modalTitle" style="margin-bottom:20px">新增服务</h2>
|
||||
<input type="hidden" id="editId">
|
||||
|
||||
<label>服务名称 *</label>
|
||||
<input type="text" id="svcName" class="input" placeholder="如:Gitea" required>
|
||||
|
||||
<label>服务地址 *</label>
|
||||
<input type="url" id="svcUrl" class="input" placeholder="https://example.com">
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>图标(emoji)</label>
|
||||
<input type="text" id="svcIcon" class="input" placeholder="🔧" maxlength="4">
|
||||
</div>
|
||||
<div>
|
||||
<label>所属分类</label>
|
||||
<select id="svcCategory" class="input">
|
||||
<option value="0">-- 无分类 --</option>
|
||||
{{ range .categories }}
|
||||
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>描述</label>
|
||||
<input type="text" id="svcDesc" class="input" placeholder="简短描述">
|
||||
|
||||
<label>标签(逗号分隔)</label>
|
||||
<input type="text" id="svcTags" class="input" placeholder="开发,工具">
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>排序</label>
|
||||
<input type="number" id="svcOrder" class="input" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<label>健康检查 URL</label>
|
||||
<input type="url" id="svcHealthUrl" class="input" placeholder="可选">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="svcEnabled" checked>
|
||||
<label for="svcEnabled" style="margin:0">启用</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="svcHealthEnabled">
|
||||
<label for="svcHealthEnabled" style="margin:0">启用健康检查</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn" style="background:#eee" onclick="closeModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="save()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 分类映射
|
||||
const categoryMap = {};
|
||||
{{ range .categories }}
|
||||
categoryMap[{{ .ID }}] = "{{ .Name }}";
|
||||
{{ end }}
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/admin/api/services');
|
||||
const data = await res.json();
|
||||
const list = document.getElementById('list');
|
||||
const emptyMsg = document.getElementById('emptyMsg');
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
list.innerHTML = '';
|
||||
emptyMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
emptyMsg.style.display = 'none';
|
||||
|
||||
list.innerHTML = data.data.map(s => `
|
||||
<tr>
|
||||
<td class="icon-cell">${s.icon || '🔗'}</td>
|
||||
<td><strong>${escapeHTML(s.name)}</strong></td>
|
||||
<td>${categoryMap[s.category_id] || '-'}</td>
|
||||
<td><span class="status-badge status-${s.status}">${s.status === 'online' ? '在线' : '离线'}</span></td>
|
||||
<td>${s.sort_order}</td>
|
||||
<td>
|
||||
<button class="btn-edit" onclick='editSvc(${JSON.stringify(s)})'>编辑</button>
|
||||
<button class="btn-del" onclick="del(${s.id}, '${escapeHTML(s.name)}')">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
document.getElementById('editId').value = '';
|
||||
document.getElementById('svcName').value = '';
|
||||
document.getElementById('svcUrl').value = '';
|
||||
document.getElementById('svcIcon').value = '';
|
||||
document.getElementById('svcCategory').value = '0';
|
||||
document.getElementById('svcDesc').value = '';
|
||||
document.getElementById('svcTags').value = '';
|
||||
document.getElementById('svcOrder').value = '0';
|
||||
document.getElementById('svcHealthUrl').value = '';
|
||||
document.getElementById('svcEnabled').checked = true;
|
||||
document.getElementById('svcHealthEnabled').checked = false;
|
||||
document.getElementById('modalTitle').textContent = '新增服务';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
document.getElementById('svcName').focus();
|
||||
}
|
||||
|
||||
function editSvc(s) {
|
||||
document.getElementById('editId').value = s.id;
|
||||
document.getElementById('svcName').value = s.name;
|
||||
document.getElementById('svcUrl').value = s.url;
|
||||
document.getElementById('svcIcon').value = s.icon || '';
|
||||
document.getElementById('svcCategory').value = s.category_id || '0';
|
||||
document.getElementById('svcDesc').value = s.description || '';
|
||||
document.getElementById('svcTags').value = s.tags || '';
|
||||
document.getElementById('svcOrder').value = s.sort_order || 0;
|
||||
document.getElementById('svcHealthUrl').value = s.health_check_url || '';
|
||||
document.getElementById('svcEnabled').checked = s.is_enabled;
|
||||
document.getElementById('svcHealthEnabled').checked = s.health_check_enabled;
|
||||
document.getElementById('modalTitle').textContent = '编辑服务';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const id = document.getElementById('editId').value;
|
||||
const name = document.getElementById('svcName').value.trim();
|
||||
const url = document.getElementById('svcUrl').value.trim();
|
||||
|
||||
if (!name) { alert('服务名称不能为空'); return; }
|
||||
if (!url) { alert('服务地址不能为空'); return; }
|
||||
|
||||
const body = {
|
||||
name,
|
||||
url,
|
||||
icon: document.getElementById('svcIcon').value.trim(),
|
||||
category_id: parseInt(document.getElementById('svcCategory').value) || 0,
|
||||
description: document.getElementById('svcDesc').value.trim(),
|
||||
tags: document.getElementById('svcTags').value.trim(),
|
||||
sort_order: parseInt(document.getElementById('svcOrder').value) || 0,
|
||||
health_check_url: document.getElementById('svcHealthUrl').value.trim(),
|
||||
is_enabled: document.getElementById('svcEnabled').checked,
|
||||
health_check_enabled: document.getElementById('svcHealthEnabled').checked,
|
||||
};
|
||||
if (id) body.id = parseInt(id);
|
||||
|
||||
const apiUrl = id ? `/admin/api/services/${id}` : '/admin/api/services';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const resp = await fetch(apiUrl, {
|
||||
method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.success) {
|
||||
closeModal();
|
||||
load();
|
||||
} else {
|
||||
alert(res.message || '保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function del(id, name) {
|
||||
if (!confirm(`确定删除服务「${name}」?`)) return;
|
||||
const resp = await fetch('/admin/api/services/' + id, { method: 'DELETE' });
|
||||
const res = await resp.json();
|
||||
if (res.success) {
|
||||
load();
|
||||
} else {
|
||||
alert(res.message || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
297
templates/index.html
Normal file
297
templates/index.html
Normal file
@@ -0,0 +1,297 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .site_title }} - 导航服务</title>
|
||||
<style>
|
||||
:root {
|
||||
--main-red: #ff4d4f;
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--header-gradient: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--primary-gradient);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
a { text-decoration: none; color: inherit; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
|
||||
/* 头部 */
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: #fff;
|
||||
padding: 25px 20px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 { font-size: 24px; font-weight: 700; margin-bottom: 5px; }
|
||||
.subtitle { font-size: 13px; color: #8c8c8c; margin-bottom: 10px; }
|
||||
.status-bar { font-size: 12px; color: #595959; }
|
||||
|
||||
/* 搜索栏 */
|
||||
.search-bar {
|
||||
background: #262626;
|
||||
padding: 15px 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 10px;
|
||||
padding: 10px 15px 10px 40px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.search-bar input:focus {
|
||||
outline: none;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-color: var(--main-red);
|
||||
}
|
||||
.search-bar input::placeholder { color: #8c8c8c; }
|
||||
.search-icon { position: absolute; left: 35px; color: #8c8c8c; }
|
||||
|
||||
/* 分类 Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #262626;
|
||||
padding: 8px;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
padding: 12px 10px;
|
||||
color: #8c8c8c;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-btn:hover { color: #bfbfbf; background: rgba(255,255,255,0.05); }
|
||||
.tab-btn.active {
|
||||
color: #fff;
|
||||
background: var(--main-red);
|
||||
box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4);
|
||||
}
|
||||
|
||||
/* 服务卡片网格 */
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
padding: 20px 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
.service-card {
|
||||
background: #f7f7f8;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
animation: fadeInUp 0.5s ease backwards;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-4px);
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||
border-color: rgba(102,126,234,0.3);
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.card-icon { font-size: 30px; margin-right: 14px; flex-shrink: 0; }
|
||||
.card-info { min-width: 0; flex: 1; }
|
||||
.card-name {
|
||||
font-size: 16px; font-weight: 600; color: #262626;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 13px; color: #8c8c8c; margin-top: 4px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
display: inline-block; flex-shrink: 0;
|
||||
}
|
||||
.status-dot.online {
|
||||
background: #52c41a;
|
||||
box-shadow: 0 0 6px rgba(82,196,26,0.4);
|
||||
}
|
||||
.status-dot.offline {
|
||||
background: #f5222d;
|
||||
box-shadow: 0 0 6px rgba(245,34,45,0.4);
|
||||
}
|
||||
.status-dot.unknown {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.footer {
|
||||
background: var(--header-gradient);
|
||||
color: #8c8c8c;
|
||||
padding: 20px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.footer a { color: var(--main-red); transition: opacity 0.3s; }
|
||||
.footer a:hover { opacity: 0.8; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body { padding: 10px; }
|
||||
.services-grid { grid-template-columns: 1fr; }
|
||||
.tab-btn { font-size: 13px; padding: 10px 8px; }
|
||||
.header { padding: 20px 15px; border-radius: 16px 16px 0 0; }
|
||||
.footer { border-radius: 0 0 16px 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧭 {{ .site_title }}</h1>
|
||||
<div class="subtitle">个人导航站</div>
|
||||
<div class="status-bar"><span id="lastCheckTime">加载中...</span></div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="搜索服务或描述..." oninput="handleSearch()">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="categoryTabs"></div>
|
||||
|
||||
<div class="services-grid" id="servicesGrid">
|
||||
<div class="empty-state">加载中...</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>© 2026 ToNav - <a href="/admin">管理后台</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 服务端渲染的数据
|
||||
const categoriesData = {{ .categories_json }};
|
||||
const servicesData = {{ .services_json }};
|
||||
|
||||
// 构建分类ID → 名称映射
|
||||
const categoryMap = {};
|
||||
categoriesData.forEach(c => { categoryMap[c.id] = c.name; });
|
||||
|
||||
let currentTab = 'all';
|
||||
let currentKeyword = '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
renderTabs();
|
||||
renderServices('all');
|
||||
updateLastCheckTime();
|
||||
});
|
||||
|
||||
function renderTabs() {
|
||||
const container = document.getElementById('categoryTabs');
|
||||
let html = '<button class="tab-btn active" data-category="all">全部</button>';
|
||||
categoriesData.forEach(c => {
|
||||
html += `<button class="tab-btn" data-category="${c.name}">${c.name}</button>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
currentTab = this.dataset.category;
|
||||
container.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
renderServices(currentTab);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentKeyword = document.getElementById('searchInput').value.toLowerCase();
|
||||
renderServices(currentTab);
|
||||
}
|
||||
|
||||
function renderServices(category) {
|
||||
const container = document.getElementById('servicesGrid');
|
||||
|
||||
let filtered = category === 'all'
|
||||
? servicesData
|
||||
: servicesData.filter(s => categoryMap[s.category_id] === category);
|
||||
|
||||
if (currentKeyword) {
|
||||
filtered = filtered.filter(s =>
|
||||
s.name.toLowerCase().includes(currentKeyword) ||
|
||||
(s.description && s.description.toLowerCase().includes(currentKeyword)) ||
|
||||
(s.tags && s.tags.toLowerCase().includes(currentKeyword))
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无服务</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map((s, i) => {
|
||||
const statusClass = s.status === 'online' ? 'online' : (s.status === 'offline' ? 'offline' : 'unknown');
|
||||
|
||||
return `
|
||||
<a href="${escapeAttr(s.url)}" target="_blank" class="service-card" style="animation-delay: ${i * 0.05}s">
|
||||
<div class="card-icon">${s.icon || '🔗'}</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">${escapeHTML(s.name)} <span class="status-dot ${statusClass}"></span></div>
|
||||
${s.description ? `<div class="card-desc">${escapeHTML(s.description)}</div>` : ''}
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
function escapeAttr(str) {
|
||||
return (str||'').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function updateLastCheckTime() {
|
||||
const now = new Date();
|
||||
document.getElementById('lastCheckTime').textContent = '最后更新: ' +
|
||||
now.toLocaleDateString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
|
||||
setInterval(() => { location.reload(); }, 30000);
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden) location.reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user