Files
peoplelib/src/ui/app.js
T
lofyer 488de94289 feat: 支持自定义 Z-Library 镜像并发布 v2.1.5
在 Z-Library 账户设置内增加镜像站点编辑入口,一行一个 HTTPS 地址,
自定义地址经 origin 规范化后优先于内置列表。镜像配置与凭据分开处理,
退出登录后仍保留;移除当前会话依赖的镜像时清理旧会话和 Cookie。

版本更新到 2.1.5,并补充存储、校验、IPC、界面与真实 Electron 保存测试。
2026-08-09 15:25:32 +08:00

474 lines
18 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.
$('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');