'use strict'; const $ = (id) => document.getElementById(id); let tasks = []; let running = false; let lastLogSeq = 0; const logBuf = []; // ---------------- 渲染 ---------------- const STATUS_TEXT = { queued: '等待', running: '准备中', parsing: '解析中', downloading: '下载中', done: '完成', failed: '失败', partial: '部分失败', }; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function render() { const tb = $('tbody'); if (!tasks.length) { tb.innerHTML = ''; $('empty').hidden = false; $('tasks').style.display = 'none'; } else { $('empty').hidden = true; $('tasks').style.display = ''; tb.innerHTML = tasks.map(rowHtml).join(''); } renderStats(); renderSelState(); } // 「删除选中」按钮的可用状态跟着勾选走 function renderSelState() { const n = tasks.filter((t) => t.selected).length; const b = $('btn-delsel'); b.disabled = n === 0; b.textContent = n ? `删除选中 (${n})` : '删除选中'; $('btn-clearall').disabled = tasks.length === 0; } function rowHtml(t) { const total = t.totalPages || 0; // 解析阶段:显示「已解析 N/M 张」,而不是 0/?(否则看着像卡死) const parsing = t.status === 'parsing'; let progCell; if (parsing) { const got = Number((t.pages || {})['_parsing'] || 0); const pct = total ? Math.round((got / total) * 100) : 0; progCell = `
已解析 ${got}${total ? '/' + total : ''} 张
`; } else { const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0); progCell = `
${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''}
`; } const title = t.title || t.raw || '(未解析)'; const err = t.error ? `${esc(t.error)}` : (t.failedList && t.failedList.length ? `${t.failedList.length} 张失败:${esc(t.failedList[0].error || '')}` : ''); const acts = []; if (t.failedList && t.failedList.length) { acts.push(``); } acts.push(``); return ` ${esc(t.gid)}
${esc(title)}${err}
${progCell} ${STATUS_TEXT[t.status] || t.status} ${acts.join(' ')} `; } function renderStats() { const c = { queued: 0, running: 0, done: 0, failed: 0 }; let dn = 0, tt = 0; for (const t of tasks) { if (t.status === 'queued') c.queued++; else if (t.status === 'running' || t.status === 'parsing' || t.status === 'downloading') c.running++; else if (t.status === 'done') c.done++; else if (t.status === 'failed' || t.status === 'partial') c.failed++; // 解析阶段的任务还没进入下载,done 不计入总进度 if (t.status !== 'parsing') { dn += t.done || 0; tt += t.totalPages || 0; } } $('s-total').textContent = tasks.length; $('s-queued').textContent = c.queued; $('s-running').textContent = c.running; $('s-done').textContent = c.done; $('s-failed').textContent = c.failed; const pct = tt ? Math.round((dn / tt) * 100) : 0; $('s-bar').style.width = pct + '%'; $('s-text').textContent = tt ? `${dn}/${tt} 张 · ${pct}%` : '—'; } function applyTask(t) { const i = tasks.findIndex((x) => x.gid === t.gid && x.token === t.token); if (i >= 0) tasks[i] = { ...tasks[i], ...t }; else tasks.unshift(t); } // ---------------- 日志 ---------------- function pushLog(rec) { if (rec.seq && rec.seq <= lastLogSeq) return; if (rec.seq) lastLogSeq = rec.seq; logBuf.push(rec); if (logBuf.length > 2000) logBuf.shift(); const d = new Date(rec.at); const ts = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; const el = document.createElement('div'); el.className = 'log-line ' + (rec.level || 'info'); el.innerHTML = `${ts}${esc(rec.msg)}`; const body = $('log-body'); const stick = body.scrollHeight - body.scrollTop - body.clientHeight < 40; body.appendChild(el); if (stick) body.scrollTop = body.scrollHeight; $('log-count').textContent = `(${logBuf.length})`; } // ---------------- 弹窗 ---------------- function openModal(id) { $(id).hidden = false; } function closeModal(id) { $(id).hidden = true; } // ---------------- 设置 ---------------- let settings = {}; async function loadSettings() { settings = await window.eh.getSettings(); $('set-dir').value = settings.downloadRoot || ''; $('set-proxy').value = settings.proxy || ''; $('set-proxymode').value = settings.proxyMode || 'off'; $('set-gconc').value = settings.galleryConcurrency || 5; $('set-gconc-v').textContent = settings.galleryConcurrency || 5; $('set-pconc').value = settings.pageConcurrency || 8; $('set-pconc-v').textContent = settings.pageConcurrency || 8; $('set-interval').value = Math.round((settings.requestIntervalMs || 0) / 1000); $('set-interval-v').textContent = Math.round((settings.requestIntervalMs || 0) / 1000); $('set-to').value = Math.round((settings.timeout || 30000) / 1000); $('set-to-v').textContent = Math.round((settings.timeout || 30000) / 1000); $('set-xres').value = String(settings.xres || 'auto'); $('set-bridgeport').value = String(settings.bridgePort || 37777); $('set-dirtpl').value = settings.dirTemplate || '{title}'; updateMaxConcurrent(); renderPresets(); refreshPreview(); } function updateMaxConcurrent() { const g = parseInt($('set-gconc').value, 10) || 5; const p = parseInt($('set-pconc').value, 10) || 8; $('set-max').textContent = g * p; } const DIR_PRESETS = [ ['{title}', '仅标题'], ['{artist}/{title}', '按作者'], ['{group}/{title}', '按社团'], ['{series}/{title}', '按系列'], ['{category}/{group}/{title}', '分类/社团/标题'], ['{tags:2}/{artist}/{title}', '标签/作者/标题'], ['{artist} - {gid} - {title}', '作者 - GID - 标题'], ]; function renderPresets() { const box = $('dir-presets'); const cur = $('set-dirtpl').value; box.innerHTML = ''; for (const [tpl, label] of DIR_PRESETS) { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.title = tpl; if (tpl === cur) b.classList.add('on'); b.onclick = () => { $('set-dirtpl').value = tpl; renderPresets(); refreshPreview(); }; box.appendChild(b); } } async function refreshPreview() { const tpl = $('set-dirtpl').value; const r = await window.eh.previewDir(tpl); $('dir-preview').textContent = r.ok ? `示例结果:${r.path}` : `模板有误:${r.error}`; } // ---------------- 事件 ---------------- function bind() { $('btn-import').onclick = () => { $('import-text').value = ''; $('import-msg').textContent = ''; openModal('modal-import'); }; $('btn-import-cancel').onclick = () => closeModal('modal-import'); $('btn-import-ok').onclick = doImport; $('import-file').onchange = async (e) => { const f = e.target.files[0]; if (!f) return; $('import-text').value = await f.text(); }; $('btn-start').onclick = doStart; $('btn-stop').onclick = async () => { await window.eh.stop(); setRunning(false); }; $('btn-clear').onclick = doClearFinished; $('btn-settings').onclick = async () => { await loadSettings(); openModal('modal-settings'); }; $('btn-settings-cancel').onclick = () => closeModal('modal-settings'); $('btn-settings-ok').onclick = saveSettings; $('btn-pickdir').onclick = async () => { const d = await window.eh.pickDir(); if (d) $('set-dir').value = d; }; $('set-gconc').oninput = (e) => { $('set-gconc-v').textContent = e.target.value; updateMaxConcurrent(); }; $('set-pconc').oninput = (e) => { $('set-pconc-v').textContent = e.target.value; updateMaxConcurrent(); }; $('set-interval').oninput = (e) => { $('set-interval-v').textContent = e.target.value; }; $('set-to').oninput = (e) => { $('set-to-v').textContent = e.target.value; }; $('set-dirtpl').oninput = () => { renderPresets(); refreshPreview(); }; $('sel-all').onchange = async (e) => { for (const cb of document.querySelectorAll('.sel')) cb.checked = e.target.checked; for (const t of tasks) t.selected = e.target.checked; renderSelState(); await window.eh.selectAll( e.target.checked ? tasks.map((t) => t.gid) : [] ); }; // 单行勾选:同步到主进程并刷新「删除选中」按钮 $('tbody').addEventListener('change', async (e) => { const cb = e.target.closest('input.sel'); if (!cb) return; const t = tasks.find((x) => x.gid === cb.dataset.gid); if (t) t.selected = cb.checked; renderSelState(); await window.eh.selectAll(tasks.filter((x) => x.selected).map((x) => x.gid)); }); $('btn-clearall').onclick = () => doClearAll(false); $('btn-delsel').onclick = () => doClearAll(true); $('tbody').onclick = async (e) => { const btn = e.target.closest('button[data-act]'); if (!btn) return; const { act, gid, token } = btn.dataset; if (act === 'del') { await window.eh.removeTask({ gid, token: token || null }); tasks = tasks.filter((t) => !(t.gid === gid && (t.token || null) === (token || null))); render(); } else if (act === 'export') { const r = await window.eh.exportFailed(gid); if (r.ok) pushLog({ level: 'ok', msg: `失败清单已导出:${r.filePath}`, at: Date.now() }); } }; $('log-head').onclick = (e) => { if (e.target.closest('button')) return; toggleLog(); }; $('btn-togglelog').onclick = toggleLog; $('btn-clearlog').onclick = () => { logBuf.length = 0; lastLogSeq = 0; $('log-body').innerHTML = ''; $('log-count').textContent = ''; }; document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (confirmCancelHook) { confirmCancelHook(); return; } closeModal('modal-import'); closeModal('modal-settings'); } }); } function toggleLog() { const p = $('logpane'); p.classList.toggle('open'); $('btn-togglelog').textContent = p.classList.contains('open') ? '收起' : '展开'; } async function doImport() { const text = $('import-text').value.trim(); if (!text) { $('import-msg').textContent = '先粘贴点内容'; return; } const r = await window.eh.addTasks(text); if (r.warning) $('import-msg').textContent = r.warning; tasks = await window.eh.listTasks(); render(); if (!r.warning) { pushLog({ level: 'ok', msg: `已导入 ${r.added} 个画廊`, at: Date.now() }); closeModal('modal-import'); } } async function saveSettings() { const patch = { downloadRoot: $('set-dir').value.trim(), proxy: $('set-proxy').value.trim(), proxyMode: $('set-proxymode').value, xres: $('set-xres').value, galleryConcurrency: parseInt($('set-gconc').value, 10), pageConcurrency: parseInt($('set-pconc').value, 10), requestIntervalMs: parseInt($('set-interval').value, 10) * 1000, timeout: parseInt($('set-to').value, 10) * 1000, bridgePort: parseInt($('set-bridgeport').value, 10) || 37777, dirTemplate: $('set-dirtpl').value.trim() || '{title}', }; await window.eh.setSettings(patch); $('set-msg').textContent = ''; closeModal('modal-settings'); const max = patch.galleryConcurrency * patch.pageConcurrency; pushLog({ level: 'ok', msg: `设置已保存 · 画廊并发 ${patch.galleryConcurrency} × 页并发 ${patch.pageConcurrency}(最多 ${max} 张同时下载)` + (patch.requestIntervalMs ? ` · 请求间隔 ${patch.requestIntervalMs / 1000} 秒` : ''), at: Date.now(), }); } async function doStart() { const sel = [...document.querySelectorAll('.sel')].filter((c) => c.checked); const gids = sel.map((c) => c.dataset.gid); const r = await window.eh.start(gids.length ? { onlyGids: gids } : {}); if (!r.started) { pushLog({ level: 'warn', msg: r.reason, at: Date.now() }); return; } setRunning(true); } // 清除已完成:两步确认,避免误点;只清任务列表,不碰本地文件 let clearArmed = false; let clearTimer = null; async function doClearFinished() { const btn = $('btn-clear'); const doneCount = tasks.filter((t) => t.status === 'done').length; if (!doneCount) { pushLog({ level: 'warn', msg: '没有已完成的任务', at: Date.now() }); return; } if (!clearArmed) { clearArmed = true; btn.textContent = `确认清除 ${doneCount} 项?`; btn.classList.add('primary'); clearTimer = setTimeout(disarmClear, 4000); return; } disarmClear(); const r = await window.eh.clearFinished(); tasks = r.tasks; render(); pushLog({ level: 'ok', msg: `已清除 ${r.removed} 个已完成任务 · 本地已下载的文件一个没动`, at: Date.now(), }); } function disarmClear() { clearArmed = false; if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; } const btn = $('btn-clear'); btn.textContent = '清除已完成'; btn.classList.remove('primary'); } // ---------------- 删除任务(列表项,本地文件不动) ---------------- /** 通用确认弹窗,resolve(true)=确认 false=取消/ESC */ let confirmCancelHook = null; function confirmDialog(title, body, okText = '确认') { return new Promise((resolve) => { $('cf-title').textContent = title; $('cf-body').textContent = body; $('cf-msg').textContent = ''; const ok = $('cf-ok'); ok.textContent = okText; openModal('modal-confirm'); const finish = (v) => { ok.onclick = null; $('cf-cancel').onclick = null; confirmCancelHook = null; closeModal('modal-confirm'); resolve(v); }; ok.onclick = () => finish(true); $('cf-cancel').onclick = () => finish(false); confirmCancelHook = () => finish(false); }); } /** @param {boolean} onlySelected true=只删勾选的,false=清空全部 */ async function doClearAll(onlySelected) { const n = onlySelected ? tasks.filter((t) => t.selected).length : tasks.length; if (!n) { pushLog({ level: 'warn', msg: onlySelected ? '没有勾选任何任务' : '任务列表已经是空的', at: Date.now(), }); return; } const ok = await confirmDialog( onlySelected ? `确认删除选中的 ${n} 个任务?` : `确认删除全部 ${n} 个任务?`, `将从任务列表里移除${onlySelected ? `这 ${n} 个` : `全部 ${n} 个`}任务。` + `\n\n⚠️ 只删列表项,本地已经下载的图片不会受到任何影响。`, '删除' ); if (!ok) return; const r = await window.eh.clearAll(onlySelected); tasks = r.tasks; render(); pushLog({ level: 'warn', msg: `已删除 ${r.removed} 个任务 · 本地已下载的文件一个没动`, at: Date.now(), }); } function setRunning(v) { running = v; $('btn-start').disabled = v; $('btn-stop').disabled = !v; } // ---------------- 启动 ---------------- async function init() { bind(); await loadSettings(); tasks = await window.eh.listTasks(); render(); const st = await window.eh.status(); setRunning(st.running); window.eh.onTaskUpdate((t) => { applyTask(t); render(); if (t.status === 'running' || t.status === 'downloading') setRunning(true); }); window.eh.onLog(pushLog); window.eh.onDone(() => { setRunning(false); pushLog({ level: 'ok', msg: '全部任务结束', at: Date.now() }); }); pushLog({ level: 'info', msg: `就绪 · 共 ${tasks.length} 个任务`, at: Date.now() }); } document.addEventListener('DOMContentLoaded', init);