构建与发布 / 单测与集成测试 (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
610 lines
22 KiB
JavaScript
610 lines
22 KiB
JavaScript
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;
|