feat: 书库支持多选批量整理与移除,修正侧栏选中亮条

工具栏新增「选择」模式,卡片显示复选框,工具条提供全选、批量整理与
批量移除。多选时隐藏单卡操作并停用封面阅读入口,避免勾选途中误开
阅读器。全选只覆盖当前筛选结果,切换书架、标签或搜索后会剔除已不
可见的选中项,不对看不见的书执行批量操作。

批量标签用三态复选框:indeterminate 表示部分书持有,跳过即保持原样,
只有明确勾上或取消才统一应用。沿用单本的覆盖语义会静默抹掉其它书
已有的标签。所选书籍分属不同书架时,下拉默认「保持不变」。

侧栏行内的重命名与删除按钮改为绝对定位。此前它们虽然 opacity 为 0,
仍占据 flex 空间,使书架与标签的选中亮条比「全部书籍」窄 47px。书架
列表原为普通 block 无行间距,现与标签列表共用 .library-filter-list,
两处间距一致。
This commit is contained in:
lofyer
2026-08-03 17:18:33 +08:00
parent 381c07733a
commit 522b0f74a5
5 changed files with 488 additions and 17 deletions
+218 -3
View File
@@ -8,6 +8,9 @@ const Library = (() => {
let searchQuery = '';
let refreshSeq = 0;
let grid, statusEl;
let selectMode = false;
const selectedIds = new Set();
let visibleIds = [];
const SORTERS = {
recent: (a, b) => (
@@ -52,6 +55,15 @@ const Library = (() => {
document.querySelectorAll('#libraryTab .library-filter[data-shelf]').forEach((button) => {
button.onclick = () => selectShelf(button.dataset.shelf || '');
});
$('librarySelectModeBtn').onclick = () => setSelectMode(!selectMode);
$('librarySelectExitBtn').onclick = () => setSelectMode(false);
$('librarySelectAll').onchange = (event) => {
if (event.target.checked) visibleIds.forEach((id) => selectedIds.add(id));
else selectedIds.clear();
syncSelectionUi();
};
$('libraryBulkOrganizeBtn').onclick = bulkOrganize;
$('libraryBulkRemoveBtn').onclick = bulkRemove;
window.api.library.onChanged(() => { dirty = true; refresh(true); });
if (window.api.reader && window.api.reader.onNotesChanged) {
window.api.reader.onNotesChanged(() => {
@@ -152,6 +164,51 @@ const Library = (() => {
refresh(true);
}
function setSelectMode(on) {
selectMode = !!on;
if (!selectMode) selectedIds.clear();
$('libraryTab').classList.toggle('select-mode', selectMode);
$('librarySelectionBar').classList.toggle('hidden', !selectMode);
const toggle = $('librarySelectModeBtn');
toggle.classList.toggle('active', selectMode);
toggle.setAttribute('aria-pressed', String(selectMode));
dirty = true;
refresh(true);
}
function syncSelectionUi() {
if (!selectMode) return;
const total = visibleIds.length;
const picked = visibleIds.filter((id) => selectedIds.has(id)).length;
const all = $('librarySelectAll');
all.checked = total > 0 && picked === total;
all.indeterminate = picked > 0 && picked < total;
all.disabled = total === 0;
$('librarySelectAllLabel').textContent = all.checked ? '取消全选' : '全选';
$('librarySelectionCount').textContent = picked
? `已选择 ${picked}${picked === total ? '(当前全部)' : ''}`
: '未选择';
$('libraryBulkOrganizeBtn').disabled = !picked;
$('libraryBulkRemoveBtn').disabled = !picked;
grid.querySelectorAll(':scope > .card').forEach((card) => {
const on = selectedIds.has(card.dataset.id);
card.classList.toggle('selected', on);
const box = card.querySelector('.card-select input');
if (box) box.checked = on;
});
}
function toggleSelection(id, on) {
if (on === undefined) on = !selectedIds.has(id);
if (on) selectedIds.add(id);
else selectedIds.delete(id);
syncSelectionUi();
}
function selectedEntries(items) {
return items.filter((item) => selectedIds.has(String(item.id)));
}
function cardHtml(it, noteCounts) {
const openable = (it.files || []).some((file) => file.exists);
const readable = (it.files || []).some((file) => (
@@ -169,24 +226,30 @@ const Library = (() => {
const tagBadges = (it.tags || []).slice(0, 3)
.map((tag) => `<span class="library-card-tag">${escapeHtml(tag)}</span>`)
.join('');
const selectBox = selectMode
? `<label class="card-select" title="选择"><input type="checkbox" aria-label="选择${escapeHtml(it.title)}" /></label>`
: '';
// 多选时封面不再触发阅读,否则勾选途中容易误开阅读器
const coverActs = readable && !selectMode;
return `
<div class="card" data-id="${escapeHtml(it.id)}">
${selectBox}
<div class="card-cover${readable ? ' readable' : ''}" style="${coverStyle(it.cover)}"
data-cover-state="${it.cover ? 'ready' : 'pending'}"
${readable ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
<div class="card-title" title="${escapeHtml(it.title)}">${escapeHtml(it.title)}</div>
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
${badge}
${noteBadge}
${tagBadges ? `<div class="library-card-tags">${tagBadges}</div>` : ''}
<div class="lib-card-actions">
${selectMode ? '' : `<div class="lib-card-actions">
${readable ? cardAction('read', '阅读', true) : ''}
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
${openable ? cardAction('reveal', '在文件夹中显示') : ''}
${it.url ? cardAction('page', '打开来源页面') : ''}
${cardAction('organize', '整理书架和标签')}
${cardAction('remove', '移除书籍')}
</div>
</div>`}
</div>`;
}
@@ -198,6 +261,13 @@ const Library = (() => {
onAction(id, button.dataset.act);
};
});
if (selectMode) {
const box = card.querySelector('.card-select input');
box.onclick = (event) => event.stopPropagation();
box.onchange = () => toggleSelection(id, box.checked);
card.onclick = () => toggleSelection(id);
return;
}
const cover = card.querySelector('.card-cover[data-act="read"]');
if (!cover) return;
cover.onclick = (event) => {
@@ -283,13 +353,20 @@ const Library = (() => {
: (items.length === allItems.length
? `${allItems.length}${missingCount ? `${missingCount} 条文件缺失` : ''}`
: `显示 ${items.length} 条,共 ${allItems.length}${missingCount ? `,当前 ${missingCount} 条文件缺失` : ''}`);
// 全选只覆盖当前筛选结果;条目被筛掉或删除后,其选中状态一并作废,
// 避免对看不见的书执行批量操作
visibleIds = items.map((item) => String(item.id));
const visible = new Set(visibleIds);
Array.from(selectedIds).forEach((id) => { if (!visible.has(id)) selectedIds.delete(id); });
if (!items.length) {
grid.innerHTML = `<div class="empty">${allItems.length
? (searchQuery ? '没有匹配标题或作者的书籍' : '当前分类中没有书籍')
: '书库为空,去「检索」页添加文献 / 图书吧'}</div>`;
syncSelectionUi();
return;
}
reconcileCards(items, noteCounts);
syncSelectionUi();
}
function selectShelf(id) {
@@ -507,6 +584,144 @@ const Library = (() => {
await refresh(true);
}
// 批量整理:标签用三态复选框。indeterminate 表示"部分书有该标签"
// 保持这个状态就不改动这些书原有的标签;只有用户明确勾上或取消才统一应用。
async function bulkOrganize() {
const listed = await window.api.library.list();
if (!listed || !listed.ok) {
await confirmModal('整理失败', (listed && listed.error) || '无法读取书库');
return;
}
const targets = selectedEntries(listed.data || []);
if (!targets.length) return;
const shelfIds = new Set(targets.map((item) => item.shelfId || ''));
const sharedShelf = shelfIds.size === 1 ? [...shelfIds][0] : null;
const options = shelves.map((shelf) => (
`<option value="${escapeHtml(shelf.id)}"${sharedShelf === shelf.id ? ' selected' : ''}>${escapeHtml(shelf.name)}</option>`
)).join('');
const counts = new Map();
targets.forEach((item) => (item.tags || []).forEach((tag) => {
const key = String(tag).toLocaleLowerCase();
counts.set(key, (counts.get(key) || 0) + 1);
}));
const tagOptions = libraryTags.map((tag) => {
const held = counts.get(tag.name.toLocaleLowerCase()) || 0;
const state = held === 0 ? 'none' : (held === targets.length ? 'all' : 'some');
return `<label class="library-tag-option">
<input type="checkbox" value="${escapeHtml(tag.name)}" data-state="${state}"${state === 'all' ? ' checked' : ''} />
<span># ${escapeHtml(tag.name)}</span>
<span class="library-filter-count">${state === 'some' ? `${held}/${targets.length}` : (tag.count || 0)}</span>
</label>`;
}).join('');
// indeterminate 只能用 DOM 属性设置,HTML 里写不出来;
// 点击后按 部分 -> 全选 -> 全不选 -> 部分 循环,保证"保持原样"始终可回到
const primeTagBoxes = () => {
document.querySelectorAll('#libraryBulkTags input[type="checkbox"]').forEach((box) => {
if (box.dataset.state !== 'some') return;
box.indeterminate = true;
box.onclick = () => {
const phase = box.dataset.phase || 'some';
const next = phase === 'some' ? 'all' : (phase === 'all' ? 'none' : 'some');
box.dataset.phase = next;
box.indeterminate = next === 'some';
box.checked = next === 'all';
};
});
};
const pending = openModal(`批量整理 ${targets.length}`, `
<div class="library-organize-form">
<label>书架
<select id="libraryBulkShelf">
${sharedShelf === null ? '<option value="__keep__" selected>保持不变(所选书籍分属不同书架)</option>' : ''}
<option value=""${sharedShelf === '' ? ' selected' : ''}>未分类</option>${options}
</select>
</label>
<label>标签
<details id="libraryBulkTags" class="library-tag-picker">
<summary>选择标签(部分选中的标签保持原样)</summary>
<div class="library-tag-options">${tagOptions || '<div class="library-tag-empty">暂无标签,请先在左侧新建</div>'}</div>
</details>
</label>
<div id="libraryBulkError" class="note-form-error"></div>
</div>
`, async () => {
const boxes = Array.from(document.querySelectorAll('#libraryBulkTags input[type="checkbox"]'));
const add = [];
const strip = [];
boxes.forEach((box) => {
if (box.indeterminate) return;
if (box.checked) add.push(box.value);
else if (box.dataset.state !== 'none') strip.push(box.value.toLocaleLowerCase());
});
const shelfValue = $('libraryBulkShelf').value;
const errorEl = $('libraryBulkError');
const failures = [];
for (const item of targets) {
const patch = {};
if (shelfValue !== '__keep__') patch.shelfId = shelfValue || null;
const kept = (item.tags || []).filter((tag) => !strip.includes(String(tag).toLocaleLowerCase()));
const merged = kept.slice();
add.forEach((tag) => {
if (!merged.some((existing) => String(existing).toLocaleLowerCase() === tag.toLocaleLowerCase())) {
merged.push(tag);
}
});
patch.tags = merged;
const response = await window.api.library.update(item.id, patch);
if (!response || !response.ok) failures.push(item.title);
}
if (failures.length) {
errorEl.textContent = `${failures.length} 本未能保存:${failures.slice(0, 3).join('、')}`;
return false;
}
return true;
});
primeTagBoxes();
if (!await pending) return;
setSelectMode(false);
}
async function bulkRemove() {
const listed = await window.api.library.list();
if (!listed || !listed.ok) {
await confirmModal('移除失败', (listed && listed.error) || '无法读取书库');
return;
}
const targets = selectedEntries(listed.data || []);
if (!targets.length) return;
const withFiles = targets.filter((item) => (item.files || []).some((file) => file.path)).length;
const preview = targets.slice(0, 5).map((item) => `<li>${escapeHtml(item.title)}</li>`).join('');
const choice = await openModal('批量移除', `
<p>确定移除以下 <b>${targets.length}</b> 本书吗?</p>
<ul class="library-bulk-preview">${preview}</ul>
${targets.length > 5 ? `<p class="muted">另有 ${targets.length - 5} 本未列出</p>` : ''}
${withFiles ? `<p style="margin-top:8px"><label><input type="checkbox" id="bulkDelFiles" /> 同时删除已下载的文件(${withFiles} 本有文件)</label></p>` : ''}
<p style="margin-top:8px"><label><input type="checkbox" id="bulkDelReadingData" /> 同时删除笔记、书签、进度和标注</label></p>
<p class="muted" style="margin-top:6px">默认保留阅读资料,移除后仍可在「我的笔记」中查看。</p>
`, () => ({
deleteFiles: !!(document.getElementById('bulkDelFiles') || {}).checked,
deleteReadingData: !!document.getElementById('bulkDelReadingData').checked
}));
if (!choice) return;
const failures = [];
for (const item of targets) {
const removed = await window.api.library.remove(item.id, choice);
if (!removed || !removed.ok) failures.push(item.title);
}
setSelectMode(false);
if (failures.length) {
await confirmModal('部分移除失败', `${failures.length} 本未能移除:${failures.slice(0, 3).join('、')}`);
return;
}
statusEl.textContent = `已移除 ${targets.length}`;
}
async function organizeBook(item) {
const options = shelves.map((shelf) => (
`<option value="${escapeHtml(shelf.id)}"${item.shelfId === shelf.id ? ' selected' : ''}>${escapeHtml(shelf.name)}</option>`