功能: - 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配 - 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt) - 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测) - 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理 技术栈: Go + Gin + GORM + SQLite
298 lines
10 KiB
HTML
298 lines
10 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>{{ .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>
|