feat: 增加下载任务中心与断点续传

This commit is contained in:
lofyer
2026-08-05 15:25:26 +08:00
parent f72c26642f
commit cb7b020dc8
20 changed files with 1094 additions and 70 deletions
+51 -1
View File
@@ -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 标签判断是否需要更新。
### macOSApple 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` 与移动端原生工程,所以上述命令是必须补齐的目标构建入口,目前不能生成移动安装包。
## 配置
### 代理
+2 -1
View File
@@ -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/` 目录无需替换。
## 开发
+216 -45
View File
@@ -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) => {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+11 -2
View File
@@ -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),
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -56,7 +56,7 @@
<h1>获取 PeopleLib</h1>
<p>
所有正式版本都通过项目的 GitHub Releases 发布,不通过 npm 或应用商店分发。
当前版本 1.3.0MIT 许可,免费。
当前版本 2.0.0MIT 许可,免费。
</p>
</div>
<p class="btn-row">
@@ -222,7 +222,7 @@
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>PeopleLib 2.0.0</li>
<li>MIT 许可</li>
</ul>
</div>
+1 -1
View File
@@ -315,7 +315,7 @@
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>PeopleLib 2.0.0</li>
<li>MIT 许可</li>
</ul>
</div>
+1 -1
View File
@@ -306,7 +306,7 @@
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>PeopleLib 2.0.0</li>
<li>MIT 许可</li>
</ul>
</div>
+2 -2
View File
@@ -64,7 +64,7 @@
<a class="btn btn-primary" href="download.html">下载 PeopleLib</a>
<a class="btn btn-secondary" href="features.html">查看功能</a>
</p>
<p class="hint">当前版本 1.3.0MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。</p>
<p class="hint">当前版本 2.0.0MIT 许可。Windows x64 免安装,macOS arm64 提供 DMG。</p>
</div>
<figure class="shot">
<img src="assets/shot-library.jpg" width="1600" height="1086" alt="PeopleLib 书库界面,左侧是书架与标签,右侧网格展示带封面的图书条目" fetchpriority="high">
@@ -265,7 +265,7 @@
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>PeopleLib 2.0.0</li>
<li>MIT 许可</li>
</ul>
</div>
+1 -1
View File
@@ -277,7 +277,7 @@
<div>
<h2>版本</h2>
<ul>
<li>PeopleLib 1.3.0</li>
<li>PeopleLib 2.0.0</li>
<li>MIT 许可</li>
</ul>
</div>
+246 -3
View File
@@ -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 {
+6
View File
@@ -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 主题图标并同步界面主题', () => {
+24 -1
View File
@@ -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'\)/);
+1
View File
@@ -45,6 +45,7 @@ document.querySelectorAll('.tab').forEach((t) => {
t.onclick = () => switchTab(t.dataset.tab);
});
DownloadCenter.init();
Browse.init();
Library.init();
Notes.init();
+341
View File
@@ -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
? `<div class="task-center-book">${escapeHtml(task.bookTitle)}</div>`
: '';
let actions = '';
if (task.status === 'complete') {
actions = `<div class="task-center-actions">
<button data-task-action="open">打开</button>
<button data-task-action="reveal">定位</button>
<button data-task-action="remove">移除</button>
</div>`;
} else if (task.status === 'running' || task.status === 'pending') {
actions = `<div class="task-center-actions">
<button data-task-action="pause">暂停</button>
<button data-task-action="delete">删除</button>
</div>`;
} else if (task.status === 'paused') {
actions = `<div class="task-center-actions">
<button data-task-action="resume">继续</button>
<button data-task-action="delete">删除</button>
</div>`;
} else if (!isActive(task)) {
actions = `<div class="task-center-actions"><button data-task-action="remove">移除</button></div>`;
}
const state = task.status === 'complete'
? '已完成'
: (task.status === 'failed'
? '失败'
: (task.status === 'canceled'
? '已取消'
: (task.status === 'paused' ? '已暂停' : '下载中')));
return `
<article class="task-center-item ${task.status}" data-task-id="${escapeHtml(task.id)}">
<div class="task-center-item-head">
<div class="task-center-name" title="${escapeHtml(task.name)}">${escapeHtml(task.name)}</div>
<span class="task-center-state">${state}</span>
</div>
${book}
<div class="task-center-progress${progressClass}">
<div class="task-center-progress-fill" style="width:${width}%"></div>
</div>
<div class="task-center-item-foot">
<span class="task-center-status" title="${escapeHtml(statusText(task))}">${escapeHtml(statusText(task))}</span>
${actions}
</div>
</article>`;
}
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('')
: '<div class="task-center-empty">暂无下载任务</div>';
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 };
})();
+20
View File
@@ -25,6 +25,25 @@
</nav>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<div class="task-center-wrap">
<button id="taskCenterBtn" class="win-btn task-center-btn" title="任务中心" aria-label="任务中心" aria-expanded="false" aria-controls="taskCenterPanel">
<svg class="titlebar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"></path>
<path d="M8 9h8M8 13h5M8 17h3"></path>
</svg>
<span id="taskCenterBadge" class="task-center-badge hidden">0</span>
</button>
<section id="taskCenterPanel" class="task-center-panel hidden" aria-label="下载任务">
<div class="task-center-head">
<div>
<div class="task-center-title">任务中心</div>
<div id="taskCenterSummary" class="task-center-summary">下载任务会显示在这里</div>
</div>
<button id="taskCenterClear" class="task-center-clear hidden">清除已结束</button>
</div>
<div id="taskCenterList" class="task-center-list"></div>
</section>
</div>
<button id="uiThemeBtn" class="win-btn ui-theme-btn" title="切换到明亮主题" aria-label="切换到明亮主题">
<svg class="titlebar-icon ui-theme-sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"></path>
@@ -342,6 +361,7 @@
</div>
<script src="util.js"></script>
<script src="download-center.js"></script>
<script src="views/browse.js"></script>
<script src="views/library.js"></script>
<script src="vendor/quill/quill.js"></script>
+144
View File
@@ -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;
+21 -6
View File
@@ -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');