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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
1a1288ce18
+102
@@ -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');
|
||||
@@ -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="设置">⚙</button>
|
||||
<button id="minBtn" class="win-btn" title="最小化">─</button>
|
||||
<button id="maxBtn" class="win-btn" title="最大化">□</button>
|
||||
<button id="closeBtn" class="win-btn win-close" title="关闭">✕</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>
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,61 @@
|
||||
window.$ = (id) => document.getElementById(id);
|
||||
|
||||
window.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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>`);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user