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
+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');