feat: upgrade search to live dropdown with paper-level results + debounce
This commit is contained in:
@@ -148,8 +148,8 @@
|
|||||||
### 4. 翻译覆盖率
|
### 4. 翻译覆盖率
|
||||||
翻译 PDF 存储在容器内 `/app/papers/translated/`,通过 volume 挂载持久化。翻译 API (`POST /api/translate/{paper_id}`) 会检查是否已翻译,避免重复。
|
翻译 PDF 存储在容器内 `/app/papers/translated/`,通过 volume 挂载持久化。翻译 API (`POST /api/translate/{paper_id}`) 会检查是否已翻译,避免重复。
|
||||||
|
|
||||||
### 5. 搜索是简单子串匹配
|
### 5. 搜索实现(2026-06-09 已修复)
|
||||||
`GET /api/papers?q=...` 用 Python `in` 操作符在 title + authors 中做子串匹配。不支持模糊搜索、中文分词或 relevance ranking。对于 189 篇论文规模够用,但超过 500 篇可能需要改进。
|
搜索已在 2026-06-09 升级为**实时下拉结果面板**。输入 ≥2 字符后,200ms debounce 后调用 `/api/papers?q=`,在下拉面板中显示匹配论文(标题高亮、模块/领域、年份、标签)。卡片同步绿框高亮。点击结果项跳转到对应模块弹窗。点外部或 Escape 关闭。
|
||||||
|
|
||||||
### 6. 数据一致性
|
### 6. 数据一致性
|
||||||
`papers.json` 是唯一数据源。新增论文通过 API 写操作 → 自动更新 JSON → 前端下次请求时自动获取最新数据。**不要手动编辑 production 上的 papers.json 绕过 API**,可能导致并发写入问题。
|
`papers.json` 是唯一数据源。新增论文通过 API 写操作 → 自动更新 JSON → 前端下次请求时自动获取最新数据。**不要手动编辑 production 上的 papers.json 绕过 API**,可能导致并发写入问题。
|
||||||
|
|||||||
101
static/app.js
101
static/app.js
@@ -35,9 +35,15 @@ async function init() {
|
|||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if ($('#pdfOverlay')?.classList.contains('open')) closePdf();
|
if ($('#pdfOverlay')?.classList.contains('open')) closePdf();
|
||||||
|
else if ($('#searchResults')?.style.display === 'block') closeSearchResults();
|
||||||
else if ($('#overlay').classList.contains('open')) closeModal();
|
else if ($('#overlay').classList.contains('open')) closeModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close search results on outside click
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!e.target.closest('.search-wrap')) closeSearchResults();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ STATUS BAR ════════════════════════════
|
// ═══════════════ STATUS BAR ════════════════════════════
|
||||||
@@ -260,16 +266,97 @@ function closePdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ SEARCH ═══════════════════════════════
|
// ═══════════════ SEARCH ═══════════════════════════════
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
function searchPapers(q) {
|
function searchPapers(q) {
|
||||||
q = (q||'').toLowerCase().trim();
|
q = (q||'').trim();
|
||||||
if (q.length<2) { $$('.card').forEach(c=>c.style.outline=''); return; }
|
if (q.length < 2) {
|
||||||
fetch(`${API}/papers?q=${encodeURIComponent(q)}&limit=10`).then(r=>r.json()).then(ps=>{
|
closeSearchResults();
|
||||||
const matched = new Set(ps.map(p=>p.module_id));
|
$$('.card').forEach(c => c.style.outline = '');
|
||||||
$$('.card').forEach(c=>{
|
return;
|
||||||
const m=[...c.classList].find(x=>x.startsWith('card-'))?.replace('card-','');
|
}
|
||||||
c.style.outline=matched.has(m)?'2px solid var(--green)':'';
|
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
fetch(`${API}/papers?q=${encodeURIComponent(q)}&limit=15`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(ps => {
|
||||||
|
// Highlight matching module cards
|
||||||
|
const matched = new Set(ps.map(p => p.module_id));
|
||||||
|
$$('.card').forEach(c => {
|
||||||
|
const m = [...c.classList].find(x => x.startsWith('card-'))?.replace('card-', '');
|
||||||
|
c.style.outline = matched.has(m) ? '2px solid var(--green)' : '';
|
||||||
|
});
|
||||||
|
// Show paper-level results dropdown
|
||||||
|
renderSearchResults(ps, q);
|
||||||
|
});
|
||||||
|
}, 200); // 200ms debounce
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults(papers, query) {
|
||||||
|
let panel = $('#searchResults');
|
||||||
|
if (!panel) {
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.id = 'searchResults';
|
||||||
|
panel.className = 'search-results';
|
||||||
|
$('.search-wrap').appendChild(panel);
|
||||||
|
panel.addEventListener('click', e => {
|
||||||
|
const item = e.target.closest('.search-result-item');
|
||||||
|
if (item && item.dataset.mid) {
|
||||||
|
closeSearchResults();
|
||||||
|
openModule(item.dataset.mid);
|
||||||
|
// After modal opens, switch to the right tab
|
||||||
|
setTimeout(() => {
|
||||||
|
const areaIdx = parseInt(item.dataset.aidx || '0');
|
||||||
|
switchArea(areaIdx);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (papers.length === 0) {
|
||||||
|
panel.innerHTML = '<div class="search-result-empty">未找到匹配论文</div>';
|
||||||
|
panel.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by module for clean display
|
||||||
|
const modColors = {};
|
||||||
|
$$('.card').forEach(c => {
|
||||||
|
const m = [...c.classList].find(x => x.startsWith('card-'))?.replace('card-', '');
|
||||||
|
if (m) modColors[m] = `var(--${m === 'arch' ? 'blue' : m === 'multi' ? 'cyan' : m === 'data' ? 'green' : m === 'pretrain' ? 'red' : m === 'post' ? 'purple' : m === 'compress' ? 'orange' : m === 'deploy' ? 'teal' : m === 'agent' ? 'pink' : 'yellow'})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
panel.innerHTML = papers.map(p => {
|
||||||
|
const mid = p.module_id;
|
||||||
|
const areaIdx = moduleData[mid]?.areas?.findIndex(a => a.id === p.area_id) ?? 0;
|
||||||
|
const color = modColors[mid] || 'var(--blue)';
|
||||||
|
const tags = (p.tags || []).map(t => `<span class="paper-tag ${TAG_CLASS[t] || 'tag-branch'}" style="font-size:0.65em">${t}</span>`).join('');
|
||||||
|
return `<div class="search-result-item" data-mid="${mid}" data-aidx="${areaIdx}">
|
||||||
|
<div class="search-result-title">${highlightMatch(p.title, query)}</div>
|
||||||
|
<div class="search-result-meta">
|
||||||
|
<span style="color:${color};font-weight:600">${p.module_name} / ${p.area_name}</span>
|
||||||
|
<span>${p.year || ''}</span>
|
||||||
|
${tags}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') + `<div class="search-result-footer">共 ${papers.length} 篇匹配 · 点击跳转</div>`;
|
||||||
|
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMatch(text, query) {
|
||||||
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (idx < 0) return text;
|
||||||
|
const before = text.slice(0, idx);
|
||||||
|
const match = text.slice(idx, idx + query.length);
|
||||||
|
const after = text.slice(idx + query.length);
|
||||||
|
return `${before}<mark>${match}</mark>${after}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearchResults() {
|
||||||
|
const panel = $('#searchResults');
|
||||||
|
if (panel) { panel.style.display = 'none'; panel.innerHTML = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ EVENTS ═══════════════════════════════
|
// ═══════════════ EVENTS ═══════════════════════════════
|
||||||
|
|||||||
@@ -174,6 +174,25 @@ body {
|
|||||||
.search-input:focus { border-color: var(--blue); }
|
.search-input:focus { border-color: var(--blue); }
|
||||||
.search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-dim); }
|
.search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Search results dropdown */
|
||||||
|
.search-results {
|
||||||
|
display: none; position: absolute; top: 100%; left: 0; right: 0;
|
||||||
|
margin-top: 4px; background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); max-height: 480px; overflow-y: auto;
|
||||||
|
z-index: 50; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.search-result-item {
|
||||||
|
padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--border);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.search-result-item:last-child { border-bottom: none; }
|
||||||
|
.search-result-item:hover { background: rgba(88,166,255,.06); }
|
||||||
|
.search-result-title { font-size: 0.85em; color: var(--text-bright); margin-bottom: 3px; line-height: 1.4; }
|
||||||
|
.search-result-title mark { background: rgba(88,166,255,.25); color: var(--blue); padding: 0 2px; border-radius: 2px; }
|
||||||
|
.search-result-meta { font-size: 0.72em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.search-result-empty { padding: 20px; text-align: center; color: var(--text-dim); font-size: 0.85em; }
|
||||||
|
.search-result-footer { padding: 8px 14px; font-size: 0.7em; color: var(--text-dim); text-align: center; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } .modal { padding: 20px 16px; } }
|
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } .modal { padding: 20px 16px; } }
|
||||||
|
|
||||||
/* PDF viewer */
|
/* PDF viewer */
|
||||||
|
|||||||
Reference in New Issue
Block a user