first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
// Wails API 适配层
//
// 前端业务代码只用 window.eh.*,这里把它接到 Wails 的 Go bindings 上。
// 这样以后即便换框架,也只要改这一个文件。
//
// 命名从 eh.* 改成 hd4k.* —— 站点换了,标识也跟着换,避免误以为还是 EH。
const go = () => window.go && window.go.main && window.go.main.App;
function requireGo() {
const g = go();
if (!g) throw new Error('Wails 后端未就绪');
return g;
}
window.hd4k = {
// ---- 设置 ----
getSettings: async () => requireGo().GetSettings(),
setSettings: async (patch) => requireGo().SetSettings(patch),
previewDir: async (tpl) => requireGo().PreviewDir(tpl),
placeholders: async () => requireGo().Placeholders(),
sites: async () => requireGo().Sites(),
// ---- 任务 ----
listTasks: async () => requireGo().ListTasks(),
addTasks: async (text) => requireGo().AddTasks(text),
removeTask: async (id) => requireGo().RemoveTask(id),
clearFinished: async () => requireGo().ClearFinished(),
countDone: async () => requireGo().CountDone(),
clearAll: async (onlySelected) => requireGo().ClearAll(!!onlySelected),
requeueFailed: async () => requireGo().RequeueFailed(),
selectAll: async (ids) => requireGo().SelectAll(ids),
// ---- 引擎 ----
status: async () => requireGo().Status(),
engineInfo: async () => requireGo().EngineInfo(),
start: async (onlyIds) => requireGo().Start(onlyIds || []),
stop: async () => requireGo().Stop(),
// ---- 扫描下载目录(用 .hd4k.json 清单补全丢失的任务信息)----
scanDownloadDir: async () => requireGo().ScanDownloadDir(),
// ---- 系统 ----
pickDir: async () => requireGo().PickDir(),
exportFailed: async (id) => {
const path = await requireGo().ExportFailed(id);
return path ? { ok: true, filePath: path } : { ok: false };
},
openPath: async (p) => requireGo().OpenPath(p),
showItem: async (p) => requireGo().ShowItem(p),
// ---- 事件 ----
onTaskUpdate: (cb) => window.runtime.EventsOn('task:update', cb),
onLog: (cb) => window.runtime.EventsOn('log', cb),
onDone: (cb) => window.runtime.EventsOn('engine:done', cb),
};
// 兼容别名:renderer.js 里的业务代码统一用 window.eh.*
window.eh = window.hd4k;
+543
View File
@@ -0,0 +1,543 @@
'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);