Files
eh-downloader/frontend/js/renderer.js
T
toor 0447875b6d feat: 双层并发 + 全局限流 + 整轮扫尾 + 画廊目录清单
原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。
按用户多年使用习惯(5 画廊 × 每画廊 8 张)改造为双层并发。

- 双层并发:画廊并发(默认5) × 页并发(默认8) = 最多 40 张同时下载,
  两个值都在设置面板可调(1-20)。页级 worker 池 + mutex 保护计数。
- 全局限流:对齐参考实现的 nextRequestTime 算法,限所有请求
  (含 /s/ 解析与图片本体),默认 0 秒关闭。
- 整轮扫尾:跑完后自动重扫失败页 1 轮(同样走完整 4 次退避重试),
  仍失败则停下并标记 partial,换线路后手动点开始即可续传。
- 画廊目录清单 .ehdl.json:记录 gid/token/总页数/已完成页,
  即使 state.json 丢失也能算出「应该多少张、还差多少张」。

顺带修复:
- Start() 双重加锁导致死锁(引擎完全无法启动)
- clampInt 越界时返回默认值而非边界值(设 30 会静默变 8)
- 直链正则写死 hath.network,CDN 换域名会全军覆没 -> 加图片 URL 兜底
- ReconcileTask 会误报「有改动」

测试从 37 增至 62,其中并发/限流/扫尾用 httptest 模拟 EH 实测:
- 页并发峰值 = 8、画廊并发峰值 = 5(完全生效)
- 12 次请求限流 80ms 实测 966ms(期望 880ms)
- 断点续传 4 张已存在时只发 5 次请求
2026-10-09 00:11:38 +08:00

477 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const $ = (id) => document.getElementById(id);
let tasks = [];
let running = false;
let lastLogSeq = 0;
const logBuf = [];
// ---------------- 渲染 ----------------
const STATUS_TEXT = {
queued: '等待', running: '解析中', downloading: '下载中',
done: '完成', failed: '失败', partial: '部分失败',
};
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;
const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0);
const badgeCls = 'b-' + (t.status || 'queued');
const title = t.title || t.raw || '(未解析)';
const err = t.error
? `<span class="e">${esc(t.error)}</span>`
: (t.failedList && t.failedList.length
? `<span class="e">${t.failedList.length} 张失败:${esc(t.failedList[0].error || '')}</span>`
: '');
const acts = [];
if (t.failedList && t.failedList.length) {
acts.push(`<button class="mini" data-act="export" data-gid="${t.gid}">导出失败</button>`);
}
acts.push(`<button class="mini" data-act="del" data-gid="${t.gid}" data-token="${t.token || ''}">删除</button>`);
return `<tr data-gid="${t.gid}" data-token="${t.token || ''}">
<td class="c-sel"><input type="checkbox" class="sel" data-gid="${t.gid}" ${t.selected ? 'checked' : ''} /></td>
<td class="c-gid">${esc(t.gid)}</td>
<td><div class="title-cell"><span class="t">${esc(title)}</span>${err}</div></td>
<td class="c-prog">
<div class="rowprog">
<div class="rb"><i style="width:${pct}%"></i></div>
<span class="rn">${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''}</span>
</div>
</td>
<td class="c-stat"><span class="badge ${badgeCls}">${STATUS_TEXT[t.status] || t.status}</span></td>
<td class="c-act">${acts.join(' ')}</td>
</tr>`;
}
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 === 'downloading') c.running++;
else if (t.status === 'done') c.done++;
else if (t.status === 'failed' || t.status === 'partial') c.failed++;
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 = `<span class="t">${ts}</span>${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);