Files
Nexus/web/app/layout.js
T
Your Name d4ea03d735 P2: 全局搜索功能 — 后端API + 前端侧边栏搜索
- 新增 /api/search/ 端点: 跨服务器/脚本/凭据/调度搜索
- layout.js: 侧边栏底部搜索框, 300ms防抖, 下拉结果面板
- main.py: 注册搜索路由
2026-05-22 09:08:09 +08:00

132 lines
7.1 KiB
JavaScript

// Nexus — Shared Sidebar + Layout Component
// Usage: Include after Alpine.js. Call initLayout() on each page.
// Sets sidebar, highlights active nav item, loads user info.
function initLayout(activeNav) {
// activeNav: 'index'|'servers'|'files'|'push'|'scripts'|'credentials'|'schedules'|'retries'|'audit'|'settings'
const navItems = [
{ id:'index', icon:'🏠', label:'仪表盘', href:'/app/index.html' },
{ id:'servers', icon:'🖥', label:'服务器', href:'/app/servers.html' },
{ id:'files', icon:'📁', label:'文件管理', href:'/app/files.html' },
{ id:'push', icon:'📤', label:'推送', href:'/app/push.html' },
{ id:'scripts', icon:'📜', label:'脚本库', href:'/app/scripts.html' },
{ id:'credentials', icon:'🔑', label:'凭据', href:'/app/credentials.html' },
{ id:'schedules', icon:'⏰', label:'调度', href:'/app/schedules.html' },
{ id:'retries', icon:'🔄', label:'重试队列', href:'/app/retries.html' },
];
const bottomNav = [
{ id:'audit', icon:'📋', label:'审计日志', href:'/app/audit.html' },
{ id:'settings', icon:'⚙️', label:'设置', href:'/app/settings.html' },
];
// Build sidebar HTML
const navHtml = navItems.map(n =>
`<a href="${n.href}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition ${n.id===activeNav?'bg-brand/10 text-brand-light font-medium':'text-slate-400 hover:text-white hover:bg-slate-800'}">${n.icon} ${n.label}</a>`
).join('');
const bottomHtml = bottomNav.map(n =>
`<a href="${n.href}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition ${n.id===activeNav?'bg-brand/10 text-brand-light font-medium':'text-slate-400 hover:text-white hover:bg-slate-800'}">${n.icon} ${n.label}</a>`
).join('');
// Find sidebar container
const sidebar = document.querySelector('[data-sidebar]');
if (!sidebar) return;
sidebar.innerHTML = `
<div class="px-5 py-4 border-b border-slate-800">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center">
<svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/>
</svg>
</div>
<span id="brandName" class="text-white font-bold text-lg">Nexus</span>
</div>
</div>
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
${navHtml}
<div class="border-t border-slate-800 my-2"></div>
${bottomHtml}
</nav>
<div class="border-t border-slate-800 p-4">
<!-- Global Search -->
<div class="relative mb-3">
<input id="globalSearchInput" type="text" placeholder="搜索..." class="w-full px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand" oninput="debounceSearch(this.value)">
<div id="searchResults" class="hidden absolute bottom-full left-0 right-0 mb-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl max-h-64 overflow-y-auto z-50"></div>
</div>
<div class="flex items-center justify-between">
<span id="sidebarUser" class="text-slate-400 text-sm">...</span>
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
</div>
</div>
`;
// Load user info
loadLayoutUser();
}
async function loadLayoutUser() {
try {
const r = await apiFetch(API + '/auth/me');
if (!r) { doLogout(); return; }
const u = await r.json();
const el = document.getElementById('sidebarUser');
if (el) el.textContent = u.username;
const headerEl = document.getElementById('headerUser');
if (headerEl) headerEl.textContent = u.username;
const brandEl = document.getElementById('brandName');
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
} catch(e) {}
}
// ── Global Search ──
let _searchTimer = null;
function debounceSearch(q) {
clearTimeout(_searchTimer);
const panel = document.getElementById('searchResults');
if (!q || q.length < 2) { panel.classList.add('hidden'); return; }
_searchTimer = setTimeout(() => doGlobalSearch(q), 300);
}
async function doGlobalSearch(q) {
try {
const r = await apiFetch(API + '/search/?q=' + encodeURIComponent(q));
if (!r) return;
const data = await r.json();
const panel = document.getElementById('searchResults');
if (!data.total) { panel.innerHTML = '<div class="p-3 text-slate-500 text-sm">无匹配结果</div>'; panel.classList.remove('hidden'); return; }
let html = '';
if (data.servers.length) {
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase">服务器</div>';
html += data.servers.map(s => `<a href="/app/servers.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span class="${s.is_online?'text-green-400':'text-red-400'}">●</span><span class="text-white text-sm">${_esc(s.name)}</span><span class="text-slate-500 text-xs">${_esc(s.domain)}</span></a>`).join('');
}
if (data.scripts.length) {
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase border-t border-slate-700">脚本</div>';
html += data.scripts.map(s => `<a href="/app/scripts.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span class="text-brand-light">📜</span><span class="text-white text-sm">${_esc(s.name)}</span><span class="text-slate-500 text-xs">${_esc(s.category||'')}</span></a>`).join('');
}
if (data.credentials.length) {
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase border-t border-slate-700">凭据</div>';
html += data.credentials.map(c => `<a href="/app/credentials.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span>🔑</span><span class="text-white text-sm">${_esc(c.name)}</span><span class="text-slate-500 text-xs">${_esc(c.host)}</span></a>`).join('');
}
if (data.schedules.length) {
html += '<div class="px-3 py-1.5 text-slate-500 text-xs uppercase border-t border-slate-700">调度</div>';
html += data.schedules.map(s => `<a href="/app/schedules.html" class="flex items-center gap-2 px-3 py-2 hover:bg-slate-700 transition"><span>⏰</span><span class="text-white text-sm">${_esc(s.name)}</span><span class="text-slate-500 text-xs font-mono">${_esc(s.cron_expr)}</span></a>`).join('');
}
panel.innerHTML = html;
panel.classList.remove('hidden');
} catch(e) {}
}
// Close search on click outside
document.addEventListener('click', (e) => {
const panel = document.getElementById('searchResults');
const input = document.getElementById('globalSearchInput');
if (panel && !panel.contains(e.target) && e.target !== input) {
panel.classList.add('hidden');
}
});
function _esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }