feat: PeopleLib 开放文献客户端,集成 Z-Library 与 LibGen 等多源检索
Electron 桌面客户端,聚合多个开放获取文献源的搜索、详情与下载。 新增数据源: - Z-Library:邮箱登录(凭据本地存储),会话失效自动重登 - LibGen:适配新版 libgen.ac 前端(旧版 search.php 镜像已全部下线) - Memory of the World、Sci-Hub、Anna's Archive 基础设施: - mirror.js:镜像故障转移,支持串行优先与并发竞速两种策略, 失效镜像 5 分钟冷却后自动重试,避免站点恢复后被永久跳过 - http.js:统一 15 秒请求超时,防止单个卡死镜像拖垮整次搜索 - settings.js:全局代理配置持久化,经 Electron net.fetch 生效于所有请求 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>
commit
1a1288ce18
@@ -0,0 +1,313 @@
|
||||
const Browse = (() => {
|
||||
const state = {
|
||||
sourceId: null,
|
||||
supportsSearch: true,
|
||||
mode: 'list',
|
||||
keyword: '',
|
||||
page: 1,
|
||||
maxPage: 1,
|
||||
scrollY: 0,
|
||||
currentPostId: null,
|
||||
currentDetail: null
|
||||
};
|
||||
|
||||
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
|
||||
|
||||
function init() {
|
||||
grid = $('grid');
|
||||
statusBar = $('statusBar');
|
||||
pager = $('pager');
|
||||
gridView = $('browseGridView');
|
||||
detailView = $('detailView');
|
||||
detailContent = $('detailContent');
|
||||
mainEl = $('main');
|
||||
sourceSelect = $('sourceSelect');
|
||||
searchInput = $('searchInput');
|
||||
|
||||
$('searchBtn').onclick = doSearch;
|
||||
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
|
||||
$('clearSearchBtn').onclick = clearSearch;
|
||||
$('prevBtn').onclick = () => { if (state.page > 1) { state.page--; loadGrid(); } };
|
||||
$('nextBtn').onclick = () => { if (state.page < state.maxPage) { state.page++; loadGrid(); } };
|
||||
$('jumpBtn').onclick = jumpToPage;
|
||||
$('jumpInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') jumpToPage(); });
|
||||
$('backBtn').onclick = showGrid;
|
||||
|
||||
sourceSelect.onchange = () => {
|
||||
state.sourceId = sourceSelect.value;
|
||||
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
|
||||
updateSearchUi();
|
||||
state.mode = 'list';
|
||||
state.page = 1;
|
||||
clearSearchUi();
|
||||
loadGrid();
|
||||
};
|
||||
|
||||
loadSources();
|
||||
}
|
||||
|
||||
function updateSearchUi() {
|
||||
searchInput.disabled = !state.supportsSearch;
|
||||
$('searchBtn').disabled = !state.supportsSearch;
|
||||
searchInput.placeholder = state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览';
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
const res = await window.api.sources.list();
|
||||
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) =>
|
||||
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
|
||||
if (!list.length) {
|
||||
state.sourceId = null;
|
||||
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
|
||||
pager.classList.add('hidden');
|
||||
statusBar.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!list.some((s) => s.id === state.sourceId)) state.sourceId = list[0].id;
|
||||
sourceSelect.value = state.sourceId;
|
||||
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
|
||||
updateSearchUi();
|
||||
loadGrid();
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
if (!state.supportsSearch) return;
|
||||
const kw = searchInput.value.trim();
|
||||
if (!kw) return;
|
||||
state.mode = 'search';
|
||||
state.keyword = kw;
|
||||
state.page = 1;
|
||||
$('clearSearchBtn').classList.remove('hidden');
|
||||
loadGrid();
|
||||
}
|
||||
|
||||
function clearSearchUi() {
|
||||
searchInput.value = '';
|
||||
$('clearSearchBtn').classList.add('hidden');
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
state.mode = 'list';
|
||||
state.keyword = '';
|
||||
state.page = 1;
|
||||
clearSearchUi();
|
||||
loadGrid();
|
||||
}
|
||||
|
||||
async function loadGrid() {
|
||||
showGrid();
|
||||
statusBar.textContent = '加载中...';
|
||||
grid.innerHTML = '';
|
||||
pager.classList.add('hidden');
|
||||
mainEl.scrollTop = 0;
|
||||
|
||||
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);
|
||||
|
||||
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>`;
|
||||
if (isAuth) {
|
||||
grid.innerHTML = '<div class="empty">Z-Library 需要登录,请到"设置"页配置账号</div>';
|
||||
} else {
|
||||
grid.innerHTML = '<div class="empty">该数据源暂时不可用,可切换其它源</div>';
|
||||
}
|
||||
$('gridRetry').onclick = loadGrid;
|
||||
return;
|
||||
}
|
||||
|
||||
const { items, maxPage } = res.data;
|
||||
state.maxPage = maxPage || 1;
|
||||
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<div class="empty">未找到相关结果</div>';
|
||||
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}` : '';
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
$('pageInfo').textContent = `第 ${state.page} / ${state.maxPage} 页`;
|
||||
$('prevBtn').disabled = state.page <= 1;
|
||||
$('nextBtn').disabled = state.page >= state.maxPage;
|
||||
const jump = $('jumpInput');
|
||||
jump.max = state.maxPage;
|
||||
jump.value = '';
|
||||
jump.placeholder = state.page;
|
||||
pager.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function jumpToPage() {
|
||||
const input = $('jumpInput');
|
||||
let n = parseInt(input.value, 10);
|
||||
if (!n || n < 1) return;
|
||||
if (n > state.maxPage) n = state.maxPage;
|
||||
if (n === state.page) return;
|
||||
state.page = n;
|
||||
loadGrid();
|
||||
}
|
||||
|
||||
function showGrid() {
|
||||
detailView.classList.add('hidden');
|
||||
gridView.classList.remove('hidden');
|
||||
if (state.scrollY) mainEl.scrollTop = state.scrollY;
|
||||
}
|
||||
|
||||
async function openDetail(postId) {
|
||||
state.scrollY = mainEl.scrollTop;
|
||||
state.currentPostId = postId;
|
||||
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);
|
||||
if (!res.ok) {
|
||||
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
|
||||
$('detailRetry').onclick = () => openDetail(postId);
|
||||
return;
|
||||
}
|
||||
state.currentDetail = res.data;
|
||||
renderDetail(res.data);
|
||||
loadDownload(postId);
|
||||
refreshAddButton();
|
||||
}
|
||||
|
||||
function renderDetail(d) {
|
||||
const tagsHtml = (d.tags || []).map((t) => {
|
||||
const idx = t.indexOf(':');
|
||||
if (idx > 0) return `<div class="meta-row"><b>${escapeHtml(t.slice(0, idx))}</b>${escapeHtml(t.slice(idx + 1))}</div>`;
|
||||
return `<div class="meta-row">${escapeHtml(t)}</div>`;
|
||||
}).join('');
|
||||
const authorsHtml = (d.authors && d.authors.length)
|
||||
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
|
||||
const briefHtml = d.brief ? `<div class="section-title">简介 / 摘要</div><div class="brief-panel">${escapeHtml(d.brief)}</div>` : '';
|
||||
|
||||
detailContent.innerHTML = `
|
||||
<div class="detail-head">
|
||||
<div class="detail-cover" style="${coverStyle(d.cover)}">${d.cover ? '' : `<div class="ph">${escapeHtml(d.title)}</div>`}</div>
|
||||
<div class="detail-meta">
|
||||
<div class="detail-title">${escapeHtml(d.title)}</div>
|
||||
${authorsHtml}
|
||||
${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>` : ''}
|
||||
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">下载 / 全文</div>
|
||||
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
|
||||
${briefHtml}
|
||||
`;
|
||||
|
||||
$('addLibBtn').onclick = addToLibrary;
|
||||
const urlLink = $('detailUrlLink');
|
||||
if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); };
|
||||
}
|
||||
|
||||
async function refreshAddButton() {
|
||||
const btn = $('addLibBtn');
|
||||
if (!btn) return;
|
||||
const res = await window.api.library.findBySource(state.sourceId, state.currentPostId);
|
||||
if (res.ok && res.data) {
|
||||
btn.textContent = '已在书库 ✓';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('in-lib');
|
||||
}
|
||||
}
|
||||
|
||||
async function addToLibrary() {
|
||||
const d = state.currentDetail;
|
||||
if (!d) return;
|
||||
const res = await window.api.library.add({
|
||||
title: d.title,
|
||||
authors: d.authors || [],
|
||||
cover: d.cover,
|
||||
date: d.date || '',
|
||||
brief: d.brief || '',
|
||||
url: d.url || '',
|
||||
sourceId: state.sourceId,
|
||||
sourcePostId: state.currentPostId
|
||||
});
|
||||
if (res.ok) {
|
||||
const btn = $('addLibBtn');
|
||||
btn.textContent = '已加入书库 ✓';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('in-lib');
|
||||
if (window.Library) window.Library.markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDownload(postId) {
|
||||
const box = $('downloadBox');
|
||||
if (!box) return;
|
||||
box.innerHTML = '<div class="dl-loading">下载信息获取中...</div>';
|
||||
const res = await window.api.sources.download(state.sourceId, 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>`;
|
||||
$('dlRetry').onclick = () => loadDownload(postId);
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
let html = '';
|
||||
const files = d.files || [];
|
||||
if (files.length) {
|
||||
html += `<div class="dl-files">${files.map((f) => `<div class="dl-file-row">
|
||||
<span class="dl-file-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
|
||||
${f.format ? `<span class="dl-fmt">${escapeHtml(f.format)}</span>` : ''}
|
||||
<button class="copy-btn" data-copy="${escapeHtml(f.link)}">复制</button>
|
||||
<button class="dl-btn" data-file="${escapeHtml(f.link)}" data-name="${escapeHtml(f.name)}">下载</button>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
const links = d.links || [];
|
||||
if (links.length) {
|
||||
html += links.map((l) => `<div class="dl-link-row">🔗 <a href="#" data-open="${escapeHtml(l.url)}">${escapeHtml(l.name)}</a></div>`).join('');
|
||||
}
|
||||
box.innerHTML = html || '<div class="dl-error">未解析到下载信息</div>';
|
||||
box.querySelectorAll('.copy-btn').forEach((btn) => { btn.onclick = () => copyText(btn, btn.dataset.copy); });
|
||||
box.querySelectorAll('.dl-btn[data-file]').forEach((btn) => { btn.onclick = () => downloadFile(btn, btn.dataset.file); });
|
||||
box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; });
|
||||
}
|
||||
|
||||
async function downloadFile(btn, url) {
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '下载中...';
|
||||
const lib = await window.api.library.findBySource(state.sourceId, 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) {
|
||||
btn.textContent = orig; btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
btn.textContent = '已保存 ✓';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); btn.disabled = false; }, 2000);
|
||||
} else {
|
||||
btn.textContent = '失败';
|
||||
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return { init, reloadSources: loadSources };
|
||||
})();
|
||||
|
||||
window.Browse = Browse;
|
||||
Reference in New Issue
Block a user