(() => { 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}
${escapeHtml(statusText(task))} ${actions}
`; } 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 }; })();