feat: 增加下载任务中心与断点续传
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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'\)/);
|
||||
|
||||
@@ -45,6 +45,7 @@ document.querySelectorAll('.tab').forEach((t) => {
|
||||
t.onclick = () => switchTab(t.dataset.tab);
|
||||
});
|
||||
|
||||
DownloadCenter.init();
|
||||
Browse.init();
|
||||
Library.init();
|
||||
Notes.init();
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user