在 Z-Library 账户设置内增加镜像站点编辑入口,一行一个 HTTPS 地址, 自定义地址经 origin 规范化后优先于内置列表。镜像配置与凭据分开处理, 退出登录后仍保留;移除当前会话依赖的镜像时清理旧会话和 Cookie。 版本更新到 2.1.5,并补充存储、校验、IPC、界面与真实 Electron 保存测试。
474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
$('minBtn').onclick = () => window.api.minimize();
|
||
$('maxBtn').onclick = () => window.api.maximize();
|
||
$('closeBtn').onclick = () => window.api.close();
|
||
|
||
let uiTheme = 'dark';
|
||
function applyUiTheme(value) {
|
||
uiTheme = value === 'light' ? 'light' : 'dark';
|
||
document.documentElement.dataset.uiTheme = uiTheme;
|
||
const label = uiTheme === 'light' ? '切换到暗色主题' : '切换到明亮主题';
|
||
$('uiThemeBtn').title = label;
|
||
$('uiThemeBtn').setAttribute('aria-label', label);
|
||
}
|
||
|
||
$('uiThemeBtn').onclick = async () => {
|
||
const next = uiTheme === 'dark' ? 'light' : 'dark';
|
||
applyUiTheme(next);
|
||
const result = await window.api.ui.setTheme(next);
|
||
if (!result || !result.ok) applyUiTheme(uiTheme === 'dark' ? 'light' : 'dark');
|
||
};
|
||
|
||
async function initUiTheme() {
|
||
const result = await window.api.ui.getTheme();
|
||
applyUiTheme(result && result.ok ? result.data : 'dark');
|
||
const unsubscribe = window.api.ui.onThemeChanged(applyUiTheme);
|
||
if (typeof unsubscribe === 'function') {
|
||
window.addEventListener('beforeunload', unsubscribe, { once: true });
|
||
}
|
||
}
|
||
initUiTheme();
|
||
|
||
let currentTab = 'library';
|
||
|
||
function switchTab(tab) {
|
||
currentTab = tab;
|
||
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
|
||
$('libraryTab').classList.toggle('hidden', tab !== 'library');
|
||
$('notesTab').classList.toggle('hidden', tab !== 'notes');
|
||
$('browseTab').classList.toggle('hidden', tab !== 'browse');
|
||
$('settingsTab').classList.toggle('hidden', tab !== 'settings');
|
||
if (tab === 'library') Library.refresh(true);
|
||
if (tab === 'notes') Notes.refresh();
|
||
}
|
||
|
||
document.querySelectorAll('.tab').forEach((t) => {
|
||
t.onclick = () => switchTab(t.dataset.tab);
|
||
});
|
||
|
||
DownloadCenter.init();
|
||
Browse.init();
|
||
Library.init();
|
||
Notes.init();
|
||
|
||
if (window.api.reader && window.api.reader.onNotesChanged) {
|
||
const unsubscribeNotes = window.api.reader.onNotesChanged(() => {
|
||
Notes.markDirty();
|
||
if (currentTab === 'notes') Notes.refresh(true);
|
||
});
|
||
if (typeof unsubscribeNotes === 'function') {
|
||
window.addEventListener('beforeunload', unsubscribeNotes, { once: true });
|
||
}
|
||
}
|
||
|
||
const sortSelect = $('sortSelect');
|
||
sortSelect.value = Library.getSortMode();
|
||
sortSelect.onchange = () => Library.setSortMode(sortSelect.value);
|
||
|
||
async function initSourceManager() {
|
||
const listEl = $('sourceList');
|
||
const res = await window.api.sources.list();
|
||
const all = res.ok ? res.data : [];
|
||
const enabled = getEnabledSources();
|
||
listEl.innerHTML = all.map((s) => {
|
||
const checked = enabled ? enabled.includes(s.id) : true;
|
||
return `
|
||
<label class="source-row">
|
||
<input type="checkbox" data-id="${escapeHtml(s.id)}" ${checked ? 'checked' : ''} />
|
||
<span>${escapeHtml(s.name)}</span>
|
||
</label>`;
|
||
}).join('');
|
||
listEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||
cb.onchange = () => {
|
||
const ids = Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked'))
|
||
.map((el) => el.dataset.id);
|
||
setEnabledSources(ids);
|
||
Browse.reloadSources();
|
||
};
|
||
});
|
||
}
|
||
initSourceManager();
|
||
|
||
async function refreshZlibStatus() {
|
||
const r = await window.api.zlib.hasCreds();
|
||
const logged = r.ok && r.data;
|
||
$('zlibStatus').textContent = logged ? '已配置(凭据保存在本地)' : '未登录';
|
||
$('zlibLoginBtn').textContent = logged ? '重新登录' : '登录';
|
||
$('zlibLogoutBtn').classList.toggle('hidden', !logged);
|
||
}
|
||
|
||
$('zlibLoginBtn').onclick = async () => {
|
||
const r = await openModal('Z-Library 登录', `
|
||
<p style="margin-bottom:8px;">使用 Z-Library 账号登录(保存在本地 userData 目录)</p>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<input id="zlibEmail" class="modal-input" type="email" placeholder="邮箱" />
|
||
<input id="zlibPassword" class="modal-input" type="password" placeholder="密码" />
|
||
<div id="zlibErr" class="note-form-error"></div>
|
||
</div>
|
||
`, async () => {
|
||
const email = $('zlibEmail').value.trim();
|
||
const password = $('zlibPassword').value;
|
||
if (!email || !password) { $('zlibErr').textContent = '请输入邮箱和密码'; return false; }
|
||
$('zlibErr').textContent = '登录中...';
|
||
const res = await window.api.zlib.login(email, password);
|
||
if (!res.ok) { $('zlibErr').textContent = res.error || '登录失败'; return false; }
|
||
if (res.data && res.data.ok === false) { $('zlibErr').textContent = res.data.error || '登录失败'; return false; }
|
||
return true;
|
||
});
|
||
if (r) refreshZlibStatus();
|
||
};
|
||
|
||
$('zlibLogoutBtn').onclick = async () => {
|
||
const ok = await confirmModal('退出 Z-Library', '确定要清除本地保存的 Z-Library 凭据吗?');
|
||
if (ok) { await window.api.zlib.logout(); refreshZlibStatus(); }
|
||
};
|
||
|
||
refreshZlibStatus();
|
||
|
||
async function refreshZlibMirrors() {
|
||
const r = await window.api.zlib.getMirrors();
|
||
const custom = (r.ok && r.data && r.data.custom) || [];
|
||
$('zlibMirrorStatus').textContent = custom.length
|
||
? `已自定义 ${custom.length} 个镜像,优先于内置列表`
|
||
: '使用内置镜像列表';
|
||
}
|
||
|
||
$('zlibMirrorBtn').onclick = async () => {
|
||
const current = await window.api.zlib.getMirrors();
|
||
if (!current.ok) return;
|
||
const custom = (current.data && current.data.custom) || [];
|
||
const defaults = (current.data && current.data.defaults) || [];
|
||
const r = await openModal('Z-Library 镜像站点', `
|
||
<p style="margin-bottom:8px;">一行一个地址,必须是 HTTPS。自定义镜像会排在内置列表前面优先尝试;留空则只用内置列表。</p>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||
<textarea id="zlibMirrorList" class="modal-input" rows="6" spellcheck="false"
|
||
placeholder="https://example.org">${escapeHtml(custom.join('\n'))}</textarea>
|
||
<div class="settings-item-desc">内置镜像:${escapeHtml(defaults.join('、'))}</div>
|
||
<div id="zlibMirrorErr" class="note-form-error"></div>
|
||
</div>
|
||
`, async () => {
|
||
const lines = $('zlibMirrorList').value.split('\n').map((s) => s.trim()).filter(Boolean);
|
||
const res = await window.api.zlib.setMirrors(lines);
|
||
if (!res.ok) { $('zlibMirrorErr').textContent = res.error || '保存失败'; return false; }
|
||
return true;
|
||
});
|
||
if (r) { refreshZlibMirrors(); refreshZlibStatus(); }
|
||
};
|
||
|
||
refreshZlibMirrors();
|
||
|
||
async function refreshLibraryDir() {
|
||
const r = await window.api.library.getDir();
|
||
if (r.ok && r.data) $('libDirPath').textContent = r.data.dir + (r.data.isDefault ? '(默认)' : '');
|
||
}
|
||
|
||
$('libDirPickBtn').onclick = async () => {
|
||
const pick = await window.api.library.pickDir();
|
||
if (!pick.ok || !pick.data) return;
|
||
const dest = pick.data;
|
||
|
||
const cur = await window.api.library.getDir();
|
||
if (cur.ok && cur.data && cur.data.dir === dest) return;
|
||
|
||
// 让用户决定旧目录里已有的书怎么处理
|
||
let migrate = false;
|
||
const choice = await openModal('切换书库目录', `
|
||
<p>新目录:</p>
|
||
<div class="settings-path" style="margin:6px 0 12px;">${escapeHtml(dest)}</div>
|
||
<label style="display:block;margin-bottom:6px;">
|
||
<input type="radio" name="migMode" value="migrate" checked /> 迁移:把现有书库内容移动到新目录
|
||
</label>
|
||
<label style="display:block;">
|
||
<input type="radio" name="migMode" value="switch" /> 直接切换:旧目录原样保留,新目录重新扫描
|
||
</label>
|
||
`, () => {
|
||
const sel = document.querySelector('input[name="migMode"]:checked');
|
||
return { migrate: sel && sel.value === 'migrate' };
|
||
});
|
||
if (!choice) return;
|
||
migrate = choice.migrate;
|
||
|
||
const res = await window.api.library.setDir(dest, migrate);
|
||
if (!res.ok) { await confirmModal('切换失败', res.error || '未知错误'); return; }
|
||
await refreshLibraryDir();
|
||
if (window.Library) window.Library.markDirty();
|
||
};
|
||
|
||
$('libDirOpenBtn').onclick = async () => {
|
||
const r = await window.api.library.getDir();
|
||
if (r.ok && r.data) window.api.openPath(r.data.dir);
|
||
};
|
||
|
||
async function refreshAskSave() {
|
||
const r = await window.api.settings.get('askSavePath', false);
|
||
$('askSaveChk').checked = !!(r.ok && r.data);
|
||
}
|
||
$('askSaveChk').onchange = () => window.api.settings.set('askSavePath', $('askSaveChk').checked);
|
||
|
||
async function refreshMangadexQuality() {
|
||
const r = await window.api.settings.get('mangadex.imageQuality', 'dataSaver');
|
||
$('mangadexQualitySelect').value = (r.ok && r.data === 'data') ? 'data' : 'dataSaver';
|
||
}
|
||
$('mangadexQualitySelect').onchange = () => (
|
||
window.api.settings.set('mangadex.imageQuality', $('mangadexQualitySelect').value)
|
||
);
|
||
|
||
async function refreshMangadexLanguage() {
|
||
const r = await window.api.settings.get('mangadex.chapterLanguage', 'zh');
|
||
$('mangadexLanguageSelect').value = r.ok && ['zh', 'zh-hk', 'all'].includes(r.data)
|
||
? r.data
|
||
: 'zh';
|
||
}
|
||
$('mangadexLanguageSelect').onchange = () => (
|
||
window.api.settings.set('mangadex.chapterLanguage', $('mangadexLanguageSelect').value)
|
||
);
|
||
|
||
refreshLibraryDir();
|
||
refreshAskSave();
|
||
refreshMangadexQuality();
|
||
refreshMangadexLanguage();
|
||
|
||
async function refreshProxy() {
|
||
const r = await window.api.proxy.get();
|
||
if (r.ok) $('proxyInput').value = r.data || '';
|
||
}
|
||
$('proxySaveBtn').onclick = async () => {
|
||
const r = await window.api.proxy.set($('proxyInput').value.trim());
|
||
$('proxySaveBtn').textContent = r.ok ? '已保存 ✓' : '保存失败';
|
||
$('proxySaveBtn').title = r.ok ? '' : (r.error || '代理地址无效');
|
||
setTimeout(() => { $('proxySaveBtn').textContent = '保存'; }, 1500);
|
||
};
|
||
refreshProxy();
|
||
|
||
async function refreshSemanticKeyStatus() {
|
||
const r = await window.api.semanticScholar.keyStatus();
|
||
const status = r.ok && r.data ? r.data : { configured: false, persistent: false };
|
||
$('semanticKeyStatus').textContent = status.configured
|
||
? (status.persistent ? '已配置(由系统安全存储加密)' : '已配置(仅本次运行)')
|
||
: '未配置(匿名请求容易被限流)';
|
||
$('semanticKeyClearBtn').classList.toggle('hidden', !status.configured);
|
||
}
|
||
|
||
$('semanticKeySaveBtn').onclick = async () => {
|
||
const key = $('semanticKeyInput').value.trim();
|
||
if (!key) {
|
||
$('semanticKeySaveBtn').textContent = '请输入 Key';
|
||
setTimeout(() => { $('semanticKeySaveBtn').textContent = '保存'; }, 1500);
|
||
return;
|
||
}
|
||
const r = await window.api.semanticScholar.setKey(key);
|
||
$('semanticKeyInput').value = '';
|
||
$('semanticKeySaveBtn').textContent = r.ok ? '已保存 ✓' : '保存失败';
|
||
$('semanticKeySaveBtn').title = r.ok ? '' : (r.error || '保存失败');
|
||
await refreshSemanticKeyStatus();
|
||
setTimeout(() => { $('semanticKeySaveBtn').textContent = '保存'; }, 1500);
|
||
};
|
||
|
||
$('semanticKeyClearBtn').onclick = async () => {
|
||
const ok = await confirmModal('清除 API Key', '确定清除 Semantic Scholar API Key 吗?');
|
||
if (!ok) return;
|
||
await window.api.semanticScholar.clearKey();
|
||
await refreshSemanticKeyStatus();
|
||
};
|
||
|
||
refreshSemanticKeyStatus();
|
||
|
||
let orphanFound = null;
|
||
|
||
function describeOrphans(data) {
|
||
const notes = data.notes || [];
|
||
const annotations = data.annotations || [];
|
||
if (!notes.length && !annotations.length) return '没有残留数据';
|
||
const parts = [];
|
||
if (notes.length) {
|
||
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
|
||
parts.push(`${notes.length} 本已移除书籍留有阅读资料(含 ${noteTotal} 条笔记)`);
|
||
}
|
||
if (annotations.length) {
|
||
const mb = (data.totalBytes || 0) / 1024 / 1024;
|
||
const size = mb >= 0.1 ? `${mb.toFixed(1)} MB` : `${Math.round((data.totalBytes || 0) / 1024)} KB`;
|
||
parts.push(`${annotations.length} 份孤立批注(约 ${size})`);
|
||
}
|
||
return parts.join(';');
|
||
}
|
||
|
||
$('orphanScanBtn').onclick = async () => {
|
||
const btn = $('orphanScanBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '检查中...';
|
||
const r = await window.api.reader.orphanReport();
|
||
btn.disabled = false;
|
||
btn.textContent = '检查';
|
||
if (!r || !r.ok) {
|
||
$('orphanStatus').textContent = `检查失败:${(r && r.error) || '未知错误'}`;
|
||
return;
|
||
}
|
||
orphanFound = r.data;
|
||
$('orphanStatus').textContent = describeOrphans(r.data);
|
||
const hasAny = (r.data.notes || []).length > 0 || (r.data.annotations || []).length > 0;
|
||
$('orphanPurgeBtn').classList.toggle('hidden', !hasAny);
|
||
};
|
||
|
||
$('orphanPurgeBtn').onclick = async () => {
|
||
if (!orphanFound) return;
|
||
const notes = orphanFound.notes || [];
|
||
const annotations = orphanFound.annotations || [];
|
||
// 笔记是用户创作,删除不可撤销,必须让用户单独确认这一项
|
||
const lines = [];
|
||
if (annotations.length) lines.push(`<li>${annotations.length} 份孤立批注</li>`);
|
||
if (notes.length) {
|
||
const noteTotal = notes.reduce((sum, item) => sum + item.notes, 0);
|
||
lines.push(`<li>${notes.length} 本已移除书籍的阅读资料,含 <b>${noteTotal} 条笔记</b></li>`);
|
||
}
|
||
const choice = await openModal('清理残留阅读资料', `
|
||
<p>检查到:</p>
|
||
<ul class="library-bulk-preview">${lines.join('')}</ul>
|
||
<p style="margin-top:8px"><label><input type="checkbox" id="orphanDelAnnotations" checked /> 清理孤立批注</label></p>
|
||
${notes.length ? `<p style="margin-top:6px"><label><input type="checkbox" id="orphanDelNotes" /> 同时删除笔记、书签与进度</label></p>
|
||
<p class="muted" style="margin-top:6px">这些笔记目前仍可在「我的笔记」中查看,删除后无法恢复。</p>` : ''}
|
||
`, () => ({
|
||
annotations: !!(document.getElementById('orphanDelAnnotations') || {}).checked,
|
||
notes: !!(document.getElementById('orphanDelNotes') || {}).checked
|
||
}));
|
||
if (!choice) return;
|
||
if (!choice.annotations && !choice.notes) return;
|
||
const r = await window.api.reader.purgeOrphans(choice);
|
||
if (!r || !r.ok) {
|
||
await confirmModal('清理失败', (r && r.error) || '未知错误');
|
||
return;
|
||
}
|
||
const done = r.data || {};
|
||
$('orphanStatus').textContent =
|
||
`已清理 ${done.annotationsRemoved || 0} 份批注、${done.notesRemoved || 0} 本阅读资料`;
|
||
$('orphanPurgeBtn').classList.add('hidden');
|
||
orphanFound = null;
|
||
};
|
||
|
||
const AI_PROTOCOL_INFO = {
|
||
anthropic: {
|
||
label: 'Anthropic',
|
||
baseUrl: 'https://api.anthropic.com/v1',
|
||
model: 'claude-sonnet-4-5',
|
||
hint: 'Anthropic 原生 Messages API,图像使用 base64 source 格式。'
|
||
},
|
||
'openai-responses': {
|
||
label: 'OpenAI Responses',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-4.1-mini',
|
||
hint: 'OpenAI 原生 Responses API,使用 /v1/responses。'
|
||
},
|
||
'chat-completions': {
|
||
label: 'OpenAI 兼容',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-4o-mini',
|
||
hint: 'Chat Completions API,适用于 DeepSeek、Kimi、硅基流动、Ollama 等兼容服务。'
|
||
}
|
||
};
|
||
|
||
function syncAiProtocolUi() {
|
||
const info = AI_PROTOCOL_INFO[$('aiProtocol').value] || AI_PROTOCOL_INFO['chat-completions'];
|
||
$('aiBaseUrl').placeholder = info.baseUrl;
|
||
$('aiModel').placeholder = info.model;
|
||
$('aiProtocolHint').textContent = info.hint;
|
||
}
|
||
|
||
$('aiProtocol').onchange = syncAiProtocolUi;
|
||
|
||
async function refreshAiStatus() {
|
||
const r = await window.api.ai.status();
|
||
const s = r.ok && r.data ? r.data : null;
|
||
if (!s) { $('aiStatus').textContent = '状态读取失败'; return; }
|
||
$('aiProtocol').value = s.protocol || 'chat-completions';
|
||
$('aiBaseUrl').value = s.baseUrl || '';
|
||
$('aiModel').value = s.model || '';
|
||
$('aiVision').checked = !!s.vision;
|
||
syncAiProtocolUi();
|
||
const ready = s.ready === undefined ? (s.hasKey || s.isLocal) : !!s.ready;
|
||
const protocol = AI_PROTOCOL_INFO[s.protocol] || AI_PROTOCOL_INFO['chat-completions'];
|
||
if (ready) {
|
||
$('aiStatus').textContent = `已就绪 · ${protocol.label} · ${s.model}${s.vision ? ' · 支持图像' : ''}${s.hasKey ? (s.persistent ? '(Key 已加密存储)' : '(Key 仅本次运行有效)') : '(本地模型,无需 Key)'}`;
|
||
} else if (s.modelConfigured) {
|
||
$('aiStatus').textContent = s.keyState === 'unreadable'
|
||
? `模型已配置 · ${protocol.label} · ${s.model} · 已保存的 API Key 无法读取,请重新输入`
|
||
: `模型已配置 · ${protocol.label} · ${s.model} · 尚缺 API Key`;
|
||
} else {
|
||
$('aiStatus').textContent = '尚未保存模型配置:请填写接口地址与模型名称';
|
||
}
|
||
$('aiKey').placeholder = s.hasKey
|
||
? '已保存,留空表示不修改'
|
||
: (s.keyState === 'unreadable'
|
||
? '原 Key 无法读取,请重新输入'
|
||
: (s.isLocal ? '本地模型可留空' : '必填(仅本地模型可留空)'));
|
||
$('aiClearBtn').classList.toggle('hidden', !s.hasKey);
|
||
}
|
||
|
||
$('aiSaveBtn').onclick = async () => {
|
||
const btn = $('aiSaveBtn');
|
||
const keyInput = $('aiKey');
|
||
const cfg = {
|
||
protocol: $('aiProtocol').value,
|
||
baseUrl: $('aiBaseUrl').value.trim(),
|
||
model: $('aiModel').value.trim(),
|
||
vision: $('aiVision').checked
|
||
};
|
||
// 留空表示不改动已存的 Key,避免用户只改模型名就把 Key 清掉
|
||
if (keyInput.value.trim()) cfg.apiKey = keyInput.value.trim();
|
||
const r = await window.api.ai.save(cfg);
|
||
keyInput.value = '';
|
||
btn.textContent = r.ok ? '已保存 ✓' : '保存失败';
|
||
btn.title = r.ok ? '' : (r.error || '');
|
||
if (!r.ok) await confirmModal('保存失败', r.error || '请检查接口地址与模型名称');
|
||
await refreshAiStatus();
|
||
setTimeout(() => { btn.textContent = '保存'; }, 1500);
|
||
};
|
||
|
||
$('aiClearBtn').onclick = async () => {
|
||
const ok = await confirmModal('清除 AI 配置', '确定清除接口地址、模型与 API Key 吗?');
|
||
if (!ok) return;
|
||
await window.api.ai.clear();
|
||
$('aiKey').value = '';
|
||
await refreshAiStatus();
|
||
};
|
||
|
||
refreshAiStatus();
|
||
|
||
async function runUpdateCheck(silent) {
|
||
const statusEl = $('updateStatus');
|
||
const btn = $('checkUpdateBtn');
|
||
if (!silent) {
|
||
btn.disabled = true;
|
||
statusEl.textContent = '正在检查...';
|
||
}
|
||
const res = await window.api.checkUpdate();
|
||
if (!silent) btn.disabled = false;
|
||
if (!res.ok) {
|
||
if (!silent) statusEl.textContent = '检查失败:' + res.error;
|
||
return;
|
||
}
|
||
const { latest, hasUpdate, url } = res.data;
|
||
if (hasUpdate) {
|
||
statusEl.textContent = `发现新版本 ${latest}`;
|
||
const ok = await openModal(
|
||
'发现新版本',
|
||
`<p>检测到新版本 <b>${escapeHtml(latest)}</b>,是否前往 GitHub Releases 下载?</p>`
|
||
);
|
||
if (ok) window.api.openExternal(url);
|
||
} else if (!silent) {
|
||
statusEl.textContent = '已是最新版本';
|
||
}
|
||
}
|
||
|
||
window.api.getVersion().then((r) => {
|
||
if (r && r.ok) $('appVersion').textContent = 'v' + r.data;
|
||
});
|
||
|
||
const autoCheckEl = $('autoCheckUpdate');
|
||
window.api.settings.get('autoCheckUpdate', false).then((r) => {
|
||
autoCheckEl.checked = !!(r.ok && r.data);
|
||
if (autoCheckEl.checked) runUpdateCheck(true);
|
||
});
|
||
autoCheckEl.onchange = () => window.api.settings.set('autoCheckUpdate', autoCheckEl.checked);
|
||
|
||
$('checkUpdateBtn').onclick = () => runUpdateCheck(false);
|
||
|
||
switchTab('library');
|