feat: 检索页聚合搜索,修复中文关键词导致主进程崩溃
一次查询所有已启用数据源并按源分组展示,每组最多 5 条, 超出时提供「查看更多」跳转到该源的单源检索。 同时修复聚合并发暴露出的网络层问题: - 主进程崩溃:Memory of the World 会把搜索关键词原样回写进 ETag 响应头,中文关键词下 Electron net.fetch 在内部 emit 回调里抛 ByteString TypeError,await 无法捕获,主进程直接 崩溃且 Promise 永不 settle。现拦截该类异常并回退到 undici。 - OpenLibrary / LibGen 要求关键词至少 3 字符,否则返回 422。 改为前置校验,直接返回空结果加说明,耗时从 14s 降到 3ms。 - 重试只针对可自愈错误(超时/5xx/限流),不再为 4xx 白等; 重试时收紧超时,避免最坏耗时翻倍。镜像轮询类源关闭内层 重试,换镜像交给 tryMirrors/raceMirrors。 - 网络错误文案改为可读提示,不再把整条 URL 抛给用户。 - Sci-Hub 非 DOI 关键词返回空而非报错,避免聚合结果刷屏。 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
1a1288ce18
commit
de3e1d8a44
@@ -140,6 +140,21 @@ body {
|
||||
|
||||
.empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; }
|
||||
|
||||
/* 聚合搜索:按源分组 */
|
||||
.agg-list { display: flex; flex-direction: column; gap: 22px; }
|
||||
.agg-list .empty { grid-column: auto; }
|
||||
.agg-group { border-top: 1px solid var(--line); padding-top: 12px; }
|
||||
.agg-group:first-child { border-top: none; padding-top: 0; }
|
||||
.agg-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.agg-name { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.agg-count { font-size: 12px; color: var(--text-dim); }
|
||||
.agg-count.err { color: var(--danger); }
|
||||
.agg-more { margin-left: auto; }
|
||||
.agg-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||
.agg-loading, .agg-msg { font-size: 12px; color: var(--text-dim); padding: 6px 0; }
|
||||
.agg-err { color: var(--danger); }
|
||||
.agg-group.agg-empty, .agg-group.agg-failed { opacity: 0.55; }
|
||||
|
||||
/* 分页 */
|
||||
.pager {
|
||||
position: sticky; bottom: -20px; z-index: 6;
|
||||
|
||||
+149
-26
@@ -1,4 +1,7 @@
|
||||
const Browse = (() => {
|
||||
const AGG_ID = '__all__';
|
||||
const AGG_LIMIT = 5;
|
||||
|
||||
const state = {
|
||||
sourceId: null,
|
||||
supportsSearch: true,
|
||||
@@ -7,6 +10,9 @@ const Browse = (() => {
|
||||
page: 1,
|
||||
maxPage: 1,
|
||||
scrollY: 0,
|
||||
sourceList: [],
|
||||
aggToken: 0,
|
||||
activeSourceId: null,
|
||||
currentPostId: null,
|
||||
currentDetail: null
|
||||
};
|
||||
@@ -34,9 +40,7 @@ const Browse = (() => {
|
||||
$('backBtn').onclick = showGrid;
|
||||
|
||||
sourceSelect.onchange = () => {
|
||||
state.sourceId = sourceSelect.value;
|
||||
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
|
||||
updateSearchUi();
|
||||
applySourceSelection(sourceSelect.value);
|
||||
state.mode = 'list';
|
||||
state.page = 1;
|
||||
clearSearchUi();
|
||||
@@ -46,10 +50,21 @@ const Browse = (() => {
|
||||
loadSources();
|
||||
}
|
||||
|
||||
function isAgg() { return state.sourceId === AGG_ID; }
|
||||
|
||||
function applySourceSelection(id) {
|
||||
state.sourceId = id;
|
||||
const opt = sourceSelect.selectedOptions[0];
|
||||
state.supportsSearch = id === AGG_ID ? true : (opt ? opt.dataset.search !== '0' : true);
|
||||
updateSearchUi();
|
||||
}
|
||||
|
||||
function updateSearchUi() {
|
||||
searchInput.disabled = !state.supportsSearch;
|
||||
$('searchBtn').disabled = !state.supportsSearch;
|
||||
searchInput.placeholder = state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览';
|
||||
searchInput.placeholder = isAgg()
|
||||
? '聚合搜索:一次查询所有已启用数据源...'
|
||||
: (state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览');
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
@@ -57,7 +72,10 @@ const Browse = (() => {
|
||||
const all = res.ok ? res.data : [];
|
||||
const enabled = getEnabledSources();
|
||||
const list = enabled ? all.filter((s) => enabled.includes(s.id)) : all;
|
||||
sourceSelect.innerHTML = list.map((s) =>
|
||||
state.sourceList = list;
|
||||
const aggOpt = list.filter((s) => s.supportsSearch).length > 1
|
||||
? `<option value="${AGG_ID}" data-search="1">🔎 聚合搜索(全部源)</option>` : '';
|
||||
sourceSelect.innerHTML = aggOpt + list.map((s) =>
|
||||
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
|
||||
if (!list.length) {
|
||||
state.sourceId = null;
|
||||
@@ -66,10 +84,21 @@ const Browse = (() => {
|
||||
statusBar.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!list.some((s) => s.id === state.sourceId)) state.sourceId = list[0].id;
|
||||
const stillValid = state.sourceId === AGG_ID ? !!aggOpt : list.some((s) => s.id === state.sourceId);
|
||||
if (!stillValid) state.sourceId = aggOpt ? AGG_ID : list[0].id;
|
||||
sourceSelect.value = state.sourceId;
|
||||
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
|
||||
updateSearchUi();
|
||||
applySourceSelection(state.sourceId);
|
||||
loadGrid();
|
||||
}
|
||||
|
||||
function gotoSource(sourceId, keyword) {
|
||||
sourceSelect.value = sourceId;
|
||||
applySourceSelection(sourceId);
|
||||
state.mode = 'search';
|
||||
state.keyword = keyword;
|
||||
state.page = 1;
|
||||
searchInput.value = keyword;
|
||||
$('clearSearchBtn').classList.remove('hidden');
|
||||
loadGrid();
|
||||
}
|
||||
|
||||
@@ -98,16 +127,21 @@ const Browse = (() => {
|
||||
}
|
||||
|
||||
async function loadGrid() {
|
||||
state.aggToken++;
|
||||
showGrid();
|
||||
statusBar.textContent = '加载中...';
|
||||
grid.innerHTML = '';
|
||||
pager.classList.add('hidden');
|
||||
mainEl.scrollTop = 0;
|
||||
|
||||
if (isAgg()) return loadAggregate();
|
||||
|
||||
const res = state.mode === 'search'
|
||||
? await window.api.sources.search(state.sourceId, state.keyword, state.page)
|
||||
: await window.api.sources.browse(state.sourceId, state.page);
|
||||
|
||||
grid.className = 'grid';
|
||||
|
||||
if (!res.ok) {
|
||||
const isAuth = state.sourceId === 'zlib' && /登录|登录|AUTH/i.test(res.error || '');
|
||||
statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} <button class="retry-btn" id="gridRetry">重试</button>`;
|
||||
@@ -131,17 +165,8 @@ const Browse = (() => {
|
||||
|
||||
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : '';
|
||||
|
||||
grid.innerHTML = items.map((it) => `
|
||||
<div class="card" data-id="${escapeHtml(it.postId)}">
|
||||
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
|
||||
<div class="card-title">${escapeHtml(it.title)}</div>
|
||||
${it.subtitle ? `<div class="card-sub">${escapeHtml(it.subtitle)}</div>` : ''}
|
||||
${it.date ? `<div class="card-date">${escapeHtml(it.date)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
grid.querySelectorAll('.card').forEach((el) => {
|
||||
el.onclick = () => openDetail(el.dataset.id);
|
||||
});
|
||||
grid.innerHTML = items.map(cardHtml).join('');
|
||||
bindCards(grid, state.sourceId);
|
||||
|
||||
$('pageInfo').textContent = `第 ${state.page} / ${state.maxPage} 页`;
|
||||
$('prevBtn').disabled = state.page <= 1;
|
||||
@@ -153,6 +178,97 @@ const Browse = (() => {
|
||||
pager.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function cardHtml(it) {
|
||||
return `
|
||||
<div class="card" data-id="${escapeHtml(it.postId)}">
|
||||
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
|
||||
<div class="card-title">${escapeHtml(it.title)}</div>
|
||||
${it.subtitle ? `<div class="card-sub">${escapeHtml(it.subtitle)}</div>` : ''}
|
||||
${it.date ? `<div class="card-date">${escapeHtml(it.date)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bindCards(root, sourceId) {
|
||||
root.querySelectorAll('.card').forEach((el) => {
|
||||
el.onclick = () => openDetail(el.dataset.id, sourceId);
|
||||
});
|
||||
}
|
||||
|
||||
// 聚合搜索:并发查询所有支持搜索的已启用源,按源分组展示,每组最多 AGG_LIMIT 条
|
||||
async function loadAggregate() {
|
||||
const targets = state.sourceList.filter((s) => s.supportsSearch);
|
||||
grid.className = 'agg-list';
|
||||
|
||||
if (state.mode !== 'search' || !state.keyword) {
|
||||
statusBar.textContent = '';
|
||||
grid.innerHTML = `<div class="empty">聚合搜索:输入关键词后将同时查询 ${targets.length} 个数据源</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++state.aggToken;
|
||||
const keyword = state.keyword;
|
||||
|
||||
statusBar.textContent = `聚合搜索「${keyword}」:0 / ${targets.length} 个源完成`;
|
||||
grid.innerHTML = targets.map((s) => `
|
||||
<section class="agg-group" id="agg-${escapeHtml(s.id)}">
|
||||
<div class="agg-head">
|
||||
<span class="agg-name">${escapeHtml(s.name)}</span>
|
||||
<span class="agg-count" data-role="count">搜索中...</span>
|
||||
</div>
|
||||
<div class="grid agg-grid" data-role="grid"><div class="agg-loading">加载中...</div></div>
|
||||
</section>`).join('');
|
||||
|
||||
let done = 0;
|
||||
let hit = 0;
|
||||
let failed = 0;
|
||||
|
||||
await Promise.all(targets.map(async (s) => {
|
||||
const res = await window.api.sources.search(s.id, keyword, 1);
|
||||
if (token !== state.aggToken || !grid.isConnected) return;
|
||||
const sec = $(`agg-${s.id}`);
|
||||
if (!sec) return;
|
||||
const countEl = sec.querySelector('[data-role="count"]');
|
||||
const gridEl = sec.querySelector('[data-role="grid"]');
|
||||
const headEl = sec.querySelector('.agg-head');
|
||||
done++;
|
||||
|
||||
if (!res.ok) {
|
||||
failed++;
|
||||
sec.classList.add('agg-failed');
|
||||
countEl.textContent = '失败';
|
||||
countEl.classList.add('err');
|
||||
gridEl.className = 'agg-msg';
|
||||
gridEl.innerHTML = `<span class="agg-err">${escapeHtml(res.error)}</span>`;
|
||||
} else {
|
||||
const items = (res.data && res.data.items) || [];
|
||||
if (!items.length) {
|
||||
sec.classList.add('agg-empty');
|
||||
countEl.textContent = '无结果';
|
||||
gridEl.className = 'agg-msg';
|
||||
gridEl.textContent = res.data.note || '未找到相关结果';
|
||||
} else {
|
||||
hit++;
|
||||
const shown = items.slice(0, AGG_LIMIT);
|
||||
countEl.textContent = `${shown.length} 条`;
|
||||
gridEl.className = 'grid agg-grid';
|
||||
gridEl.innerHTML = shown.map(cardHtml).join('');
|
||||
bindCards(gridEl, s.id);
|
||||
if (items.length > AGG_LIMIT || (res.data.maxPage || 1) > 1) {
|
||||
const more = document.createElement('button');
|
||||
more.className = 'tb-btn ghost sm agg-more';
|
||||
more.textContent = '查看更多 →';
|
||||
more.onclick = () => gotoSource(s.id, keyword);
|
||||
headEl.appendChild(more);
|
||||
}
|
||||
}
|
||||
}
|
||||
statusBar.textContent = `聚合搜索「${keyword}」:${done} / ${targets.length} 个源完成`;
|
||||
}));
|
||||
|
||||
if (token !== state.aggToken) return;
|
||||
statusBar.textContent = `聚合搜索「${keyword}」:${hit} 个源有结果${failed ? `,${failed} 个源失败` : ''}(共 ${targets.length} 个源)`;
|
||||
}
|
||||
|
||||
function jumpToPage() {
|
||||
const input = $('jumpInput');
|
||||
let n = parseInt(input.value, 10);
|
||||
@@ -169,18 +285,19 @@ const Browse = (() => {
|
||||
if (state.scrollY) mainEl.scrollTop = state.scrollY;
|
||||
}
|
||||
|
||||
async function openDetail(postId) {
|
||||
async function openDetail(postId, sourceId) {
|
||||
state.scrollY = mainEl.scrollTop;
|
||||
state.currentPostId = postId;
|
||||
state.activeSourceId = sourceId || state.sourceId;
|
||||
gridView.classList.add('hidden');
|
||||
detailView.classList.remove('hidden');
|
||||
mainEl.scrollTop = 0;
|
||||
detailContent.innerHTML = '<div class="dl-loading">加载中...</div>';
|
||||
|
||||
const res = await window.api.sources.detail(state.sourceId, postId);
|
||||
const res = await window.api.sources.detail(state.activeSourceId, postId);
|
||||
if (!res.ok) {
|
||||
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
|
||||
$('detailRetry').onclick = () => openDetail(postId);
|
||||
$('detailRetry').onclick = () => openDetail(postId, state.activeSourceId);
|
||||
return;
|
||||
}
|
||||
state.currentDetail = res.data;
|
||||
@@ -189,6 +306,11 @@ const Browse = (() => {
|
||||
refreshAddButton();
|
||||
}
|
||||
|
||||
function sourceNameOf(id) {
|
||||
const s = state.sourceList.find((x) => x.id === id);
|
||||
return s ? s.name : id;
|
||||
}
|
||||
|
||||
function renderDetail(d) {
|
||||
const tagsHtml = (d.tags || []).map((t) => {
|
||||
const idx = t.indexOf(':');
|
||||
@@ -205,6 +327,7 @@ const Browse = (() => {
|
||||
<div class="detail-meta">
|
||||
<div class="detail-title">${escapeHtml(d.title)}</div>
|
||||
${authorsHtml}
|
||||
<div class="meta-row"><b>来源</b>${escapeHtml(sourceNameOf(state.activeSourceId))}</div>
|
||||
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
|
||||
${tagsHtml}
|
||||
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
|
||||
@@ -224,7 +347,7 @@ const Browse = (() => {
|
||||
async function refreshAddButton() {
|
||||
const btn = $('addLibBtn');
|
||||
if (!btn) return;
|
||||
const res = await window.api.library.findBySource(state.sourceId, state.currentPostId);
|
||||
const res = await window.api.library.findBySource(state.activeSourceId, state.currentPostId);
|
||||
if (res.ok && res.data) {
|
||||
btn.textContent = '已在书库 ✓';
|
||||
btn.disabled = true;
|
||||
@@ -242,7 +365,7 @@ const Browse = (() => {
|
||||
date: d.date || '',
|
||||
brief: d.brief || '',
|
||||
url: d.url || '',
|
||||
sourceId: state.sourceId,
|
||||
sourceId: state.activeSourceId,
|
||||
sourcePostId: state.currentPostId
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -258,7 +381,7 @@ const Browse = (() => {
|
||||
const box = $('downloadBox');
|
||||
if (!box) return;
|
||||
box.innerHTML = '<div class="dl-loading">下载信息获取中...</div>';
|
||||
const res = await window.api.sources.download(state.sourceId, postId);
|
||||
const res = await window.api.sources.download(state.activeSourceId, postId);
|
||||
if (!box.isConnected) return;
|
||||
if (!res.ok) {
|
||||
box.innerHTML = `<div class="dl-error">获取失败:${escapeHtml(res.error)}<button class="retry-btn" id="dlRetry">重试</button></div>`;
|
||||
@@ -290,7 +413,7 @@ const Browse = (() => {
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '下载中...';
|
||||
const lib = await window.api.library.findBySource(state.sourceId, state.currentPostId);
|
||||
const lib = await window.api.library.findBySource(state.activeSourceId, state.currentPostId);
|
||||
const entryId = (lib.ok && lib.data) ? lib.data.id : undefined;
|
||||
const res = await window.api.downloadFile(url, btn.dataset.name || '', entryId);
|
||||
if (res.ok && res.data && res.data.canceled) {
|
||||
|
||||
Reference in New Issue
Block a user