feat: PeopleLib 开放文献客户端,集成 Z-Library 与 LibGen 等多源检索

Electron 桌面客户端,聚合多个开放获取文献源的搜索、详情与下载。

新增数据源:
- Z-Library:邮箱登录(凭据本地存储),会话失效自动重登
- LibGen:适配新版 libgen.ac 前端(旧版 search.php 镜像已全部下线)
- Memory of the World、Sci-Hub、Anna's Archive

基础设施:
- mirror.js:镜像故障转移,支持串行优先与并发竞速两种策略,
  失效镜像 5 分钟冷却后自动重试,避免站点恢复后被永久跳过
- http.js:统一 15 秒请求超时,防止单个卡死镜像拖垮整次搜索
- settings.js:全局代理配置持久化,经 Electron net.fetch 生效于所有请求

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-25 14:51:10 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 1a1288ce18
33 changed files with 4640 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
$('minBtn').onclick = () => window.api.minimize();
$('maxBtn').onclick = () => window.api.maximize();
$('closeBtn').onclick = () => window.api.close();
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');
$('browseTab').classList.toggle('hidden', tab !== 'browse');
$('settingsTab').classList.toggle('hidden', tab !== 'settings');
if (tab === 'library') Library.refresh(true);
}
document.querySelectorAll('.tab').forEach((t) => {
t.onclick = () => switchTab(t.dataset.tab);
});
Browse.init();
Library.init();
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" type="email" placeholder="邮箱" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<input id="zlibPassword" type="password" placeholder="密码" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<div id="zlibErr" style="color:#f66;font-size:12px;min-height:16px;"></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 refreshProxy() {
const r = await window.api.proxy.get();
if (r.ok) $('proxyInput').value = r.data || '';
}
$('proxySaveBtn').onclick = async () => {
await window.api.proxy.set($('proxyInput').value.trim());
$('proxySaveBtn').textContent = '已保存 ✓';
setTimeout(() => { $('proxySaveBtn').textContent = '保存'; }, 1500);
};
refreshProxy();
window.api.getVersion().then((r) => {
if (r && r.ok) $('appVersion').textContent = 'v' + r.data;
});
switchTab('library');
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https: http: data: file:; style-src 'self' 'unsafe-inline';" />
<title>PeopleLib 文献库</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="titlebar">
<div class="titlebar-left">
<span class="brand">PeopleLib <span class="brand-sub">开放文献库</span></span>
</div>
<nav class="tabs">
<button class="tab active" data-tab="library">我的书库</button>
<button class="tab" data-tab="browse">检索</button>
</nav>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<button class="win-btn tab" data-tab="settings" title="设置">&#9881;</button>
<button id="minBtn" class="win-btn" title="最小化">&#9472;</button>
<button id="maxBtn" class="win-btn" title="最大化">&#9633;</button>
<button id="closeBtn" class="win-btn win-close" title="关闭">&#10005;</button>
</div>
</div>
<main id="main">
<!-- 我的书库 -->
<section id="libraryTab" class="tab-panel">
<div class="toolbar">
<span id="libStatus" class="status-bar"></span>
<div class="spacer"></div>
<button id="addLocalBtn" class="tb-btn">+ 添加本地文件</button>
</div>
<div id="libGrid" class="grid"></div>
</section>
<!-- 检索 -->
<section id="browseTab" class="tab-panel hidden">
<div id="browseGridView">
<div class="browse-head">
<div class="toolbar">
<select id="sourceSelect" class="source-select"></select>
<div class="search-inline">
<input id="searchInput" type="text" placeholder="搜索标题 / 作者 / 关键词..." />
<button id="searchBtn" class="tb-btn">搜索</button>
<button id="clearSearchBtn" class="tb-btn ghost hidden">✕ 清除</button>
</div>
</div>
<div id="statusBar" class="status-bar"></div>
</div>
<div id="grid" class="grid"></div>
<div id="pager" class="pager hidden">
<button id="prevBtn" class="page-btn">← 上一页</button>
<span id="pageInfo" class="page-info"></span>
<button id="nextBtn" class="page-btn">下一页 →</button>
<span class="page-jump">
跳转到
<input id="jumpInput" type="number" min="1" class="jump-input" />
<button id="jumpBtn" class="page-btn">跳转</button>
</span>
</div>
</div>
<div id="detailView" class="hidden">
<button id="backBtn" class="back-btn" aria-label="返回">← 返回</button>
<div id="detailContent"></div>
</div>
</section>
<!-- 设置 -->
<section id="settingsTab" class="tab-panel hidden">
<div class="settings-page">
<h2 class="settings-title">设置</h2>
<div class="settings-group">
<div class="settings-item settings-item-block">
<div class="settings-item-info">
<div class="settings-item-label">数据源管理</div>
<div class="settings-item-desc">选择在检索页显示的数据源</div>
</div>
<div id="sourceList" class="source-list"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">书库默认排序</div>
<div class="settings-item-desc">控制书库条目的排列顺序</div>
</div>
<select id="sortSelect" class="source-select">
<option value="added">添加时间</option>
<option value="title">标题</option>
<option value="author">作者</option>
</select>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">网络代理</div>
<div class="settings-item-desc">对所有数据源与下载统一生效;访问 LibGen / Z-Library 通常需要代理(留空表示直连)</div>
</div>
<input id="proxyInput" type="text" placeholder="留空表示直连,例如 http://localhost:7897" style="width:220px;padding:6px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<button id="proxySaveBtn" class="tb-btn">保存</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">Z-Library 账号</div>
<div class="settings-item-desc" id="zlibStatus">未登录</div>
</div>
<button id="zlibLoginBtn" class="tb-btn">登录</button>
<button id="zlibLogoutBtn" class="tb-btn ghost hidden">退出</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">关于</div>
<div class="settings-item-desc">PeopleLib <span id="appVersion">-</span> · 开放获取文献与公版图书客户端</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- 通用弹窗 -->
<div id="modal" class="modal hidden">
<div class="modal-box">
<div id="modalTitle" class="modal-title"></div>
<div id="modalBody" class="modal-body"></div>
<div class="modal-actions">
<button id="modalCancel" class="page-btn">取消</button>
<button id="modalOk" class="tb-btn">确定</button>
</div>
</div>
</div>
<script src="util.js"></script>
<script src="views/browse.js"></script>
<script src="views/library.js"></script>
<script src="app.js"></script>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
:root {
--bg: #14161a;
--bg-soft: #181b20;
--bg-card: #1c2027;
--line: #2a2f38;
--accent: #6ea8fe;
--accent-bright: #9cc2ff;
--text: #dfe4ec;
--text-dim: #8b94a3;
--green: #3fb96f;
--danger: #d9534f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.hidden { display: none !important; }
/* 标题栏 */
.titlebar {
height: 44px;
background: linear-gradient(135deg, #171b26, #12141c);
display: flex; align-items: center;
padding: 0 8px 0 16px;
-webkit-app-region: drag;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.titlebar-left { flex-shrink: 0; margin-right: 24px; }
.brand { font-size: 15px; font-weight: 700; color: var(--accent-bright); letter-spacing: 0.5px; }
.brand-sub { color: var(--text-dim); font-weight: 400; font-size: 12px; }
.tabs { display: flex; gap: 4px; -webkit-app-region: no-drag; }
.tab {
height: 30px; padding: 0 18px;
background: transparent; border: none; color: var(--text-dim);
font-size: 14px; cursor: pointer; border-radius: 8px;
}
.tab:hover { color: var(--text); background: rgba(255,255,255,0.05); }
.tab.active { color: #0d1420; background: var(--accent); font-weight: 600; }
.titlebar-spacer { flex: 1; }
.titlebar-controls { display: flex; gap: 2px; -webkit-app-region: no-drag; flex-shrink: 0; }
.win-btn {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 30px;
background: transparent; border: none; border-radius: 6px;
color: var(--text-dim); font-size: 14px; cursor: pointer;
}
.win-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); }
.win-close:hover { background: var(--danger); color: #fff; }
/* 主区 */
#main { flex: 1; overflow-y: auto; padding: 20px; }
.tab-panel { min-height: 100%; }
/* 工具栏 */
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar .status-bar { margin: 0; flex: 1; }
.spacer { flex: 1; }
.tb-btn {
height: 30px; padding: 0 16px;
background: var(--accent); color: #0d1420; border: none; border-radius: 8px;
font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap;
}
.tb-btn:hover { background: var(--accent-bright); }
.tb-btn.ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--line); }
.tb-btn.ghost:hover { color: var(--text); border-color: var(--accent); }
.tb-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.tb-btn.in-lib { background: #2a2f38; color: var(--text-dim); }
.tb-btn.sm { height: 24px; padding: 0 10px; font-size: 12px; }
.tb-btn.danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
.tb-btn.danger:hover { background: var(--danger); color: #fff; }
.status-bar { color: var(--text-dim); font-size: 13px; min-height: 18px; }
/* 浏览页头部常驻 */
.browse-head {
position: sticky; top: -20px; z-index: 6;
margin: -20px -20px 0; padding: 20px 20px 14px;
background: linear-gradient(to bottom, var(--bg) 62%, transparent);
}
.browse-head .toolbar { margin-bottom: 0; }
.browse-head .status-bar { margin: 0; min-height: 0; }
.browse-head .status-bar:not(:empty) { margin-top: 8px; }
.source-select {
height: 30px; padding: 0 12px;
background: var(--bg-soft); color: var(--text);
border: 1px solid var(--line); border-radius: 8px; font-size: 13px; cursor: pointer; outline: none;
}
.search-inline { display: flex; align-items: center; gap: 8px; }
#searchInput {
width: 300px; height: 30px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--line); border-radius: 8px; padding: 0 14px;
color: var(--text); font-size: 13px; outline: none;
}
#searchInput:focus { border-color: var(--accent); }
/* 卡片网格 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}
.card { cursor: pointer; }
.card-cover {
width: 100%; aspect-ratio: 3/4;
background: var(--bg-card) center/cover no-repeat;
border: 1px solid var(--line); border-radius: 10px;
display: flex; align-items: center; justify-content: center;
padding: 10px; text-align: center;
transition: transform 0.15s, border-color 0.15s;
}
.card:hover .card-cover { transform: translateY(-3px); border-color: var(--accent); }
.card-cover .ph { color: var(--text-dim); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.card-title {
margin-top: 8px; font-size: 13px; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card-sub { margin-top: 2px; font-size: 11px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-date { margin-top: 2px; font-size: 11px; color: var(--text-dim); }
.card-badge {
display: inline-block; margin-top: 4px; padding: 1px 8px;
font-size: 11px; border-radius: 10px;
background: rgba(63,185,111,0.15); color: var(--green);
}
.card-badge.miss { background: rgba(217,83,79,0.15); color: var(--danger); }
.empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; }
/* 分页 */
.pager {
position: sticky; bottom: -20px; z-index: 6;
margin: 16px -20px -20px; padding: 12px 20px 16px;
background: linear-gradient(to top, var(--bg) 62%, transparent);
display: flex; align-items: center; justify-content: center; gap: 12px;
}
.page-btn {
height: 28px; padding: 0 14px;
background: var(--bg-soft); color: var(--text);
border: 1px solid var(--line); border-radius: 8px; font-size: 12px; cursor: pointer;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); }
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.page-info { color: var(--text-dim); font-size: 13px; }
.page-jump { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12px; }
.jump-input {
width: 60px; height: 28px; text-align: center;
background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px;
color: var(--text); font-size: 12px; outline: none;
}
/* 详情 */
.back-btn {
margin-bottom: 14px; height: 30px; padding: 0 14px;
background: transparent; color: var(--text-dim);
border: 1px solid var(--line); border-radius: 8px; font-size: 13px; cursor: pointer;
}
.back-btn:hover { color: var(--text); border-color: var(--accent); }
.detail-head { display: flex; gap: 20px; margin-bottom: 20px; }
.detail-cover {
width: 150px; height: 200px; flex-shrink: 0;
background: var(--bg-card) center/cover no-repeat;
border: 1px solid var(--line); border-radius: 10px;
display: flex; align-items: center; justify-content: center; padding: 12px; text-align: center;
}
.detail-cover .ph { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
.detail-meta { flex: 1; min-width: 0; }
.detail-title { font-size: 20px; font-weight: 700; line-height: 1.4; margin-bottom: 8px; }
.detail-authors { color: var(--accent-bright); font-size: 13px; margin-bottom: 10px; }
.meta-row { font-size: 13px; color: var(--text-dim); margin-bottom: 6px; }
.meta-row b { color: var(--text); font-weight: 600; margin-right: 8px; }
.meta-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
.meta-row a:hover { text-decoration: underline; }
.add-lib-btn { margin-top: 10px; }
.add-lib-hint { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.section-title { font-size: 14px; font-weight: 700; color: var(--accent-bright); margin: 18px 0 10px; }
.brief-panel {
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 10px;
padding: 14px; font-size: 13px; line-height: 1.7; color: var(--text-dim);
max-height: 220px; overflow-y: auto;
}
/* 下载区 */
.download-box { display: flex; flex-direction: column; gap: 10px; }
.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; }
.dl-error { color: var(--danger); }
.retry-btn { margin-left: 10px; background: none; border: none; color: var(--accent); cursor: pointer; font-size: 13px; }
.dl-files { display: flex; flex-direction: column; gap: 6px; }
.dl-panel-name { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
.dl-file-row {
display: flex; align-items: center; gap: 10px;
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px;
}
.dl-file-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dl-fmt { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 6px; padding: 0 6px; flex-shrink: 0; }
.copy-btn, .dl-btn {
height: 24px; padding: 0 10px; flex-shrink: 0;
background: transparent; color: var(--text-dim);
border: 1px solid var(--line); border-radius: 6px; font-size: 12px; cursor: pointer;
}
.copy-btn:hover, .dl-btn:hover { color: var(--text); border-color: var(--accent); }
.dl-btn { background: var(--accent); color: #0d1420; border: none; }
.dl-btn:hover { background: var(--accent-bright); color: #0d1420; }
.dl-btn.copied, .copy-btn.copied { color: var(--green); border-color: var(--green); }
.dl-link-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 4px 0; }
.dl-link-row a { color: var(--accent); text-decoration: none; }
.dl-link-row a:hover { text-decoration: underline; }
/* 书库 */
.lib-card-actions { display: flex; gap: 6px; margin-top: 8px; }
.lib-card-actions button {
flex: 1; height: 26px; font-size: 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--line); background: transparent; color: var(--text-dim);
}
.lib-card-actions button:hover { color: var(--text); border-color: var(--accent); }
.lib-card-actions .open-btn { background: var(--accent); color: #0d1420; border: none; font-weight: 600; }
.lib-card-actions .open-btn:hover { background: var(--accent-bright); }
.lib-card-actions .open-btn:disabled { background: #2a2f38; color: var(--text-dim); cursor: not-allowed; }
/* 设置 */
.settings-page { max-width: 720px; }
.settings-title { font-size: 20px; margin-bottom: 20px; }
.settings-group {
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 12px;
padding: 6px 18px; margin-bottom: 16px;
}
.settings-item { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--line); }
.settings-item:last-child { border-bottom: none; }
.settings-item-block { flex-direction: column; align-items: stretch; }
.settings-item-label { font-size: 14px; font-weight: 600; }
.settings-item-desc { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
.source-list { display: flex; flex-direction: column; gap: 2px; padding: 10px 0 14px; }
.source-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; cursor: pointer; }
.source-row input { accent-color: var(--accent); }
/* 弹窗 */
.modal {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
}
.modal-box {
width: 420px; max-width: 90vw;
background: var(--bg-card); border: 1px solid var(--line); border-radius: 14px; padding: 20px;
}
.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
.modal-body { font-size: 13px; color: var(--text-dim); line-height: 1.6; margin-bottom: 18px; }
.modal-body input[type="text"] {
width: 100%; height: 32px; margin-top: 8px;
background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px;
padding: 0 12px; color: var(--text); font-size: 13px; outline: none;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* 滚动条 */
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2f38; border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: #3a4150; }
+61
View File
@@ -0,0 +1,61 @@
window.$ = (id) => document.getElementById(id);
window.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
window.coverStyle = (cover) => {
if (!cover) return '';
const url = /^(https?:|data:)/.test(cover) ? cover : 'file:///' + String(cover).replace(/\\/g, '/');
return `background-image:url('${url.replace(/'/g, "\\'")}')`;
};
window.copyText = async (btn, text) => {
await window.api.copy(text);
const orig = btn.textContent;
btn.textContent = '已复制 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1500);
};
window.formatDate = (ts) => {
if (!ts) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
window.getEnabledSources = () => {
let ids = null;
try {
const raw = localStorage.getItem('enabledSources');
if (raw) ids = JSON.parse(raw);
} catch (e) { /* ignore */ }
return ids;
};
window.setEnabledSources = (ids) => {
localStorage.setItem('enabledSources', JSON.stringify(ids));
};
// 通用弹窗: 返回 Promise<{ok, values}|null>
window.openModal = (title, bodyHtml, onOk) => {
const modal = $('modal');
$('modalTitle').textContent = title;
$('modalBody').innerHTML = bodyHtml;
modal.classList.remove('hidden');
return new Promise((resolve) => {
const close = (result) => {
modal.classList.add('hidden');
$('modalOk').onclick = null;
$('modalCancel').onclick = null;
resolve(result);
};
$('modalCancel').onclick = () => close(null);
$('modalOk').onclick = async () => {
const r = onOk ? await onOk() : true;
if (r !== false) close(r);
};
});
};
window.confirmModal = (title, text) => window.openModal(title, `<p>${escapeHtml(text)}</p>`);
+313
View File
@@ -0,0 +1,313 @@
const Browse = (() => {
const state = {
sourceId: null,
supportsSearch: true,
mode: 'list',
keyword: '',
page: 1,
maxPage: 1,
scrollY: 0,
currentPostId: null,
currentDetail: null
};
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
function init() {
grid = $('grid');
statusBar = $('statusBar');
pager = $('pager');
gridView = $('browseGridView');
detailView = $('detailView');
detailContent = $('detailContent');
mainEl = $('main');
sourceSelect = $('sourceSelect');
searchInput = $('searchInput');
$('searchBtn').onclick = doSearch;
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
$('clearSearchBtn').onclick = clearSearch;
$('prevBtn').onclick = () => { if (state.page > 1) { state.page--; loadGrid(); } };
$('nextBtn').onclick = () => { if (state.page < state.maxPage) { state.page++; loadGrid(); } };
$('jumpBtn').onclick = jumpToPage;
$('jumpInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') jumpToPage(); });
$('backBtn').onclick = showGrid;
sourceSelect.onchange = () => {
state.sourceId = sourceSelect.value;
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
updateSearchUi();
state.mode = 'list';
state.page = 1;
clearSearchUi();
loadGrid();
};
loadSources();
}
function updateSearchUi() {
searchInput.disabled = !state.supportsSearch;
$('searchBtn').disabled = !state.supportsSearch;
searchInput.placeholder = state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览';
}
async function loadSources() {
const res = await window.api.sources.list();
const all = res.ok ? res.data : [];
const enabled = getEnabledSources();
const list = enabled ? all.filter((s) => enabled.includes(s.id)) : all;
sourceSelect.innerHTML = list.map((s) =>
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
if (!list.length) {
state.sourceId = null;
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
pager.classList.add('hidden');
statusBar.textContent = '';
return;
}
if (!list.some((s) => s.id === state.sourceId)) state.sourceId = list[0].id;
sourceSelect.value = state.sourceId;
state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0';
updateSearchUi();
loadGrid();
}
function doSearch() {
if (!state.supportsSearch) return;
const kw = searchInput.value.trim();
if (!kw) return;
state.mode = 'search';
state.keyword = kw;
state.page = 1;
$('clearSearchBtn').classList.remove('hidden');
loadGrid();
}
function clearSearchUi() {
searchInput.value = '';
$('clearSearchBtn').classList.add('hidden');
}
function clearSearch() {
state.mode = 'list';
state.keyword = '';
state.page = 1;
clearSearchUi();
loadGrid();
}
async function loadGrid() {
showGrid();
statusBar.textContent = '加载中...';
grid.innerHTML = '';
pager.classList.add('hidden');
mainEl.scrollTop = 0;
const res = state.mode === 'search'
? await window.api.sources.search(state.sourceId, state.keyword, state.page)
: await window.api.sources.browse(state.sourceId, state.page);
if (!res.ok) {
const isAuth = state.sourceId === 'zlib' && /登录|登录|AUTH/i.test(res.error || '');
statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} <button class="retry-btn" id="gridRetry">重试</button>`;
if (isAuth) {
grid.innerHTML = '<div class="empty">Z-Library 需要登录,请到"设置"页配置账号</div>';
} else {
grid.innerHTML = '<div class="empty">该数据源暂时不可用,可切换其它源</div>';
}
$('gridRetry').onclick = loadGrid;
return;
}
const { items, maxPage } = res.data;
state.maxPage = maxPage || 1;
if (!items.length) {
grid.innerHTML = '<div class="empty">未找到相关结果</div>';
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}` : '';
return;
}
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : '';
grid.innerHTML = items.map((it) => `
<div class="card" data-id="${escapeHtml(it.postId)}">
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
<div class="card-title">${escapeHtml(it.title)}</div>
${it.subtitle ? `<div class="card-sub">${escapeHtml(it.subtitle)}</div>` : ''}
${it.date ? `<div class="card-date">${escapeHtml(it.date)}</div>` : ''}
</div>`).join('');
grid.querySelectorAll('.card').forEach((el) => {
el.onclick = () => openDetail(el.dataset.id);
});
$('pageInfo').textContent = `${state.page} / ${state.maxPage}`;
$('prevBtn').disabled = state.page <= 1;
$('nextBtn').disabled = state.page >= state.maxPage;
const jump = $('jumpInput');
jump.max = state.maxPage;
jump.value = '';
jump.placeholder = state.page;
pager.classList.remove('hidden');
}
function jumpToPage() {
const input = $('jumpInput');
let n = parseInt(input.value, 10);
if (!n || n < 1) return;
if (n > state.maxPage) n = state.maxPage;
if (n === state.page) return;
state.page = n;
loadGrid();
}
function showGrid() {
detailView.classList.add('hidden');
gridView.classList.remove('hidden');
if (state.scrollY) mainEl.scrollTop = state.scrollY;
}
async function openDetail(postId) {
state.scrollY = mainEl.scrollTop;
state.currentPostId = postId;
gridView.classList.add('hidden');
detailView.classList.remove('hidden');
mainEl.scrollTop = 0;
detailContent.innerHTML = '<div class="dl-loading">加载中...</div>';
const res = await window.api.sources.detail(state.sourceId, postId);
if (!res.ok) {
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
$('detailRetry').onclick = () => openDetail(postId);
return;
}
state.currentDetail = res.data;
renderDetail(res.data);
loadDownload(postId);
refreshAddButton();
}
function renderDetail(d) {
const tagsHtml = (d.tags || []).map((t) => {
const idx = t.indexOf('');
if (idx > 0) return `<div class="meta-row"><b>${escapeHtml(t.slice(0, idx))}</b>${escapeHtml(t.slice(idx + 1))}</div>`;
return `<div class="meta-row">${escapeHtml(t)}</div>`;
}).join('');
const authorsHtml = (d.authors && d.authors.length)
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
const briefHtml = d.brief ? `<div class="section-title">简介 / 摘要</div><div class="brief-panel">${escapeHtml(d.brief)}</div>` : '';
detailContent.innerHTML = `
<div class="detail-head">
<div class="detail-cover" style="${coverStyle(d.cover)}">${d.cover ? '' : `<div class="ph">${escapeHtml(d.title)}</div>`}</div>
<div class="detail-meta">
<div class="detail-title">${escapeHtml(d.title)}</div>
${authorsHtml}
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
${tagsHtml}
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
</div>
</div>
<div class="section-title">下载 / 全文</div>
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
${briefHtml}
`;
$('addLibBtn').onclick = addToLibrary;
const urlLink = $('detailUrlLink');
if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); };
}
async function refreshAddButton() {
const btn = $('addLibBtn');
if (!btn) return;
const res = await window.api.library.findBySource(state.sourceId, state.currentPostId);
if (res.ok && res.data) {
btn.textContent = '已在书库 ✓';
btn.disabled = true;
btn.classList.add('in-lib');
}
}
async function addToLibrary() {
const d = state.currentDetail;
if (!d) return;
const res = await window.api.library.add({
title: d.title,
authors: d.authors || [],
cover: d.cover,
date: d.date || '',
brief: d.brief || '',
url: d.url || '',
sourceId: state.sourceId,
sourcePostId: state.currentPostId
});
if (res.ok) {
const btn = $('addLibBtn');
btn.textContent = '已加入书库 ✓';
btn.disabled = true;
btn.classList.add('in-lib');
if (window.Library) window.Library.markDirty();
}
}
async function loadDownload(postId) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = '<div class="dl-loading">下载信息获取中...</div>';
const res = await window.api.sources.download(state.sourceId, postId);
if (!box.isConnected) return;
if (!res.ok) {
box.innerHTML = `<div class="dl-error">获取失败:${escapeHtml(res.error)}<button class="retry-btn" id="dlRetry">重试</button></div>`;
$('dlRetry').onclick = () => loadDownload(postId);
return;
}
const d = res.data;
let html = '';
const files = d.files || [];
if (files.length) {
html += `<div class="dl-files">${files.map((f) => `<div class="dl-file-row">
<span class="dl-file-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
${f.format ? `<span class="dl-fmt">${escapeHtml(f.format)}</span>` : ''}
<button class="copy-btn" data-copy="${escapeHtml(f.link)}">复制</button>
<button class="dl-btn" data-file="${escapeHtml(f.link)}" data-name="${escapeHtml(f.name)}">下载</button>
</div>`).join('')}</div>`;
}
const links = d.links || [];
if (links.length) {
html += links.map((l) => `<div class="dl-link-row">🔗 <a href="#" data-open="${escapeHtml(l.url)}">${escapeHtml(l.name)}</a></div>`).join('');
}
box.innerHTML = html || '<div class="dl-error">未解析到下载信息</div>';
box.querySelectorAll('.copy-btn').forEach((btn) => { btn.onclick = () => copyText(btn, btn.dataset.copy); });
box.querySelectorAll('.dl-btn[data-file]').forEach((btn) => { btn.onclick = () => downloadFile(btn, btn.dataset.file); });
box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; });
}
async function downloadFile(btn, url) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '下载中...';
const lib = await window.api.library.findBySource(state.sourceId, state.currentPostId);
const entryId = (lib.ok && lib.data) ? lib.data.id : undefined;
const res = await window.api.downloadFile(url, btn.dataset.name || '', entryId);
if (res.ok && res.data && res.data.canceled) {
btn.textContent = orig; btn.disabled = false;
return;
}
if (res.ok) {
btn.textContent = '已保存 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); btn.disabled = false; }, 2000);
} else {
btn.textContent = '失败';
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000);
}
}
return { init, reloadSources: loadSources };
})();
window.Browse = Browse;
+114
View File
@@ -0,0 +1,114 @@
const Library = (() => {
let dirty = true;
let sortMode = localStorage.getItem('libSortMode') || 'added';
let grid, statusEl;
const SORTERS = {
added: (a, b) => (b.addedAt || 0) - (a.addedAt || 0),
title: (a, b) => String(a.title).localeCompare(String(b.title), 'zh'),
author: (a, b) => String((a.authors || [])[0] || '').localeCompare(String((b.authors || [])[0] || ''), 'zh')
};
function init() {
grid = $('libGrid');
statusEl = $('libStatus');
$('addLocalBtn').onclick = addLocal;
window.api.library.onChanged(() => { dirty = true; refresh(true); });
}
function getSortMode() { return sortMode; }
function setSortMode(m) {
sortMode = m;
localStorage.setItem('libSortMode', m);
dirty = true;
refresh(true);
}
async function refresh(force) {
if (!force && !dirty) return;
const res = await window.api.library.list();
dirty = false;
if (!res.ok) { statusEl.textContent = '加载失败:' + res.error; return; }
const items = res.data.slice().sort(SORTERS[sortMode] || SORTERS.added);
statusEl.textContent = `${items.length}`;
if (!items.length) {
grid.innerHTML = '<div class="empty">书库为空,去「检索」页添加文献 / 图书吧</div>';
return;
}
grid.innerHTML = items.map((it) => {
const hasFile = (it.files || []).some((f) => f.path);
const badge = hasFile
? '<span class="card-badge">已下载</span>'
: '<span class="card-badge miss">未下载</span>';
return `
<div class="card" data-id="${escapeHtml(it.id)}">
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
<div class="card-title">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
<div class="lib-card-actions">
<button class="open-btn" data-act="open" ${hasFile ? '' : 'disabled'}>打开</button>
${it.url ? '<button data-act="page">页面</button>' : ''}
<button data-act="remove">移除</button>
</div>
</div>`;
}).join('');
grid.querySelectorAll('.card').forEach((el) => {
const id = el.dataset.id;
el.querySelectorAll('button').forEach((btn) => {
btn.onclick = (e) => { e.stopPropagation(); onAction(id, btn.dataset.act); };
});
});
}
async function onAction(id, act) {
const res = await window.api.library.get(id);
if (!res.ok || !res.data) return;
const it = res.data;
if (act === 'open') {
const f = (it.files || []).find((x) => x.path);
if (f) window.api.openPath(f.path);
} else if (act === 'page') {
if (it.url) window.api.openExternal(it.url);
} else if (act === 'remove') {
const hasFile = (it.files || []).some((f) => f.path);
const r = await openModal('移除条目', `
<p>确定移除「${escapeHtml(it.title)}」吗?</p>
${hasFile ? '<p style="margin-top:8px"><label><input type="checkbox" id="delFiles" /> 同时删除已下载的文件</label></p>' : ''}
`, () => ({ del: !!(document.getElementById('delFiles') || {}).checked }));
if (!r) return;
await window.api.library.remove(id, r.del);
dirty = true;
refresh(true);
}
}
async function addLocal() {
const r = await window.api.pickFile();
if (!r.ok || !r.data) return;
const { path: p, name } = r.data;
const res = await openModal('添加本地文件', `
<p>文件:${escapeHtml(p)}</p>
<input type="text" id="localTitle" placeholder="标题" value="${escapeHtml(name)}" />
<input type="text" id="localAuthor" placeholder="作者(可选)" />
`, () => ({
title: (document.getElementById('localTitle').value || name).trim(),
author: (document.getElementById('localAuthor').value || '').trim()
}));
if (!res) return;
await window.api.library.add({
title: res.title,
authors: res.author ? [res.author] : [],
files: [{ path: p, name: p.split(/[\\/]/).pop(), format: (p.split('.').pop() || '').toUpperCase() }]
});
dirty = true;
refresh(true);
}
function markDirty() { dirty = true; }
return { init, refresh, markDirty, getSortMode, setSortMode };
})();
window.Library = Library;