Files
peoplelib/src/ui/views/browse.js
T
lofyer c2292da442
构建与发布 / 单测与集成测试 (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
构建与发布 / 发布 GitHub Release (push) Has been cancelled
fix: Z-Library 按需获取下载地址并发布 v2.1.4
2026-08-07 21:29:19 +08:00

610 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
? `<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;
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
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)} <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">${escapeHtml(res.data.note || '未找到相关结果')}</div>`;
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 `
<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);
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 = '<div class="dl-loading">加载中...</div>';
const res = await window.api.sources.detail(activeSourceId, postId);
if (token !== state.detailToken) return;
if (!res.ok) {
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
$('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 `<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}
<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>` : ''}
<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.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 = '<div class="dl-loading">下载信息获取中...</div>';
const res = await window.api.sources.download(sourceId, postId);
if (!box.isConnected || token !== state.detailToken) 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, sourceId, token);
return;
}
renderDownloadResult(box, res.data);
}
function renderDownloadResult(box, d) {
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); }; });
}
function renderOnDemandDownload(postId, sourceId, token) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = `<div class="dl-files"><div class="dl-file-row">
<span class="dl-file-name">每日免费下载额度有限,仅在点击后获取下载地址</span>
<button class="dl-btn" id="onDemandDownloadBtn">下载</button>
</div></div>`;
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', `<div class="dl-error">获取失败:${escapeHtml(res.error)}</div>`);
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;