用户反馈:点开始下载后 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(进度回调,含最后一屏)
492 lines
16 KiB
JavaScript
492 lines
16 KiB
JavaScript
'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 = `<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); |