feat: 书库支持多选批量整理与移除,修正侧栏选中亮条
工具栏新增「选择」模式,卡片显示复选框,工具条提供全选、批量整理与 批量移除。多选时隐藏单卡操作并停用封面阅读入口,避免勾选途中误开 阅读器。全选只覆盖当前筛选结果,切换书架、标签或搜索后会剔除已不 可见的选中项,不对看不见的书执行批量操作。 批量标签用三态复选框:indeterminate 表示部分书持有,跳过即保持原样, 只有明确勾上或取消才统一应用。沿用单本的覆盖语义会静默抹掉其它书 已有的标签。所选书籍分属不同书架时,下拉默认「保持不变」。 侧栏行内的重命名与删除按钮改为绝对定位。此前它们虽然 opacity 为 0, 仍占据 flex 空间,使书架与标签的选中亮条比「全部书籍」窄 47px。书架 列表原为普通 block 无行间距,现与标签列表共用 .library-filter-list, 两处间距一致。
This commit is contained in:
+218
-3
@@ -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>`
|
||||
|
||||
Reference in New Issue
Block a user