Files
eh-downloader/frontend/js/renderer.js
T
toor 7243f73d33 fix: 解析大画廊时连接泄漏 + 解析阶段无进度反馈
用户反馈:点开始下载后 6 个画廊(都是 2000 张级)一直显示「解析中 0/?」,
看起来像卡死。排查发现两个问题:

1. 连接泄漏(真 bug)
   httpClient() 每次请求都新建 http.Transport,用完丢弃。Transport 被丢弃后
   它持有的 keep-alive 连接不会立刻释放(要等 IdleConnTimeout=90s),
   于是每发一个请求留下一条僵尸连接。
   现场实测:91 条 Established 连接,而解析是串行的,正常应该只有 1 条。

   修复:Transport 全局复用,只在代理配置变化时重建,
   重建时 CloseIdleConnections 关掉旧连接;新增 Client.Close()。

   实测:20 次请求从 20 条连接降到 1 条;100 次并发请求只用 22 条。

2. 解析阶段毫无反馈
   2000 张 = 100 屏串行翻页,整个解析阶段界面都显示「解析中 0/?」,
   完全没有进度,用户只能干等。

   修复:FetchGalleryPage 增加 ParseProgress 回调,每翻完一屏上报一次
   (已解析张数/总张数/当前屏),界面显示「已解析 340/2000 张」。

另外:用户设置里请求间隔是 1000ms(1 秒),全局限流让 5 个画廊共享
1 请求/秒 —— 500 次解析请求需要约 8 分钟。这是配置导致的慢,不是 bug,
但可以在界面上看到进度后就不会以为卡死了。

测试从 74 增至 80,新增:
- TestTransportIsReused / TestTransportReusedUnderConcurrency(连接复用)
- TestSetProxyRebuildsTransport(改代理要重建)
- TestParseProgressCallback(进度回调,含最后一屏)
2026-10-09 10:02:50 +08:00

492 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: '准备中', parsing: '解析中', 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;
// 解析阶段:显示「已解析 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 = `<div class="rowprog">
<div class="rb"><i style="width:${pct}%"></i></div>
<span class="rn">已解析 ${got}${total ? '/' + total : ''} 张</span>
</div>`;
} else {
const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0);
progCell = `<div class="rowprog">
<div class="rb"><i style="width:${pct}%"></i></div>
<span class="rn">${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''}</span>
</div>`;
}
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">${progCell}</td>
<td class="c-stat"><span class="badge ${'b-' + (t.status || 'queued')}">${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 === '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 = `<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);