Files
2026-10-11 12:56:38 +08:00

543 lines
18 KiB
JavaScript
Raw Permalink 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) {
// 4KHD 的 total 是**图片总数**(不是 EH 那种 HTML 分页数)
const total = t.total || 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-id="${t.id}">导出失败</button>`);
}
acts.push(`<button class="mini" data-act="del" data-id="${t.id}">删除</button>`);
// 「ID」列展示 slug(去掉 .html),比完整 URL 好认
const slug = t.slug || t.url || '';
return `<tr data-id="${t.id}">
<td class="c-sel"><input type="checkbox" class="sel" data-id="${t.id}" ${t.selected ? 'checked' : ''} /></td>
<td class="c-gid">${esc(slug)}</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.total || 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.id === t.id);
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-bridgeport').value = String(settings.bridgePort || 37778);
$('set-dirtpl').value = settings.dirTemplate || '{title}';
renderSites(settings.sites || []);
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;
}
// 4KHD 没有 EH 那套 artist/group/tags 标签体系,站点真实提供的只有
// 标题、slug、域名、分类数字 —— 所以预设也只剩这些。
const DIR_PRESETS = [
['{title}', '仅标题'],
['{name}', '路径名'],
['{category}/{title}', '分类/标题'],
['{host}/{title}', '域名/标题'],
['{category}/{name}', '分类/路径名'],
['{name} - {title}', '路径名 - 标题'],
];
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;
};
$('btn-site-add').onclick = () => {
const box = $('set-sites');
box.appendChild(siteRow());
box.lastChild.querySelector('.site-host').focus();
};
$('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.id) : []
);
};
// 单行勾选:同步到主进程并刷新「删除选中」按钮
$('tbody').addEventListener('change', async (e) => {
const cb = e.target.closest('input.sel');
if (!cb) return;
const t = tasks.find((x) => x.id === cb.dataset.id);
if (t) t.selected = cb.checked;
renderSelState();
await window.eh.selectAll(tasks.filter((x) => x.selected).map((x) => x.id));
});
$('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, id } = btn.dataset;
if (act === 'del') {
await window.eh.removeTask(id);
tasks = tasks.filter((t) => t.id !== id);
render();
} else if (act === 'export') {
const r = await window.eh.exportFailed(id);
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,
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) || 37778,
dirTemplate: $('set-dirtpl').value.trim() || '{title}',
sites: collectSites(),
};
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} 秒` : '') +
(patch.sites.length ? ` · 站点 ${patch.sites.map((s) => s.host).join(', ')}` : ''),
at: Date.now(),
});
}
async function doStart() {
const sel = [...document.querySelectorAll('.sel')].filter((c) => c.checked);
const ids = sel.map((c) => c.dataset.id);
const r = await window.eh.start(ids);
if (!r.started) {
pushLog({ level: 'warn', msg: r.reason, at: Date.now() });
return;
}
setRunning(true);
}
// ---------------- 站点域名管理 ----------------
//
// 这类采集站随时换域名(实测 www.4khd.com / m.4khd.com 已全 404,
// 只有 migmg.ssuu.uk 还活着)。域名做成设置项:站点挂了加一行即可,
// 后端解析规则和油猴的 @match 都不用动。
function renderSites(sites) {
const box = $('set-sites');
if (!box) return;
box.innerHTML = '';
sites.forEach((s, i) => box.appendChild(siteRow(s, i)));
}
function siteRow(s = { host: '', label: '', enabled: true }, idx = 0) {
const row = document.createElement('div');
row.className = 'site-row';
row.innerHTML = `
<input type="checkbox" class="site-on" ${s.enabled ? 'checked' : ''} title="启用该站点" />
<input type="text" class="site-host" placeholder="migmg.ssuu.uk" value="${esc(s.host || '')}" />
<input type="text" class="site-label" placeholder="显示名(可选)" value="${esc(s.label || '')}" />
<button type="button" class="mini site-del" title="删除">✕</button>`;
row.querySelector('.site-del').onclick = () => { row.remove(); };
return row;
}
function collectSites() {
const out = [];
document.querySelectorAll('#set-sites .site-row').forEach((row) => {
const host = row.querySelector('.site-host').value.trim();
if (!host) return;
out.push({
host,
label: row.querySelector('.site-label').value.trim(),
enabled: row.querySelector('.site-on').checked,
});
});
return out;
}
// 清除已完成:两步确认,避免误点。
// 除了清任务列表,还会顺带删掉画廊目录里 <10KB 的过小图片(占位图/损坏图)。
let clearArmed = false;
let clearTimer = null;
async function doClearFinished() {
const btn = $('btn-clear');
const doneCount = tasks.filter((t) => t.status === 'done').length;
if (!clearArmed) {
clearArmed = true;
// 即使没有已完成任务也允许确认 —— 没有任务时这一步只做「清理小图」
btn.textContent = doneCount
? `确认清除 ${doneCount} 项并清理小图?`
: '确认清理画廊里的小图?';
btn.classList.add('primary');
clearTimer = setTimeout(disarmClear, 4000);
return;
}
disarmClear();
const r = await window.eh.clearFinished();
tasks = r.tasks;
render();
let msg = `已清除 ${r.removed} 个已完成任务`;
if (r.sweptImages > 0) {
msg += ` · 删除 ${r.sweptImages} 张过小图片(释放 ${r.sweptFreedKB} KB)`;
} else {
msg += ' · 没有发现过小的图片';
}
pushLog({ level: 'ok', msg, 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);