Files
peoplelib/src/ui/app.js
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 3ccd044527 feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 12:13:02 +08:00

350 lines
13 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);
});
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 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);
refreshLibraryDir();
refreshAskSave();
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();
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');