(() => {
const tasks = [];
const MAX_HISTORY = 30;
let seq = 0;
let button = null;
let badge = null;
let panel = null;
let list = null;
let summary = null;
let clearButton = null;
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function isActive(task) {
return ['pending', 'running', 'pausing', 'deleting'].includes(task.status);
}
function isUnfinished(task) {
return isActive(task) || task.status === 'paused';
}
function statusText(task) {
if (task.status === 'pending') return '准备下载…';
if (task.status === 'pausing') return '正在暂停…';
if (task.status === 'paused') {
return task.receivedBytes ? `已暂停 · ${formatBytes(task.receivedBytes)}` : '已暂停';
}
if (task.status === 'deleting') return '正在删除…';
if (task.status === 'failed') return task.error || '下载失败';
if (task.status === 'canceled') return '已取消';
if (task.status === 'complete') {
return task.receivedBytes ? `下载完成 · ${formatBytes(task.receivedBytes)}` : '下载完成';
}
if (task.totalBytes && task.percent != null) {
return `${Math.round(task.percent * 100)}% · ${formatBytes(task.receivedBytes)} / ${formatBytes(task.totalBytes)}`;
}
return task.receivedBytes ? `${formatBytes(task.receivedBytes)} 已下载` : '正在连接…';
}
function taskHtml(task) {
const ratio = task.status === 'complete'
? 1
: (task.percent == null ? null : Math.max(0, Math.min(1, task.percent)));
const progressClass = ratio == null && ['pending', 'running', 'pausing'].includes(task.status)
? ' indeterminate'
: '';
const width = ratio == null ? 0 : Math.round(ratio * 100);
const book = task.bookTitle && task.bookTitle !== task.name
? `
${escapeHtml(task.bookTitle)}
`
: '';
let actions = '';
if (task.status === 'complete') {
actions = `
`;
} else if (task.status === 'running' || task.status === 'pending') {
actions = `
`;
} else if (task.status === 'paused') {
actions = `
`;
} else if (!isActive(task)) {
actions = ``;
}
const state = task.status === 'complete'
? '已完成'
: (task.status === 'failed'
? '失败'
: (task.status === 'canceled'
? '已取消'
: (task.status === 'paused' ? '已暂停' : '下载中')));
return `
${escapeHtml(task.name)}
${state}
${book}
`;
}
function render() {
if (!button) return;
const active = tasks.filter(isActive).length;
const paused = tasks.filter((task) => task.status === 'paused').length;
const unread = tasks.filter((task) => task.unread).length;
const count = active + paused + unread;
badge.textContent = String(Math.min(99, count));
badge.classList.toggle('hidden', count === 0);
badge.classList.toggle('has-result', active === 0 && unread > 0);
button.title = active
? `任务中心,${active} 个下载中`
: (paused
? `任务中心,${paused} 个已暂停`
: (unread ? `任务中心,${unread} 个新结果` : '任务中心'));
button.setAttribute('aria-label', button.title);
const complete = tasks.filter((task) => task.status === 'complete').length;
const failed = tasks.filter((task) => task.status === 'failed').length;
summary.textContent = active
? `${active} 个下载中${paused ? `,${paused} 个已暂停` : ''}${complete ? `,${complete} 个已完成` : ''}`
: (tasks.length
? `${paused ? `${paused} 个已暂停,` : ''}${complete} 个已完成${failed ? `,${failed} 个失败` : ''}`
: '下载任务会显示在这里');
list.innerHTML = tasks.length
? tasks.map(taskHtml).join('')
: '暂无下载任务
';
clearButton.classList.toggle('hidden', !tasks.some((task) => !isUnfinished(task)));
}
function setPanelOpen(open) {
panel.classList.toggle('hidden', !open);
button.setAttribute('aria-expanded', String(open));
if (open) {
tasks.forEach((task) => { task.unread = false; });
render();
}
}
function pruneHistory() {
let terminal = tasks.filter((task) => !isUnfinished(task)).length;
for (let i = tasks.length - 1; i >= 0 && terminal > MAX_HISTORY; i--) {
if (isUnfinished(tasks[i])) continue;
tasks.splice(i, 1);
terminal--;
}
}
function updateProgress(task, data, callback) {
if (!['pausing', 'deleting'].includes(task.status)) task.status = 'running';
task.receivedBytes = Math.max(0, Number(data && data.receivedBytes) || 0);
const total = Number(data && data.totalBytes);
task.totalBytes = Number.isFinite(total) && total > 0 ? total : null;
const ratio = Number(data && data.percent);
task.percent = data && data.percent != null && Number.isFinite(ratio)
? Math.max(0, Math.min(1, ratio))
: null;
render();
if (typeof callback === 'function') {
try { callback(data); } catch (e) { /* 页面内进度异常不影响全局任务 */ }
}
}
async function run(task, input, onProgress) {
task.status = 'running';
task.error = '';
render();
let result;
try {
result = await window.api.downloads.run(
task.requestId,
input.url,
input.suggestName,
input.entryId,
input.extraHeaders,
input.meta,
(data) => updateProgress(task, data, onProgress)
);
} catch (error) {
result = { ok: false, error: (error && error.message) || String(error) };
}
if (result && result.ok && result.data && result.data.canceled) {
task.status = 'canceled';
} else if (result && result.ok && result.data && result.data.paused) {
task.status = 'paused';
task.receivedBytes = Number(result.data.receivedBytes) || task.receivedBytes;
task.totalBytes = Number(result.data.totalBytes) || task.totalBytes;
task.percent = task.totalBytes ? task.receivedBytes / task.totalBytes : task.percent;
task.promise = null;
render();
return result;
} else if (result && result.ok && result.data && result.data.deleted) {
const index = tasks.indexOf(task);
if (index >= 0) tasks.splice(index, 1);
render();
return result;
} else if (!result || !result.ok) {
task.status = 'failed';
task.error = (result && result.error) || '下载失败';
} else {
task.status = 'complete';
task.percent = 1;
task.path = result.data.path || '';
task.receivedBytes = Math.max(task.receivedBytes, Number(result.data.receivedBytes) || 0);
if (window.Library) window.Library.markDirty();
}
task.key = '';
task.input = null;
task.promise = null;
task.unread = panel.classList.contains('hidden');
pruneHistory();
render();
return result;
}
function start(input) {
const key = String(input && input.key || '');
const existing = key && tasks.find((task) => task.key === key && isUnfinished(task));
if (existing) {
if (existing.status === 'paused') {
existing.input.onProgress = input && input.onProgress;
existing.promise = run(existing, existing.input, existing.input.onProgress);
}
return existing.promise || Promise.resolve({ ok: true, data: { paused: true } });
}
const meta = input && input.meta || {};
const task = {
id: `download_${Date.now().toString(36)}_${(++seq).toString(36)}`,
requestId: `task_${Date.now().toString(36)}_${seq.toString(36)}`,
key,
name: String(input && input.suggestName || meta.title || '未命名下载'),
bookTitle: String(meta.title || ''),
status: 'pending',
receivedBytes: 0,
totalBytes: null,
percent: null,
error: '',
path: '',
unread: false,
promise: null,
input: {
url: String(input && input.url || ''),
suggestName: String(input && input.suggestName || ''),
entryId: input && input.entryId,
extraHeaders: input && input.extraHeaders,
meta,
onProgress: input && input.onProgress
}
};
tasks.unshift(task);
pruneHistory();
render();
task.promise = run(task, task.input, task.input.onProgress);
return task.promise;
}
async function taskAction(action, id) {
const index = tasks.findIndex((task) => task.id === id);
if (index < 0) return;
const task = tasks[index];
if (action === 'open' && task.path) {
const result = await window.api.openPath(task.path);
if (!result.ok) await confirmModal('打开失败', result.error || '无法打开该文件');
return;
}
if (action === 'reveal' && task.path) {
window.api.showItem(task.path);
return;
}
if (action === 'pause' && ['pending', 'running'].includes(task.status)) {
const previousStatus = task.status;
task.status = 'pausing';
render();
const result = await window.api.downloads.pause(task.requestId);
if ((!result || !result.ok || !result.data) && task.status === 'pausing') {
task.status = previousStatus;
render();
}
return;
}
if (action === 'resume' && task.status === 'paused' && task.input) {
task.promise = run(task, task.input, task.input.onProgress);
return;
}
if (action === 'delete' && isUnfinished(task)) {
const previousStatus = task.status;
task.status = 'deleting';
render();
const result = await window.api.downloads.delete(task.requestId);
if ((!result || !result.ok || !result.data) && task.status === 'deleting') {
task.status = previousStatus;
task.error = (result && result.error) || '';
render();
return;
}
if (!task.promise) {
tasks.splice(index, 1);
render();
}
return;
}
if (action === 'remove' && !isUnfinished(task)) {
tasks.splice(index, 1);
render();
}
}
function init() {
button = $('taskCenterBtn');
badge = $('taskCenterBadge');
panel = $('taskCenterPanel');
list = $('taskCenterList');
summary = $('taskCenterSummary');
clearButton = $('taskCenterClear');
button.onclick = () => setPanelOpen(panel.classList.contains('hidden'));
list.onclick = (event) => {
const actionButton = event.target.closest('[data-task-action]');
const item = event.target.closest('[data-task-id]');
if (actionButton && item) taskAction(actionButton.dataset.taskAction, item.dataset.taskId);
};
clearButton.onclick = () => {
for (let i = tasks.length - 1; i >= 0; i--) {
if (!isUnfinished(tasks[i])) tasks.splice(i, 1);
}
render();
};
document.addEventListener('pointerdown', (event) => {
if (!panel.classList.contains('hidden') && !event.target.closest('.task-center-wrap')) {
setPanelOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !panel.classList.contains('hidden')) setPanelOpen(false);
});
render();
}
window.DownloadCenter = { init, start };
})();