const Browse = (() => {
const AGG_ID = '__all__';
const AGG_LIMIT = 5;
const state = {
sourceId: null,
supportsSearch: true,
mode: 'list',
keyword: '',
page: 1,
maxPage: 1,
scrollY: 0,
sourceList: [],
aggToken: 0,
gridToken: 0,
detailToken: 0,
activeSourceId: null,
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 = () => {
applySourceSelection(sourceSelect.value);
state.mode = 'list';
state.page = 1;
clearSearchUi();
loadGrid();
};
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 = isAgg()
? '聚合搜索:一次查询所有已启用数据源...'
: (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;
state.sourceList = list;
const aggOpt = list.filter((s) => s.supportsSearch).length > 1
? `` : '';
sourceSelect.innerHTML = aggOpt + list.map((s) =>
``).join('');
if (!list.length) {
state.sourceId = null;
grid.innerHTML = '
未启用任何数据源,请在设置中开启
';
pager.classList.add('hidden');
statusBar.textContent = '';
return;
}
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;
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();
}
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() {
const token = ++state.gridToken;
state.aggToken++;
showGrid();
statusBar.textContent = '加载中...';
grid.innerHTML = '';
pager.classList.add('hidden');
mainEl.scrollTop = 0;
if (isAgg()) return loadAggregate();
const sourceId = state.sourceId;
const mode = state.mode;
const keyword = state.keyword;
const page = state.page;
const res = mode === 'search'
? await window.api.sources.search(sourceId, keyword, page)
: await window.api.sources.browse(sourceId, page);
if (token !== state.gridToken) return;
grid.className = 'grid';
if (!res.ok) {
const isAuth = sourceId === 'zlib' && /登录|AUTH/i.test(res.error || '');
statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} `;
if (isAuth) {
grid.innerHTML = 'Z-Library 需要登录,请到"设置"页配置账号
';
} else {
grid.innerHTML = '该数据源暂时不可用,可切换其它源
';
}
$('gridRetry').onclick = loadGrid;
return;
}
const { items, maxPage } = res.data;
state.maxPage = maxPage || 1;
if (!items.length) {
grid.innerHTML = `${escapeHtml(res.data.note || '未找到相关结果')}
`;
statusBar.textContent = mode === 'search' ? `搜索:${keyword}` : '';
return;
}
statusBar.textContent = mode === 'search' ? `搜索:${keyword}(第 ${page} 页)` : '';
grid.innerHTML = items.map(cardHtml).join('');
bindCards(grid, sourceId);
$('pageInfo').textContent = `第 ${page} / ${state.maxPage} 页`;
$('prevBtn').disabled = page <= 1;
$('nextBtn').disabled = page >= state.maxPage;
const jump = $('jumpInput');
jump.max = state.maxPage;
jump.value = '';
jump.placeholder = page;
pager.classList.remove('hidden');
}
function cardHtml(it) {
return `
${it.cover ? '' : `
${escapeHtml(it.title)}
`}
${escapeHtml(it.title)}
${it.subtitle ? `
${escapeHtml(it.subtitle)}
` : ''}
${it.date ? `
${escapeHtml(it.date)}
` : ''}
`;
}
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 = `聚合搜索:输入关键词后将同时查询 ${targets.length} 个数据源
`;
return;
}
const token = ++state.aggToken;
const keyword = state.keyword;
statusBar.textContent = `聚合搜索「${keyword}」:0 / ${targets.length} 个源完成`;
grid.innerHTML = targets.map((s) => `
${escapeHtml(s.name)}
搜索中...
`).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 = `${escapeHtml(res.error)}`;
} 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);
if (!n || n < 1) return;
if (n > state.maxPage) n = state.maxPage;
if (n === state.page) return;
state.page = n;
loadGrid();
}
function showGrid() {
state.detailToken++;
detailView.classList.add('hidden');
gridView.classList.remove('hidden');
if (state.scrollY) mainEl.scrollTop = state.scrollY;
}
async function openDetail(postId, sourceId) {
const token = ++state.detailToken;
state.scrollY = mainEl.scrollTop;
state.currentPostId = postId;
const activeSourceId = sourceId || state.sourceId;
state.activeSourceId = activeSourceId;
gridView.classList.add('hidden');
detailView.classList.remove('hidden');
mainEl.scrollTop = 0;
detailContent.innerHTML = '加载中...
';
const res = await window.api.sources.detail(activeSourceId, postId);
if (token !== state.detailToken) return;
if (!res.ok) {
detailContent.innerHTML = `加载失败:${escapeHtml(res.error)}
`;
$('detailRetry').onclick = () => openDetail(postId, state.activeSourceId);
return;
}
state.currentDetail = res.data;
renderDetail(res.data);
if (sourceDownloadsOnDemand(activeSourceId)) {
renderOnDemandDownload(postId, activeSourceId, token);
} else {
loadDownload(postId, activeSourceId, token);
}
refreshAddButton();
}
function sourceNameOf(id) {
const s = state.sourceList.find((x) => x.id === id);
return s ? s.name : id;
}
function sourceDownloadsOnDemand(id) {
const source = state.sourceList.find((item) => item.id === id);
return !!(source && source.downloadOnDemand);
}
function renderDetail(d) {
const tagsHtml = (d.tags || []).map((t) => {
const idx = t.indexOf(':');
if (idx > 0) return `${escapeHtml(t.slice(0, idx))}${escapeHtml(t.slice(idx + 1))}
`;
return `${escapeHtml(t)}
`;
}).join('');
const authorsHtml = (d.authors && d.authors.length)
? `${escapeHtml(d.authors.join(', '))}
` : '';
const briefHtml = d.brief ? `简介 / 摘要
${escapeHtml(d.brief)}
` : '';
detailContent.innerHTML = `
${d.cover ? '' : `
${escapeHtml(d.title)}
`}
下载 / 全文
${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.activeSourceId, 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(entryMeta());
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, sourceId = state.activeSourceId, token = state.detailToken) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = '下载信息获取中...
';
const res = await window.api.sources.download(sourceId, postId);
if (!box.isConnected || token !== state.detailToken) return;
if (!res.ok) {
box.innerHTML = `获取失败:${escapeHtml(res.error)}
`;
$('dlRetry').onclick = () => loadDownload(postId, sourceId, token);
return;
}
renderDownloadResult(box, res.data);
}
function renderDownloadResult(box, d) {
let html = '';
const files = d.files || [];
if (files.length) {
html += `${files.map((f) => `
${escapeHtml(f.name)}
${f.format ? `${escapeHtml(f.format)}` : ''}
`).join('')}
`;
}
const links = d.links || [];
if (links.length) {
html += links.map((l) => ``).join('');
}
box.innerHTML = html || '未解析到下载信息
';
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); }; });
}
function renderOnDemandDownload(postId, sourceId, token) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = `
每日免费下载额度有限,仅在点击后获取下载地址
`;
const btn = $('onDemandDownloadBtn');
btn.onclick = async () => {
const oldError = box.querySelector('.dl-error');
if (oldError) oldError.remove();
btn.disabled = true;
btn.textContent = '获取中...';
const res = await window.api.sources.download(sourceId, postId);
if (!box.isConnected || token !== state.detailToken) return;
if (!res.ok) {
btn.disabled = false;
btn.textContent = '重试';
btn.title = res.error || '获取下载地址失败';
box.insertAdjacentHTML('beforeend', `获取失败:${escapeHtml(res.error)}
`);
return;
}
renderDownloadResult(box, res.data);
const download = box.querySelector('.dl-btn[data-file]');
if (download) download.click();
};
}
// 当前条目的元数据,供下载时自动建库用
function entryMeta() {
const d = state.currentDetail || {};
return {
title: d.title || '未命名',
authors: d.authors || [],
cover: d.cover || '',
date: d.date || '',
brief: d.brief || '',
url: d.url || '',
sourceId: state.activeSourceId,
sourcePostId: state.currentPostId
};
}
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function createDownloadProgress(row) {
const box = document.createElement('div');
box.className = 'dl-progress';
const track = document.createElement('div');
track.className = 'dl-progress-track';
const fill = document.createElement('div');
fill.className = 'dl-progress-fill';
const label = document.createElement('span');
label.className = 'dl-progress-label';
label.textContent = '准备下载…';
track.appendChild(fill);
box.append(track, label);
row.appendChild(box);
return { box, fill, label };
}
function updateDownloadProgress(progress, data) {
const received = Number(data && data.receivedBytes) || 0;
const total = Number(data && data.totalBytes) || 0;
const ratio = Number(data && data.percent);
if (data && data.percent != null && Number.isFinite(ratio)) {
const percent = Math.max(0, Math.min(1, ratio));
progress.box.classList.remove('indeterminate');
progress.fill.style.width = `${Math.round(percent * 100)}%`;
progress.label.textContent = total
? `${Math.round(percent * 100)}% · ${formatBytes(received)} / ${formatBytes(total)}`
: `${Math.round(percent * 100)}% · ${formatBytes(received)}`;
} else {
progress.box.classList.add('indeterminate');
progress.label.textContent = `${formatBytes(received)} 已下载`;
}
}
async function downloadFile(btn, url) {
const orig = btn.textContent;
const sourceId = state.activeSourceId;
const sourcePostId = state.currentPostId;
const meta = entryMeta();
const suggestedName = btn.dataset.name || '';
const row = btn.closest('.dl-file-row');
const oldProgress = row && row.querySelector('.dl-progress');
if (oldProgress) oldProgress.remove();
const progress = row ? createDownloadProgress(row) : null;
btn.disabled = true;
btn.textContent = '下载中...';
const lib = await window.api.library.findBySource(sourceId, sourcePostId);
const entryId = (lib.ok && lib.data) ? lib.data.id : undefined;
// 元数据与页面内进度回调交给全局任务中心,详情页离开后下载仍由它接管
let res;
try {
res = await window.DownloadCenter.start({
key: `${sourceId}:${sourcePostId}:${suggestedName}`,
url,
suggestName: suggestedName,
entryId,
meta,
onProgress: (data) => { if (progress) updateDownloadProgress(progress, data); }
});
} catch (error) {
res = { ok: false, error: (error && error.message) || String(error) };
}
if (res.ok && res.data && res.data.canceled) {
if (progress) progress.box.remove();
btn.textContent = orig; btn.disabled = false;
return;
}
if (res.ok && res.data && res.data.paused) {
if (progress) {
progress.box.classList.remove('indeterminate');
progress.label.textContent = '已暂停,可在任务中心继续';
}
btn.textContent = '继续';
btn.disabled = false;
return;
}
if (res.ok && res.data && res.data.deleted) {
if (progress) progress.box.remove();
btn.textContent = orig;
btn.disabled = false;
return;
}
if (!res.ok) {
if (progress) {
progress.box.classList.add('failed');
progress.label.textContent = res.error || '下载失败';
}
btn.textContent = '失败';
btn.title = res.error || '';
setTimeout(() => {
if (progress) progress.box.remove();
btn.textContent = orig;
btn.disabled = false;
}, 2000);
return;
}
// 下载即入库,刷新"加入书库"按钮并就地提供打开入口
if (window.Library) window.Library.markDirty();
if (state.activeSourceId === sourceId && state.currentPostId === sourcePostId) {
refreshAddButton();
}
const saved = res.data.path;
if (progress) {
updateDownloadProgress(progress, { ...res.data, percent: 1, complete: true });
progress.label.textContent = '下载完成';
setTimeout(() => progress.box.remove(), 900);
}
btn.textContent = '打开';
btn.title = '打开已下载文件';
btn.disabled = false;
btn.classList.add('downloaded');
btn.onclick = async () => {
const r = await window.api.openPath(saved);
if (!r.ok) await confirmModal('打开失败', r.error || '无法打开该文件');
};
if (row && !row.querySelector('.reveal-btn')) {
const reveal = document.createElement('button');
reveal.className = 'copy-btn reveal-btn';
reveal.textContent = '定位';
reveal.onclick = () => window.api.showItem(saved);
row.appendChild(reveal);
}
}
return { init, reloadSources: loadSources };
})();
window.Browse = Browse;