From cb7b020dc8d121d9a540c545043d8e5150c31888 Mon Sep 17 00:00:00 2001
From: lofyer
Date: Wed, 5 Aug 2026 15:25:26 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=B8=8B=E8=BD=BD?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=B8=AD=E5=BF=83=E4=B8=8E=E6=96=AD=E7=82=B9?=
=?UTF-8?q?=E7=BB=AD=E4=BC=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
BUILD.md | 52 +++-
README.md | 3 +-
main.js | 261 +++++++++++++---
package-lock.json | 4 +-
package.json | 2 +-
preload.js | 13 +-
site/DEPLOY.md | 2 +-
site/download.html | 4 +-
site/faq.html | 2 +-
site/features.html | 2 +-
site/index.html | 4 +-
site/privacy.html | 2 +-
src/_test/electron/download.integration.js | 249 ++++++++++++++-
src/_test/main.test.js | 6 +
src/_test/ui.test.js | 25 +-
src/ui/app.js | 1 +
src/ui/download-center.js | 341 +++++++++++++++++++++
src/ui/index.html | 20 ++
src/ui/style.css | 144 +++++++++
src/ui/views/browse.js | 27 +-
20 files changed, 1094 insertions(+), 70 deletions(-)
create mode 100644 src/ui/download-center.js
diff --git a/BUILD.md b/BUILD.md
index 89684a1..eccd844 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -19,7 +19,7 @@ npm run portable
输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。
-发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v1.3.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
+发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v2.0.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新。
### macOS(Apple Silicon)
@@ -45,6 +45,56 @@ xattr -dr com.apple.quarantine /Applications/PeopleLib.app
图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`。
+## 固定构建变体
+
+PeopleLib 采用固定构建变体,不使用远程开关在应用发布后改变功能范围:
+
+| 构建 | 定位 | 功能范围 |
+|---|---|---|
+| 桌面完整版 | Windows、macOS | 多源检索、下载与任务中心、数据源账号、代理、本地书库、阅读和笔记 |
+| 移动阅读版 | iPadOS、Android | 本地导入、书库、阅读、笔记、书签和批注 |
+
+移动阅读版固定关闭以下能力:
+
+```js
+{
+ remoteSearch: false,
+ remoteDownload: false,
+ sourceAccounts: false,
+ proxy: false
+}
+```
+
+这些能力必须在构建时确定。移动端不展示相关入口、不注册对应桥接 API、不发起数据源请求,也不能通过服务端配置重新开启。桌面端继续保留完整功能。
+
+移动端不能直接复用 Electron 包,需要使用独立的移动端外壳。下载、文件系统、安全存储和阅读文件访问均应通过 iPadOS/Android 原生桥接实现。首个移动版本只提供本地阅读能力;从系统文件选择器、分享面板或用户自行管理的云盘导入文件,不提供应用内在线检索和下载。
+
+移动阅读版必须由专用脚本生成,不能依赖开发者手工删除页面或模块。计划提供以下固定入口:
+
+```bash
+npm run build:android
+npm run build:ios
+```
+
+两个命令应调用同一套移动构建脚本,并把平台与固定变体显式传入,例如:
+
+```bash
+node build-mobile.js --platform android --variant reader-only
+node build-mobile.js --platform ios --variant reader-only
+```
+
+`build-mobile.js` 必须完成:
+
+1. 校验变体只能是 `reader-only`,拒绝从环境变量或远程配置开启受限能力。
+2. 生成移动端能力清单,并在编译前固定关闭检索、下载、数据源账号和代理。
+3. 使用移动端入口组装资源,不注册桌面端 IPC,不复制在线数据源模块。
+4. 调用 Android 或 iOS 原生工程构建工具,并把产物输出到固定的 `dist/PeopleLib-android/` 或 `dist/PeopleLib-ios/`。
+5. 对最终产物运行自动化检查,确认不存在检索、下载、数据源账号和代理入口。
+
+Android 构建可以在 Windows、macOS 或 Linux 上执行;iOS/iPadOS 构建依赖 Xcode、签名与 Apple SDK,只能在 macOS 上执行。
+
+当前仓库尚未包含 `build-mobile.js` 与移动端原生工程,所以上述命令是必须补齐的目标构建入口,目前不能生成移动安装包。
+
## 配置
### 代理
diff --git a/README.md b/README.md
index 8bf21a0..f6ec2f8 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ AI 助手,可把选中文本、当前页、全文或框选区域作为上下
- **多源检索**:12 个数据源统一的搜索、详情、下载流程
- **本地书库**:收藏条目、下载文件、封面缓存、阅读状态管理
+- **任务中心**:全局查看下载进度,离开详情页后继续下载,支持暂停、断点续传和删除未完成任务
- **内置阅读器**:PDF、EPUB、无 DRM 的 MOBI/KF7/KF8 与 TXT/Markdown 阅读,支持进度、书签、选文和笔记
- **全局代理**:一处配置,对所有数据源与封面请求生效
- **镜像故障转移**:镜像失效自动切换,恢复后自动重新启用
@@ -69,7 +70,7 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不
2. 双击目录中的 `PeopleLib.exe`。
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`。
-当前版本为 **1.3.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
+当前版本为 **2.0.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
## 开发
diff --git a/main.js b/main.js
index 91b06ee..a12e092 100644
--- a/main.js
+++ b/main.js
@@ -150,6 +150,8 @@ const legacyImportPending = !settings.get('legacyImported', false);
let mainWindow;
let activeDownloads = 0;
+const downloadSessions = new Map();
+const downloadSessionSenders = new Set();
let readerPurgeSeq = 0;
let startupMaintenanceStarted = false;
const readerPurgeWaiters = new Map();
@@ -177,6 +179,40 @@ function ensureReaderWritable(entryId) {
if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除');
}
+function downloadSessionKey(senderId, requestId) {
+ return `${senderId}:${requestId}`;
+}
+
+function removeDownloadPartial(session) {
+ if (!session || !session.partial) return;
+ try {
+ fs.unlinkSync(session.partial);
+ session.partial = '';
+ } catch (e) {
+ if (e.code === 'ENOENT') session.partial = '';
+ }
+}
+
+function discardDownloadSession(session) {
+ if (!session) return;
+ session.control = 'delete';
+ if (session.controller) session.controller.abort();
+ removeDownloadPartial(session);
+ if (session.state !== 'running') downloadSessions.delete(session.key);
+}
+
+function trackDownloadSender(webContents) {
+ const senderId = webContents.id;
+ if (downloadSessionSenders.has(senderId)) return;
+ downloadSessionSenders.add(senderId);
+ webContents.once('destroyed', () => {
+ downloadSessionSenders.delete(senderId);
+ for (const session of downloadSessions.values()) {
+ if (session.senderId === senderId) discardDownloadSession(session);
+ }
+ });
+}
+
function createWindow() {
mainWindow = new BrowserWindow({
width: 1240,
@@ -276,6 +312,7 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
+ for (const download of downloadSessions.values()) discardDownloadSession(download);
coverGenerator.close();
rangeSessions.closeAll().catch(() => {});
});
@@ -439,7 +476,7 @@ ipcMain.handle('library:pickDir', async () => {
ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => {
const dest = String(dir || '').trim();
if (!dest) throw new Error('目录不能为空');
- if (activeDownloads) throw new Error('请等待当前下载完成后再切换书库目录');
+ if (activeDownloads || downloadSessions.size) throw new Error('请等待当前下载完成或删除未完成任务后再切换书库目录');
const previousSetting = settings.get('libraryDir', '');
const previousRoot = library.getRoot();
try {
@@ -624,11 +661,17 @@ ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => {
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
+ const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(progressId)) return { ok: false, error: '下载任务 ID 无效' };
+ const key = downloadSessionKey(event.sender.id, progressId);
+ let download = downloadSessions.get(key);
+ if (download && download.state === 'running') return { ok: false, error: '该下载任务正在运行' };
+ if (download && download.control === 'delete') return { ok: false, error: '该下载任务已删除' };
+ trackDownloadSender(event.sender);
+
activeDownloads++;
- let partial = '';
let res = null;
let bodyHandled = false;
- const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const sendProgress = (data) => {
if (!progressId || event.sender.isDestroyed()) return;
event.sender.send('download:progress', { requestId: progressId, ...data });
@@ -638,22 +681,65 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
const askSavePath = settings.get('askSavePath', false);
const suggested = library.sanitize(suggestName || 'download.bin');
- let target;
- if (askSavePath) {
+ if (!download) {
+ download = {
+ key,
+ senderId: event.sender.id,
+ requestId: progressId,
+ state: 'preparing',
+ control: '',
+ controller: null,
+ partial: '',
+ target: '',
+ defaultName: '',
+ askSavePath,
+ receivedBytes: 0,
+ totalBytes: null,
+ validator: '',
+ url: parsedUrl.toString(),
+ suggestName: String(suggestName || ''),
+ entryId,
+ extraHeaders: { ...(extraHeaders || {}) },
+ meta
+ };
+ downloadSessions.set(key, download);
+ }
+ if (download.url !== parsedUrl.toString()) throw new Error('续传链接与原任务不一致');
+
+ if (download.askSavePath && !download.target) {
const save = await dialog.showSaveDialog(liveWindow(), {
title: '保存文件',
defaultPath: path.join(library.filesDir(), suggested)
});
- if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } };
- target = save.filePath;
+ if (save.canceled || !save.filePath) {
+ downloadSessions.delete(key);
+ return { ok: true, data: { canceled: true } };
+ }
+ download.target = save.filePath;
+ }
+ if (download.control === 'delete') {
+ downloadSessions.delete(key);
+ return { ok: true, data: { deleted: true } };
}
- const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) };
+ const headers = { 'User-Agent': DL_UA, ...download.extraHeaders };
headers['Referer'] = parsedUrl.origin + '/';
+ const resumeBytes = download.partial && fs.existsSync(download.partial)
+ ? fs.statSync(download.partial).size
+ : 0;
+ download.receivedBytes = resumeBytes;
+ if (resumeBytes > 0) {
+ headers['Range'] = `bytes=${resumeBytes}-`;
+ if (download.validator) headers['If-Range'] = download.validator;
+ }
+
const ac = new AbortController();
+ download.controller = ac;
+ download.control = '';
+ download.state = 'running';
const timer = setTimeout(() => ac.abort(), 30000);
try {
- res = await fetchWithProxy(parsedUrl.toString(), {
+ res = await fetchWithProxy(download.url, {
redirect: 'follow',
headers,
signal: ac.signal
@@ -661,30 +747,58 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
} finally {
clearTimeout(timer);
}
+ if (download.control) throw new Error('下载已中止');
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
- const declaredSize = Number(res.headers.get('content-length'));
- const totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null;
- let receivedBytes = 0;
- let lastProgressAt = 0;
- sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? 0 : null });
- const respName = filenameFromResponse(res, suggestName);
- const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName);
- const defaultName = library.sanitize(hasExt ? suggestName : respName);
+ const resumed = resumeBytes > 0 && res.status === 206;
+ if (resumeBytes > 0 && !resumed) {
+ removeDownloadPartial(download);
+ download.receivedBytes = 0;
+ }
+ const declaredSize = Number(res.headers.get('content-length'));
+ let totalBytes = Number.isFinite(declaredSize) && declaredSize > 0
+ ? declaredSize + (resumed ? resumeBytes : 0)
+ : null;
+ const contentRange = res.headers.get('content-range') || '';
+ const rangeMatch = contentRange.match(/^bytes\s+(\d+)-\d+\/(\d+|\*)$/i);
+ if (resumed && (!rangeMatch || Number(rangeMatch[1]) !== resumeBytes)) {
+ throw new Error('远端服务器返回了错误的断点位置,请删除任务后重新下载');
+ }
+ if (rangeMatch && rangeMatch[2] !== '*') totalBytes = Number(rangeMatch[2]);
+ if (resumed && download.totalBytes && totalBytes && download.totalBytes !== totalBytes) {
+ throw new Error('远端文件在暂停期间发生变化,请删除任务后重新下载');
+ }
+ if (!resumed) download.validator = res.headers.get('etag') || res.headers.get('last-modified') || '';
+ download.totalBytes = totalBytes;
+ let receivedBytes = resumed ? resumeBytes : 0;
+ let lastProgressAt = 0;
+ sendProgress({
+ receivedBytes,
+ totalBytes,
+ percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null
+ });
+
+ const respName = filenameFromResponse(res, download.suggestName);
+ const hasExt = download.suggestName && /\.[a-z0-9]{2,5}$/i.test(download.suggestName);
+ if (!download.defaultName) {
+ download.defaultName = library.sanitize(hasExt ? download.suggestName : respName);
+ }
const contentType = res.headers.get('content-type') || '';
- const expectedExt = path.extname(target || defaultName).toLowerCase();
+ const expectedExt = path.extname(download.target || download.defaultName).toLowerCase();
if (/text\/html|application\/json/i.test(contentType)
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
throw new Error('下载地址返回了网页而不是文献文件');
}
- if (!target) target = library.allocFilePath(defaultName);
+ if (!download.target) download.target = library.allocFilePath(download.defaultName);
- fs.mkdirSync(path.dirname(target), { recursive: true });
- partial = path.join(
- path.dirname(target),
- `.${path.basename(target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
- );
+ fs.mkdirSync(path.dirname(download.target), { recursive: true });
+ if (!download.partial) {
+ download.partial = path.join(
+ path.dirname(download.target),
+ `.${path.basename(download.target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
+ );
+ }
if (!res.body) throw new Error('下载响应没有文件内容');
let transferTimer;
const refreshTransferTimer = () => {
@@ -695,6 +809,7 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
transform(chunk, encoding, callback) {
refreshTransferTimer();
receivedBytes += chunk.length;
+ download.receivedBytes = receivedBytes;
const now = Date.now();
if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) {
lastProgressAt = now;
@@ -710,71 +825,127 @@ ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHe
refreshTransferTimer();
bodyHandled = true;
try {
- await pipeline(Readable.fromWeb(res.body), activity, fs.createWriteStream(partial, { flags: 'wx' }));
+ await pipeline(
+ Readable.fromWeb(res.body),
+ activity,
+ fs.createWriteStream(download.partial, { flags: resumed ? 'a' : 'wx' })
+ );
} finally {
clearTimeout(transferTimer);
}
- if (askSavePath) {
- const backup = `${target}.${process.pid}-${Date.now()}.bak`;
+ if (download.control) throw new Error('下载已中止');
+
+ if (download.askSavePath) {
+ const backup = `${download.target}.${process.pid}-${Date.now()}.bak`;
let backedUp = false;
try {
- if (fs.existsSync(target)) {
- fs.renameSync(target, backup);
+ if (fs.existsSync(download.target)) {
+ fs.renameSync(download.target, backup);
backedUp = true;
}
- fs.renameSync(partial, target);
- partial = '';
+ fs.renameSync(download.partial, download.target);
+ download.partial = '';
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
}
} catch (e) {
try {
- if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
+ if (backedUp && !fs.existsSync(download.target) && fs.existsSync(backup)) {
+ fs.renameSync(backup, download.target);
+ }
} catch (rollbackError) { /* ignore */ }
throw e;
}
} else {
for (;;) {
try {
- fs.linkSync(partial, target);
+ fs.linkSync(download.partial, download.target);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
- target = library.allocFilePath(defaultName);
+ download.target = library.allocFilePath(download.defaultName);
}
}
- try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
- partial = '';
+ try { fs.unlinkSync(download.partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
+ download.partial = '';
}
sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
// 落库:优先挂到已有条目,否则用 meta 新建
- let id = entryId;
- if (!id && meta) {
- const existing = meta.sourceId && meta.sourcePostId
- ? library.findBySource(meta.sourceId, meta.sourcePostId) : null;
- id = existing ? existing.id : library.add(meta).id;
+ let id = download.entryId;
+ if (!id && download.meta) {
+ const existing = download.meta.sourceId && download.meta.sourcePostId
+ ? library.findBySource(download.meta.sourceId, download.meta.sourcePostId) : null;
+ id = existing ? existing.id : library.add(download.meta).id;
}
- const entry = id ? library.attachFile(id, target) : null;
+ const entry = id ? library.attachFile(id, download.target) : null;
if (entry) coverGenerator.ensure(entry.id).catch(() => {});
- return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } };
+ downloadSessions.delete(key);
+ return {
+ ok: true,
+ data: {
+ path: download.target,
+ name: path.basename(download.target),
+ entryId: id || null,
+ entry,
+ receivedBytes,
+ totalBytes
+ }
+ };
} catch (e) {
if (res && res.body && !bodyHandled) {
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
}
- if (partial) {
- try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ }
+ if (download && download.control === 'pause') {
+ download.state = 'paused';
+ download.controller = null;
+ if (download.partial && fs.existsSync(download.partial)) {
+ download.receivedBytes = fs.statSync(download.partial).size;
+ }
+ return {
+ ok: true,
+ data: {
+ paused: true,
+ receivedBytes: download.receivedBytes,
+ totalBytes: download.totalBytes
+ }
+ };
}
+ if (download && download.control === 'delete') {
+ removeDownloadPartial(download);
+ downloadSessions.delete(key);
+ return { ok: true, data: { deleted: true } };
+ }
+ removeDownloadPartial(download);
+ downloadSessions.delete(key);
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
return { ok: false, error: '下载超时,请检查网络或代理设置' };
}
return { ok: false, error: e.message || String(e) };
} finally {
+ if (download) download.controller = null;
activeDownloads--;
}
});
+ipcMain.handle('download:pause', (event, requestId) => wrap(() => {
+ const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
+ const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
+ if (!download || download.state !== 'running' || !download.controller) return false;
+ download.control = 'pause';
+ download.controller.abort();
+ return true;
+}));
+
+ipcMain.handle('download:delete', (event, requestId) => wrap(() => {
+ const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
+ const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
+ if (!download) return false;
+ discardDownloadSession(download);
+ return true;
+}));
+
// 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次,
// 在资源管理器里定位该文件,而不是静默失败。
ipcMain.handle('shell:openPath', async (_e, p) => {
diff --git a/package-lock.json b/package-lock.json
index 7f5d3b9..35e751b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "peoplelib",
- "version": "1.1.0",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peoplelib",
- "version": "1.1.0",
+ "version": "2.0.0",
"license": "MIT",
"dependencies": {
"foliate-js": "1.0.1",
diff --git a/package.json b/package.json
index f1d7ab9..575270b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "peoplelib",
- "version": "1.3.0",
+ "version": "2.0.0",
"description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library)",
"main": "main.js",
"author": "peoplelib",
diff --git a/preload.js b/preload.js
index 5f61983..f66e56f 100644
--- a/preload.js
+++ b/preload.js
@@ -1,8 +1,7 @@
const { contextBridge, ipcRenderer } = require('electron');
let downloadSeq = 0;
-function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
- const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`;
+function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
const listener = (_event, data) => {
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
@@ -13,6 +12,11 @@ function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress)
.finally(() => ipcRenderer.removeListener('download:progress', listener));
}
+function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
+ const requestId = `dl_${Date.now().toString(36)}_${(++downloadSeq).toString(36)}`;
+ return runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress);
+}
+
function captureReaderRect(rect) {
const value = rect && typeof rect === 'object' ? rect : {};
const area = {
@@ -89,6 +93,11 @@ contextBridge.exposeInMainWorld('api', {
}
},
downloadFile,
+ downloads: {
+ run: runDownload,
+ pause: (requestId) => ipcRenderer.invoke('download:pause', requestId),
+ delete: (requestId) => ipcRenderer.invoke('download:delete', requestId)
+ },
zlib: {
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
diff --git a/site/DEPLOY.md b/site/DEPLOY.md
index 5ad660a..96dfebe 100644
--- a/site/DEPLOY.md
+++ b/site/DEPLOY.md
@@ -260,7 +260,7 @@ DNS 变更最多需要 24 小时传播(查证)。多数情况几分钟就好
### 版本号会过期
-页脚、首页与下载页都写了 **1.3.0**,取自 `package.json` 的 `version` 字段。发新版时记得改,一共出现在这几处:
+页脚、首页与下载页都写了 **2.0.0**,取自 `package.json` 的 `version` 字段。发新版时记得改,一共出现在这几处:
```powershell
Select-String -Path D:\my_git\peoplelib\site\*.html -Pattern '1\.3\.0'
diff --git a/site/download.html b/site/download.html
index d703abd..53b5882 100644
--- a/site/download.html
+++ b/site/download.html
@@ -56,7 +56,7 @@
获取 PeopleLib
所有正式版本都通过项目的 GitHub Releases 发布,不通过 npm 或应用商店分发。
- 当前版本 1.3.0,MIT 许可,免费。
+ 当前版本 2.0.0,MIT 许可,免费。
@@ -222,7 +222,7 @@
版本
- - PeopleLib 1.3.0
+ - PeopleLib 2.0.0
- MIT 许可
diff --git a/site/faq.html b/site/faq.html
index d9ad541..2a0f3a4 100644
--- a/site/faq.html
+++ b/site/faq.html
@@ -315,7 +315,7 @@
版本
- - PeopleLib 1.3.0
+ - PeopleLib 2.0.0
- MIT 许可
diff --git a/site/features.html b/site/features.html
index af3b145..eb927bc 100644
--- a/site/features.html
+++ b/site/features.html
@@ -306,7 +306,7 @@
版本
- - PeopleLib 1.3.0
+ - PeopleLib 2.0.0
- MIT 许可
diff --git a/site/index.html b/site/index.html
index b605cb0..b2f131b 100644
--- a/site/index.html
+++ b/site/index.html
@@ -64,7 +64,7 @@
下载 PeopleLib
查看功能
- 当前版本 1.3.0,MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。
+ 当前版本 2.0.0,MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。
@@ -265,7 +265,7 @@
版本
- - PeopleLib 1.3.0
+ - PeopleLib 2.0.0
- MIT 许可
diff --git a/site/privacy.html b/site/privacy.html
index e5c876e..5200e48 100644
--- a/site/privacy.html
+++ b/site/privacy.html
@@ -277,7 +277,7 @@
版本
- - PeopleLib 1.3.0
+ - PeopleLib 2.0.0
- MIT 许可
diff --git a/src/_test/electron/download.integration.js b/src/_test/electron/download.integration.js
index 63541f4..958a9d2 100644
--- a/src/_test/electron/download.integration.js
+++ b/src/_test/electron/download.integration.js
@@ -20,10 +20,15 @@ const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
`unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
));
+const rangedChunks = Array.from({ length: 12 }, (_unused, index) => Buffer.from(
+ `ranged-chunk-${index}-` + String.fromCharCode(75 + (index % 10)).repeat(16 * 1024)
+));
const knownPayload = Buffer.concat(knownChunks);
const unknownPayload = Buffer.concat(unknownChunks);
+const rangedPayload = Buffer.concat(rangedChunks);
const results = [];
+const rangedRequests = [];
let server;
let testWindow;
@@ -35,6 +40,15 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+async function waitForRenderer(expression, timeout = 8000) {
+ const started = Date.now();
+ while (Date.now() - started < timeout) {
+ if (await testWindow.webContents.executeJavaScript(`Boolean(${expression})`)) return true;
+ await wait(50);
+ }
+ return false;
+}
+
function serveChunks(response, chunks, contentLength) {
const headers = {
'Content-Type': 'text/plain; charset=utf-8',
@@ -58,6 +72,46 @@ function serveChunks(response, chunks, contentLength) {
sendNext();
}
+function serveRange(request, response) {
+ const match = String(request.headers.range || '').match(/^bytes=(\d+)-$/);
+ const start = match ? Number(match[1]) : 0;
+ rangedRequests.push({ url: request.url, start });
+ if (!Number.isSafeInteger(start) || start < 0 || start >= rangedPayload.length) {
+ response.writeHead(416, {
+ 'Content-Range': `bytes */${rangedPayload.length}`,
+ Connection: 'close'
+ });
+ response.end();
+ return;
+ }
+ const chunks = [];
+ for (let offset = start; offset < rangedPayload.length; offset += 16 * 1024) {
+ chunks.push(rangedPayload.subarray(offset, Math.min(rangedPayload.length, offset + 16 * 1024)));
+ }
+ const headers = {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Content-Disposition': 'attachment; filename="ranged-fixture.txt"',
+ 'Content-Length': String(rangedPayload.length - start),
+ 'Accept-Ranges': 'bytes',
+ ETag: '"ranged-fixture-v1"',
+ Connection: 'close'
+ };
+ if (start) headers['Content-Range'] = `bytes ${start}-${rangedPayload.length - 1}/${rangedPayload.length}`;
+ response.writeHead(start ? 206 : 200, headers);
+ if (response.socket) response.socket.setNoDelay(true);
+ let index = 0;
+ const sendNext = () => {
+ if (response.destroyed || response.writableEnded) return;
+ if (index >= chunks.length) {
+ response.end();
+ return;
+ }
+ response.write(chunks[index++]);
+ setTimeout(sendNext, 130);
+ };
+ sendNext();
+}
+
function monotonic(events) {
return events.every((event, index) => {
const current = Number(event.receivedBytes);
@@ -139,6 +193,8 @@ async function run() {
serveChunks(response, knownChunks, knownPayload.length);
} else if (request.url === '/unknown.txt') {
serveChunks(response, unknownChunks, null);
+ } else if (request.url.startsWith('/range.txt')) {
+ serveRange(request, response);
} else {
response.writeHead(404, { Connection: 'close' });
response.end('not found');
@@ -210,7 +266,12 @@ async function run() {
})()`);
check('preload 暴露下载 API',
- await testWindow.webContents.executeJavaScript('typeof window.api.downloadFile === "function"'));
+ await testWindow.webContents.executeJavaScript(
+ 'typeof window.api.downloadFile === "function"'
+ + ' && typeof window.api.downloads.run === "function"'
+ + ' && typeof window.api.downloads.pause === "function"'
+ + ' && typeof window.api.downloads.delete === "function"'
+ ));
const port = server.address().port;
const known = await downloadInRenderer(
@@ -294,6 +355,188 @@ async function run() {
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
unknownEntry && unknownEntry.id);
+ const directPageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
+ await testWindow.loadFile(path.join(ROOT, 'src', 'ui', 'index.html'));
+ await testWindow.webContents.executeJavaScript(`(() => {
+ window.__pageErrors = [];
+ addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
+ addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
+ })()`);
+ const centerReady = await waitForRenderer(
+ 'window.DownloadCenter && document.getElementById("taskCenterBtn").onclick'
+ );
+ check('主界面加载任务中心', centerReady);
+
+ const centerStarted = await testWindow.webContents.executeJavaScript(`(() => {
+ window.__centerResult = null;
+ window.DownloadCenter.start({
+ key: 'integration-center-download',
+ url: ${JSON.stringify(`http://127.0.0.1:${port}/known.txt`)},
+ suggestName: 'center-fixture.txt',
+ meta: {
+ title: 'Task Center Download',
+ authors: ['Integration Fixture'],
+ sourceId: 'download-test',
+ sourcePostId: 'center'
+ }
+ }).then((result) => { window.__centerResult = result; });
+ document.getElementById('taskCenterBtn').click();
+ return true;
+ })()`);
+ check('任务中心可发起下载', centerStarted);
+ const centerRunning = await waitForRenderer(
+ 'document.querySelector(".task-center-item.running")'
+ );
+ check('任务中心显示进行中任务', centerRunning);
+ check('进行中任务显示实时字节进度',
+ await waitForRenderer(
+ 'document.querySelector(".task-center-item.running .task-center-status")'
+ + ' && /已下载|%/.test(document.querySelector(".task-center-item.running .task-center-status").textContent)'
+ ));
+
+ await testWindow.webContents.executeJavaScript(
+ 'document.querySelector(".tab[data-tab=\\"settings\\"]").click()'
+ );
+ check('切换页面后任务中心仍保留下载',
+ await testWindow.webContents.executeJavaScript(
+ '!document.getElementById("settingsTab").classList.contains("hidden")'
+ + ' && !!document.querySelector(".task-center-item.running")'
+ ));
+
+ const centerComplete = await waitForRenderer(
+ 'window.__centerResult && document.querySelector(".task-center-item.complete")',
+ 10000
+ );
+ check('切换页面后下载继续并完成', centerComplete);
+ const centerState = await testWindow.webContents.executeJavaScript(`(() => {
+ const item = document.querySelector('.task-center-item.complete');
+ return {
+ result: window.__centerResult,
+ state: item && item.querySelector('.task-center-state').textContent,
+ hasOpen: !!(item && item.querySelector('[data-task-action="open"]')),
+ hasReveal: !!(item && item.querySelector('[data-task-action="reveal"]'))
+ };
+ })()`);
+ check('已完成任务提供打开与定位入口',
+ centerState.state === '已完成' && centerState.hasOpen && centerState.hasReveal,
+ JSON.stringify(centerState));
+ const centerPath = centerState.result && centerState.result.ok && centerState.result.data.path;
+ check('任务中心下载字节完全一致',
+ !!centerPath && fs.existsSync(centerPath) && fs.readFileSync(centerPath).equals(knownPayload),
+ centerPath || '');
+ const centerEntry = centerState.result && centerState.result.ok
+ ? library.get(centerState.result.data.entryId) : null;
+ check('任务中心下载自动挂载到书库',
+ !!centerEntry && centerEntry.title === 'Task Center Download'
+ && centerEntry.files.some((file) => file.path === centerPath && file.exists),
+ centerEntry && centerEntry.id);
+
+ await testWindow.webContents.executeJavaScript(`(() => {
+ window.__resumeFirst = null;
+ window.DownloadCenter.start({
+ key: 'integration-resume-download',
+ url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?resume=1`)},
+ suggestName: 'resume-fixture.txt',
+ meta: {
+ title: 'Resume Download',
+ authors: [],
+ sourceId: 'download-test',
+ sourcePostId: 'resume'
+ }
+ }).then((result) => { window.__resumeFirst = result; });
+ })()`);
+ const resumeProgress = await waitForRenderer(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
+ return item && item.classList.contains('running')
+ && parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
+ })()`);
+ check('可续传任务开始下载并产生进度', resumeProgress);
+ await testWindow.webContents.executeJavaScript(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
+ item.querySelector('[data-task-action="pause"]').click();
+ })()`);
+ const paused = await waitForRenderer(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
+ return item && item.classList.contains('paused')
+ && item.querySelector('.task-center-state').textContent === '已暂停'
+ && item.querySelector('[data-task-action="resume"]');
+ })()`);
+ check('任务中心可暂停未完成下载', paused);
+
+ const filesDir = path.join(LIBRARY_DIR, 'files');
+ const pausedParts = fs.readdirSync(filesDir).filter((name) => name.endsWith('.part'));
+ const pausedPart = pausedParts.length === 1 ? path.join(filesDir, pausedParts[0]) : '';
+ const pausedSize = pausedPart && fs.existsSync(pausedPart) ? fs.statSync(pausedPart).size : 0;
+ check('暂停保留未完成文件作为续传断点',
+ pausedParts.length === 1 && pausedSize > 0 && pausedSize < rangedPayload.length,
+ `文件=${pausedParts.join(',')} 大小=${pausedSize}`);
+
+ await testWindow.webContents.executeJavaScript(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
+ item.querySelector('[data-task-action="resume"]').click();
+ })()`);
+ const resumed = await waitForRenderer(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
+ return item && item.classList.contains('complete');
+ })()`, 10000);
+ check('任务中心可继续已暂停下载并完成', resumed);
+ const resumeRequests = rangedRequests.filter((request) => request.url.includes('resume=1'));
+ check('继续下载从临时文件末尾发送 Range',
+ resumeRequests.length >= 2 && resumeRequests[1].start === pausedSize && pausedSize > 0,
+ JSON.stringify(resumeRequests));
+ const resumeEntry = library.findBySource('download-test', 'resume');
+ const resumePath = resumeEntry && resumeEntry.files[0] && resumeEntry.files[0].path;
+ check('断点续传后的文件字节完全一致',
+ !!resumePath && fs.existsSync(resumePath)
+ && fs.readFileSync(resumePath).equals(rangedPayload),
+ resumePath || '');
+ check('断点续传完成后清理临时文件',
+ fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
+
+ await testWindow.webContents.executeJavaScript(`(() => {
+ window.__deleteResult = null;
+ window.DownloadCenter.start({
+ key: 'integration-delete-download',
+ url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?delete=1`)},
+ suggestName: 'delete-fixture.txt',
+ meta: {
+ title: 'Delete Download',
+ authors: [],
+ sourceId: 'download-test',
+ sourcePostId: 'delete'
+ }
+ }).then((result) => { window.__deleteResult = result; });
+ })()`);
+ check('待删除任务先产生部分内容',
+ await waitForRenderer(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
+ return item && item.classList.contains('running')
+ && parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
+ })()`));
+ await testWindow.webContents.executeJavaScript(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
+ item.querySelector('[data-task-action="delete"]').click();
+ })()`);
+ const deleted = await waitForRenderer(`(() => {
+ const item = [...document.querySelectorAll('.task-center-item')]
+ .find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
+ return !item && window.__deleteResult && window.__deleteResult.ok
+ && window.__deleteResult.data.deleted === true;
+ })()`);
+ check('任务中心可删除进行中的下载', deleted);
+ await wait(150);
+ check('删除未完成任务会清理临时文件',
+ fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
+ check('删除未完成任务不会创建书库条目',
+ !library.findBySource('download-test', 'delete'));
+
const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
const backgroundValue = declaration(downloadedRule, 'background');
@@ -313,8 +556,8 @@ async function run() {
await wait(100);
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
check('下载流程没有渲染器错误',
- rendererErrors.length === 0 && pageErrors.length === 0,
- rendererErrors.concat(pageErrors).join(' | '));
+ rendererErrors.length === 0 && directPageErrors.length === 0 && pageErrors.length === 0,
+ rendererErrors.concat(directPageErrors, pageErrors).join(' | '));
} catch (error) {
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
} finally {
diff --git a/src/_test/main.test.js b/src/_test/main.test.js
index 1422510..de11ea7 100644
--- a/src/_test/main.test.js
+++ b/src/_test/main.test.js
@@ -285,6 +285,12 @@ test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/);
assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/);
+ assert.match(mainSrc, /headers\['Range'\]\s*=\s*`bytes=\$\{resumeBytes\}-`/);
+ assert.match(mainSrc, /res\.status\s*===\s*206/);
+ assert.match(mainSrc, /ipcMain\.handle\('download:pause'/);
+ assert.match(mainSrc, /ipcMain\.handle\('download:delete'/);
+ assert.match(mainSrc, /downloadSessionKey\(event\.sender\.id,\s*id\)/);
+ assert.match(mainSrc, /removeDownloadPartial\(download\)/);
});
test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js
index 457c9b9..2ce9354 100644
--- a/src/_test/ui.test.js
+++ b/src/_test/ui.test.js
@@ -165,9 +165,32 @@ test('书库页提供可管理标签目录和整理多选下拉', () => {
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
});
-test('下载区展示进度且完成按钮使用高对比绿色底色', () => {
+test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
+ const center = fs.readFileSync(path.join(__dirname, '..', 'ui', 'download-center.js'), 'utf8');
+ const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
+ const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
+ assert.match(html, /id="taskCenterBtn"/);
+ assert.match(html, /id="taskCenterPanel"/);
+ assert.ok(html.indexOf('download-center.js') < html.indexOf('views/browse.js'));
+ assert.match(app, /DownloadCenter\.init\(\)/);
+ assert.match(browse, /window\.DownloadCenter\.start\(\{/);
+ assert.doesNotMatch(browse, /await window\.api\.downloadFile/);
+ assert.match(center, /window\.api\.downloads\.run\(/);
+ assert.match(center, /window\.api\.downloads\.pause\(/);
+ assert.match(center, /window\.api\.downloads\.delete\(/);
+ assert.match(center, /data-task-action="pause"/);
+ assert.match(center, /data-task-action="resume"/);
+ assert.match(center, /data-task-action="delete"/);
+ assert.match(preload, /pause:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:pause'/);
+ assert.match(preload, /delete:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:delete'/);
+ assert.match(center, /task\.status = 'complete'/);
+ assert.match(center, /task\.status = 'failed'/);
+ assert.match(center, /data-task-action="open"/);
+ assert.match(css, /\.task-center-panel\s*\{/);
+ assert.match(css, /\.task-center-badge\s*\{/);
assert.match(browse, /createDownloadProgress/);
assert.match(browse, /updateDownloadProgress/);
assert.match(browse, /classList\.add\('downloaded'\)/);
diff --git a/src/ui/app.js b/src/ui/app.js
index 69aa53f..e675ff1 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -45,6 +45,7 @@ document.querySelectorAll('.tab').forEach((t) => {
t.onclick = () => switchTab(t.dataset.tab);
});
+DownloadCenter.init();
Browse.init();
Library.init();
Notes.init();
diff --git a/src/ui/download-center.js b/src/ui/download-center.js
new file mode 100644
index 0000000..5650aff
--- /dev/null
+++ b/src/ui/download-center.js
@@ -0,0 +1,341 @@
+(() => {
+ const tasks = [];
+ const MAX_HISTORY = 30;
+ let seq = 0;
+ let button = null;
+ let badge = null;
+ let panel = null;
+ let list = null;
+ let summary = null;
+ let clearButton = null;
+
+ 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 isActive(task) {
+ return ['pending', 'running', 'pausing', 'deleting'].includes(task.status);
+ }
+
+ function isUnfinished(task) {
+ return isActive(task) || task.status === 'paused';
+ }
+
+ function statusText(task) {
+ if (task.status === 'pending') return '准备下载…';
+ if (task.status === 'pausing') return '正在暂停…';
+ if (task.status === 'paused') {
+ return task.receivedBytes ? `已暂停 · ${formatBytes(task.receivedBytes)}` : '已暂停';
+ }
+ if (task.status === 'deleting') return '正在删除…';
+ if (task.status === 'failed') return task.error || '下载失败';
+ if (task.status === 'canceled') return '已取消';
+ if (task.status === 'complete') {
+ return task.receivedBytes ? `下载完成 · ${formatBytes(task.receivedBytes)}` : '下载完成';
+ }
+ if (task.totalBytes && task.percent != null) {
+ return `${Math.round(task.percent * 100)}% · ${formatBytes(task.receivedBytes)} / ${formatBytes(task.totalBytes)}`;
+ }
+ return task.receivedBytes ? `${formatBytes(task.receivedBytes)} 已下载` : '正在连接…';
+ }
+
+ function taskHtml(task) {
+ const ratio = task.status === 'complete'
+ ? 1
+ : (task.percent == null ? null : Math.max(0, Math.min(1, task.percent)));
+ const progressClass = ratio == null && ['pending', 'running', 'pausing'].includes(task.status)
+ ? ' indeterminate'
+ : '';
+ const width = ratio == null ? 0 : Math.round(ratio * 100);
+ const book = task.bookTitle && task.bookTitle !== task.name
+ ? `${escapeHtml(task.bookTitle)}
`
+ : '';
+ let actions = '';
+ if (task.status === 'complete') {
+ actions = `
+
+
+
+
`;
+ } else if (task.status === 'running' || task.status === 'pending') {
+ actions = `
+
+
+
`;
+ } else if (task.status === 'paused') {
+ actions = `
+
+
+
`;
+ } else if (!isActive(task)) {
+ actions = ``;
+ }
+ const state = task.status === 'complete'
+ ? '已完成'
+ : (task.status === 'failed'
+ ? '失败'
+ : (task.status === 'canceled'
+ ? '已取消'
+ : (task.status === 'paused' ? '已暂停' : '下载中')));
+ return `
+
+
+
${escapeHtml(task.name)}
+
${state}
+
+ ${book}
+
+
+ `;
+ }
+
+ function render() {
+ if (!button) return;
+ const active = tasks.filter(isActive).length;
+ const paused = tasks.filter((task) => task.status === 'paused').length;
+ const unread = tasks.filter((task) => task.unread).length;
+ const count = active + paused + unread;
+ badge.textContent = String(Math.min(99, count));
+ badge.classList.toggle('hidden', count === 0);
+ badge.classList.toggle('has-result', active === 0 && unread > 0);
+ button.title = active
+ ? `任务中心,${active} 个下载中`
+ : (paused
+ ? `任务中心,${paused} 个已暂停`
+ : (unread ? `任务中心,${unread} 个新结果` : '任务中心'));
+ button.setAttribute('aria-label', button.title);
+
+ const complete = tasks.filter((task) => task.status === 'complete').length;
+ const failed = tasks.filter((task) => task.status === 'failed').length;
+ summary.textContent = active
+ ? `${active} 个下载中${paused ? `,${paused} 个已暂停` : ''}${complete ? `,${complete} 个已完成` : ''}`
+ : (tasks.length
+ ? `${paused ? `${paused} 个已暂停,` : ''}${complete} 个已完成${failed ? `,${failed} 个失败` : ''}`
+ : '下载任务会显示在这里');
+ list.innerHTML = tasks.length
+ ? tasks.map(taskHtml).join('')
+ : '暂无下载任务
';
+ clearButton.classList.toggle('hidden', !tasks.some((task) => !isUnfinished(task)));
+ }
+
+ function setPanelOpen(open) {
+ panel.classList.toggle('hidden', !open);
+ button.setAttribute('aria-expanded', String(open));
+ if (open) {
+ tasks.forEach((task) => { task.unread = false; });
+ render();
+ }
+ }
+
+ function pruneHistory() {
+ let terminal = tasks.filter((task) => !isUnfinished(task)).length;
+ for (let i = tasks.length - 1; i >= 0 && terminal > MAX_HISTORY; i--) {
+ if (isUnfinished(tasks[i])) continue;
+ tasks.splice(i, 1);
+ terminal--;
+ }
+ }
+
+ function updateProgress(task, data, callback) {
+ if (!['pausing', 'deleting'].includes(task.status)) task.status = 'running';
+ task.receivedBytes = Math.max(0, Number(data && data.receivedBytes) || 0);
+ const total = Number(data && data.totalBytes);
+ task.totalBytes = Number.isFinite(total) && total > 0 ? total : null;
+ const ratio = Number(data && data.percent);
+ task.percent = data && data.percent != null && Number.isFinite(ratio)
+ ? Math.max(0, Math.min(1, ratio))
+ : null;
+ render();
+ if (typeof callback === 'function') {
+ try { callback(data); } catch (e) { /* 页面内进度异常不影响全局任务 */ }
+ }
+ }
+
+ async function run(task, input, onProgress) {
+ task.status = 'running';
+ task.error = '';
+ render();
+ let result;
+ try {
+ result = await window.api.downloads.run(
+ task.requestId,
+ input.url,
+ input.suggestName,
+ input.entryId,
+ input.extraHeaders,
+ input.meta,
+ (data) => updateProgress(task, data, onProgress)
+ );
+ } catch (error) {
+ result = { ok: false, error: (error && error.message) || String(error) };
+ }
+
+ if (result && result.ok && result.data && result.data.canceled) {
+ task.status = 'canceled';
+ } else if (result && result.ok && result.data && result.data.paused) {
+ task.status = 'paused';
+ task.receivedBytes = Number(result.data.receivedBytes) || task.receivedBytes;
+ task.totalBytes = Number(result.data.totalBytes) || task.totalBytes;
+ task.percent = task.totalBytes ? task.receivedBytes / task.totalBytes : task.percent;
+ task.promise = null;
+ render();
+ return result;
+ } else if (result && result.ok && result.data && result.data.deleted) {
+ const index = tasks.indexOf(task);
+ if (index >= 0) tasks.splice(index, 1);
+ render();
+ return result;
+ } else if (!result || !result.ok) {
+ task.status = 'failed';
+ task.error = (result && result.error) || '下载失败';
+ } else {
+ task.status = 'complete';
+ task.percent = 1;
+ task.path = result.data.path || '';
+ task.receivedBytes = Math.max(task.receivedBytes, Number(result.data.receivedBytes) || 0);
+ if (window.Library) window.Library.markDirty();
+ }
+ task.key = '';
+ task.input = null;
+ task.promise = null;
+ task.unread = panel.classList.contains('hidden');
+ pruneHistory();
+ render();
+ return result;
+ }
+
+ function start(input) {
+ const key = String(input && input.key || '');
+ const existing = key && tasks.find((task) => task.key === key && isUnfinished(task));
+ if (existing) {
+ if (existing.status === 'paused') {
+ existing.input.onProgress = input && input.onProgress;
+ existing.promise = run(existing, existing.input, existing.input.onProgress);
+ }
+ return existing.promise || Promise.resolve({ ok: true, data: { paused: true } });
+ }
+
+ const meta = input && input.meta || {};
+ const task = {
+ id: `download_${Date.now().toString(36)}_${(++seq).toString(36)}`,
+ requestId: `task_${Date.now().toString(36)}_${seq.toString(36)}`,
+ key,
+ name: String(input && input.suggestName || meta.title || '未命名下载'),
+ bookTitle: String(meta.title || ''),
+ status: 'pending',
+ receivedBytes: 0,
+ totalBytes: null,
+ percent: null,
+ error: '',
+ path: '',
+ unread: false,
+ promise: null,
+ input: {
+ url: String(input && input.url || ''),
+ suggestName: String(input && input.suggestName || ''),
+ entryId: input && input.entryId,
+ extraHeaders: input && input.extraHeaders,
+ meta,
+ onProgress: input && input.onProgress
+ }
+ };
+ tasks.unshift(task);
+ pruneHistory();
+ render();
+ task.promise = run(task, task.input, task.input.onProgress);
+ return task.promise;
+ }
+
+ async function taskAction(action, id) {
+ const index = tasks.findIndex((task) => task.id === id);
+ if (index < 0) return;
+ const task = tasks[index];
+ if (action === 'open' && task.path) {
+ const result = await window.api.openPath(task.path);
+ if (!result.ok) await confirmModal('打开失败', result.error || '无法打开该文件');
+ return;
+ }
+ if (action === 'reveal' && task.path) {
+ window.api.showItem(task.path);
+ return;
+ }
+ if (action === 'pause' && ['pending', 'running'].includes(task.status)) {
+ const previousStatus = task.status;
+ task.status = 'pausing';
+ render();
+ const result = await window.api.downloads.pause(task.requestId);
+ if ((!result || !result.ok || !result.data) && task.status === 'pausing') {
+ task.status = previousStatus;
+ render();
+ }
+ return;
+ }
+ if (action === 'resume' && task.status === 'paused' && task.input) {
+ task.promise = run(task, task.input, task.input.onProgress);
+ return;
+ }
+ if (action === 'delete' && isUnfinished(task)) {
+ const previousStatus = task.status;
+ task.status = 'deleting';
+ render();
+ const result = await window.api.downloads.delete(task.requestId);
+ if ((!result || !result.ok || !result.data) && task.status === 'deleting') {
+ task.status = previousStatus;
+ task.error = (result && result.error) || '';
+ render();
+ return;
+ }
+ if (!task.promise) {
+ tasks.splice(index, 1);
+ render();
+ }
+ return;
+ }
+ if (action === 'remove' && !isUnfinished(task)) {
+ tasks.splice(index, 1);
+ render();
+ }
+ }
+
+ function init() {
+ button = $('taskCenterBtn');
+ badge = $('taskCenterBadge');
+ panel = $('taskCenterPanel');
+ list = $('taskCenterList');
+ summary = $('taskCenterSummary');
+ clearButton = $('taskCenterClear');
+
+ button.onclick = () => setPanelOpen(panel.classList.contains('hidden'));
+ list.onclick = (event) => {
+ const actionButton = event.target.closest('[data-task-action]');
+ const item = event.target.closest('[data-task-id]');
+ if (actionButton && item) taskAction(actionButton.dataset.taskAction, item.dataset.taskId);
+ };
+ clearButton.onclick = () => {
+ for (let i = tasks.length - 1; i >= 0; i--) {
+ if (!isUnfinished(tasks[i])) tasks.splice(i, 1);
+ }
+ render();
+ };
+ document.addEventListener('pointerdown', (event) => {
+ if (!panel.classList.contains('hidden') && !event.target.closest('.task-center-wrap')) {
+ setPanelOpen(false);
+ }
+ });
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && !panel.classList.contains('hidden')) setPanelOpen(false);
+ });
+ render();
+ }
+
+ window.DownloadCenter = { init, start };
+})();
diff --git a/src/ui/index.html b/src/ui/index.html
index 65aff08..161f68d 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -25,6 +25,25 @@
+
diff --git a/src/ui/style.css b/src/ui/style.css
index c367673..d2326a1 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -111,6 +111,150 @@ body {
}
.win-btn:hover { background: var(--hover-strong); color: var(--text); }
.win-close:hover { background: var(--danger); color: #fff; }
+.task-center-wrap { position: relative; }
+.task-center-btn { position: relative; }
+.task-center-badge {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ min-width: 15px;
+ height: 15px;
+ padding: 0 4px;
+ border: 1px solid var(--titlebar-start);
+ border-radius: 8px;
+ background: var(--accent);
+ color: var(--active-text);
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 13px;
+ text-align: center;
+}
+.task-center-badge.has-result { background: var(--green); color: #07130b; }
+.task-center-panel {
+ position: absolute;
+ top: 37px;
+ right: -210px;
+ z-index: 50;
+ width: min(390px, calc(100vw - 20px));
+ max-height: min(560px, calc(100vh - 58px));
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg-soft);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ box-shadow: 0 18px 46px rgba(0,0,0,0.32);
+ -webkit-app-region: no-drag;
+}
+.task-center-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 15px 12px;
+ border-bottom: 1px solid var(--line);
+}
+.task-center-head > div { min-width: 0; flex: 1; }
+.task-center-title { color: var(--text); font-size: 14px; font-weight: 700; }
+.task-center-summary {
+ margin-top: 3px;
+ overflow: hidden;
+ color: var(--text-dim);
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.task-center-clear {
+ flex: none;
+ padding: 4px;
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 11px;
+}
+.task-center-clear:hover { color: var(--text); }
+.task-center-list { min-height: 76px; overflow-y: auto; padding: 7px; }
+.task-center-empty {
+ padding: 26px 12px;
+ color: var(--text-dim);
+ font-size: 12px;
+ text-align: center;
+}
+.task-center-item {
+ padding: 10px;
+ border-radius: 8px;
+ border-left: 3px solid transparent;
+}
+.task-center-item:hover { background: var(--hover-bg); }
+.task-center-item.complete { border-left-color: var(--green); }
+.task-center-item.paused { border-left-color: var(--amber); }
+.task-center-item.failed { border-left-color: var(--danger); }
+.task-center-item-head,
+.task-center-item-foot { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.task-center-name {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.task-center-state { flex: none; color: var(--text-dim); font-size: 10px; }
+.task-center-item.complete .task-center-state { color: var(--green); }
+.task-center-item.paused .task-center-state { color: var(--amber); }
+.task-center-item.failed .task-center-state { color: var(--danger); }
+.task-center-book {
+ margin-top: 2px;
+ overflow: hidden;
+ color: var(--text-dim);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.task-center-progress {
+ position: relative;
+ height: 4px;
+ margin: 8px 0 6px;
+ overflow: hidden;
+ border-radius: 3px;
+ background: var(--hover-strong);
+}
+.task-center-progress-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: inherit;
+ transition: width 0.12s linear;
+}
+.task-center-item.complete .task-center-progress-fill { background: var(--green); }
+.task-center-item.paused .task-center-progress-fill { background: var(--amber); }
+.task-center-item.failed .task-center-progress-fill { background: var(--danger); }
+.task-center-progress.indeterminate .task-center-progress-fill {
+ width: 32% !important;
+ animation: dl-progress-slide 1s ease-in-out infinite;
+}
+.task-center-status {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ color: var(--text-dim);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.task-center-item.failed .task-center-status { color: var(--danger); }
+.task-center-actions { display: flex; flex: none; gap: 7px; }
+.task-center-actions button {
+ padding: 0;
+ background: none;
+ border: none;
+ color: var(--accent-bright);
+ cursor: pointer;
+ font-size: 10px;
+}
+.task-center-actions button:hover { text-decoration: underline; }
+.task-center-actions button[data-task-action="delete"] { color: var(--danger); }
.titlebar-icon {
width: 17px;
height: 17px;
diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js
index 7b3ffba..bb57871 100644
--- a/src/ui/views/browse.js
+++ b/src/ui/views/browse.js
@@ -483,17 +483,17 @@ const Browse = (() => {
const lib = await window.api.library.findBySource(sourceId, sourcePostId);
const entryId = (lib.ok && lib.data) ? lib.data.id : undefined;
- // 传 meta:条目还不在书库时由主进程自动建,避免下载完却找不到文件
+ // 元数据与页面内进度回调交给全局任务中心,详情页离开后下载仍由它接管
let res;
try {
- res = await window.api.downloadFile(
+ res = await window.DownloadCenter.start({
+ key: `${sourceId}:${sourcePostId}:${suggestedName}`,
url,
- suggestedName,
+ suggestName: suggestedName,
entryId,
- undefined,
meta,
- (data) => { if (progress) updateDownloadProgress(progress, data); }
- );
+ onProgress: (data) => { if (progress) updateDownloadProgress(progress, data); }
+ });
} catch (error) {
res = { ok: false, error: (error && error.message) || String(error) };
}
@@ -503,6 +503,21 @@ const Browse = (() => {
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');