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)}
`}
${escapeHtml(d.title)}
${authorsHtml}
来源${escapeHtml(sourceNameOf(state.activeSourceId))}
${d.date ? `
日期${escapeHtml(d.date)}
` : ''} ${tagsHtml} ${d.url ? `` : ''}
下载 / 全文
下载信息获取中...
${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;