P2: 全局搜索功能 — 后端API + 前端侧边栏搜索
- 新增 /api/search/ 端点: 跨服务器/脚本/凭据/调度搜索 - layout.js: 侧边栏底部搜索框, 300ms防抖, 下拉结果面板 - main.py: 注册搜索路由
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
"""Nexus — Global Search API Route
|
||||||
|
Cross-entity search across servers, scripts, credentials, and schedules.
|
||||||
|
All operations require JWT authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from server.api.dependencies import get_db
|
||||||
|
from server.api.auth_jwt import get_current_admin
|
||||||
|
from server.domain.models import Admin, Server, Script, DbCredential, PushSchedule
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=dict)
|
||||||
|
async def global_search(
|
||||||
|
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Global search across servers, scripts, credentials, and schedules.
|
||||||
|
|
||||||
|
Returns top 10 matches per entity type.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
pattern = f"%{q}%"
|
||||||
|
results = {"servers": [], "scripts": [], "credentials": [], "schedules": []}
|
||||||
|
|
||||||
|
# Servers
|
||||||
|
try:
|
||||||
|
stmt = select(Server).where(
|
||||||
|
or_(
|
||||||
|
Server.name.ilike(pattern),
|
||||||
|
Server.domain.ilike(pattern),
|
||||||
|
Server.description.ilike(pattern),
|
||||||
|
Server.category.ilike(pattern),
|
||||||
|
)
|
||||||
|
).limit(10)
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
for s in res.scalars().all():
|
||||||
|
results["servers"].append({
|
||||||
|
"id": s.id, "name": s.name, "domain": s.domain,
|
||||||
|
"category": s.category, "is_online": s.is_online,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Scripts
|
||||||
|
try:
|
||||||
|
stmt = select(Script).where(
|
||||||
|
or_(
|
||||||
|
Script.name.ilike(pattern),
|
||||||
|
Script.category.ilike(pattern),
|
||||||
|
Script.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
).limit(10)
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
for s in res.scalars().all():
|
||||||
|
results["scripts"].append({
|
||||||
|
"id": s.id, "name": s.name, "category": s.category,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Credentials
|
||||||
|
try:
|
||||||
|
stmt = select(DbCredential).where(
|
||||||
|
or_(
|
||||||
|
DbCredential.name.ilike(pattern),
|
||||||
|
DbCredential.host.ilike(pattern),
|
||||||
|
)
|
||||||
|
).limit(10)
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
for c in res.scalars().all():
|
||||||
|
results["credentials"].append({
|
||||||
|
"id": c.id, "name": c.name, "db_type": c.db_type, "host": c.host,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Schedules
|
||||||
|
try:
|
||||||
|
stmt = select(PushSchedule).where(
|
||||||
|
or_(
|
||||||
|
PushSchedule.name.ilike(pattern),
|
||||||
|
PushSchedule.source_path.ilike(pattern),
|
||||||
|
)
|
||||||
|
).limit(10)
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
for s in res.scalars().all():
|
||||||
|
results["schedules"].append({
|
||||||
|
"id": s.id, "name": s.name, "cron_expr": s.cron_expr, "enabled": s.enabled,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Total count
|
||||||
|
total = sum(len(v) for v in results.values())
|
||||||
|
results["total"] = total
|
||||||
|
results["query"] = q
|
||||||
|
|
||||||
|
return results
|
||||||
+5
-1
@@ -54,6 +54,7 @@ from server.api.health import router as health_router
|
|||||||
from server.api.assets import router as assets_router
|
from server.api.assets import router as assets_router
|
||||||
from server.api.webssh import router as webssh_router
|
from server.api.webssh import router as webssh_router
|
||||||
from server.api.sync_v2 import router as sync_v2_router
|
from server.api.sync_v2 import router as sync_v2_router
|
||||||
|
from server.api.search import router as search_router
|
||||||
|
|
||||||
# Background tasks
|
# Background tasks
|
||||||
from server.background.heartbeat_flush import heartbeat_flush_loop
|
from server.background.heartbeat_flush import heartbeat_flush_loop
|
||||||
@@ -267,4 +268,7 @@ app.include_router(assets_router)
|
|||||||
app.include_router(webssh_router)
|
app.include_router(webssh_router)
|
||||||
|
|
||||||
# Sync Engine v2 (Step 4)
|
# Sync Engine v2 (Step 4)
|
||||||
app.include_router(sync_v2_router)
|
app.include_router(sync_v2_router)
|
||||||
|
|
||||||
|
# Global Search
|
||||||
|
app.include_router(search_router)
|
||||||
+55
-1
@@ -50,6 +50,11 @@ function initLayout(activeNav) {
|
|||||||
${bottomHtml}
|
${bottomHtml}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="border-t border-slate-800 p-4">
|
<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">
|
<div class="flex items-center justify-between">
|
||||||
<span id="sidebarUser" class="text-slate-400 text-sm">...</span>
|
<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>
|
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
|
||||||
@@ -70,8 +75,57 @@ async function loadLayoutUser() {
|
|||||||
if (el) el.textContent = u.username;
|
if (el) el.textContent = u.username;
|
||||||
const headerEl = document.getElementById('headerUser');
|
const headerEl = document.getElementById('headerUser');
|
||||||
if (headerEl) headerEl.textContent = u.username;
|
if (headerEl) headerEl.textContent = u.username;
|
||||||
// Set brand name
|
|
||||||
const brandEl = document.getElementById('brandName');
|
const brandEl = document.getElementById('brandName');
|
||||||
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
|
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
|
||||||
} catch(e) {}
|
} 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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user