diff --git a/src/_test/electron/library-notes.integration.js b/src/_test/electron/library-notes.integration.js
index c4b3157..fc80b8b 100644
--- a/src/_test/electron/library-notes.integration.js
+++ b/src/_test/electron/library-notes.integration.js
@@ -738,6 +738,143 @@ app.whenReady().then(async () => {
&& (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
);
+ await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
+ await pollJs('多选前重置为全部书籍', "document.querySelectorAll('#libGrid .card').length === 3");
+
+ check(
+ '默认不进入多选模式',
+ await js(`document.getElementById('librarySelectionBar').classList.contains('hidden')
+ && document.querySelectorAll('#libGrid .card-select').length === 0`)
+ );
+
+ await js("document.getElementById('librarySelectModeBtn').click()");
+ await pollJs(
+ '进入多选模式后卡片出现复选框',
+ "document.querySelectorAll('#libGrid .card-select').length === 3"
+ );
+ check(
+ '多选模式隐藏单卡操作并停用封面阅读入口',
+ await js(`!document.getElementById('librarySelectionBar').classList.contains('hidden')
+ && document.querySelectorAll('#libGrid .lib-card-actions').length === 0
+ && document.querySelectorAll('#libGrid .card-cover[data-act="read"]').length === 0
+ && document.getElementById('libraryBulkOrganizeBtn').disabled
+ && document.getElementById('libraryBulkRemoveBtn').disabled`)
+ );
+
+ await js("document.querySelectorAll('#libGrid .card-select input')[0].click()");
+ await pollJs(
+ '勾选单项后启用批量操作',
+ `document.getElementById('librarySelectionCount').textContent.includes('已选择 1')
+ && document.getElementById('librarySelectAll').indeterminate === true
+ && !document.getElementById('libraryBulkOrganizeBtn').disabled`
+ );
+
+ await js("document.getElementById('librarySelectAll').click()");
+ await pollJs(
+ '全选覆盖当前筛选结果',
+ `document.getElementById('librarySelectionCount').textContent.includes('已选择 3')
+ && document.getElementById('librarySelectAllLabel').textContent === '取消全选'`
+ );
+
+ // 全选只作用于当前筛选结果:切到只含一本的书架后,看不见的选中项必须失效,
+ // 否则批量操作会误伤用户看不到的书
+ await js(`(() => {
+ const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
+ .find((candidate) => candidate.textContent.trim() === '研究书架');
+ button.click();
+ })()`);
+ await pollJs(
+ '切换筛选后丢弃不可见项的选中状态',
+ `document.querySelectorAll('#libGrid .card').length === 1
+ && document.getElementById('librarySelectionCount').textContent.includes('已选择 1')`
+ );
+
+ await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
+ await pollJs('恢复全部书籍视图', "document.querySelectorAll('#libGrid .card').length === 3");
+ await js("document.getElementById('librarySelectAll').click()");
+ await pollJs(
+ '重新全选三本',
+ "document.getElementById('librarySelectionCount').textContent.includes('已选择 3')"
+ );
+
+ const beforeBulk = library.list().reduce((acc, item) => {
+ acc[item.id] = { shelfId: item.shelfId, tags: (item.tags || []).slice() };
+ return acc;
+ }, {});
+ const total = Object.keys(beforeBulk).length;
+ const holdersOf = (name) => Object.values(beforeBulk)
+ .filter((entry) => entry.tags.some((tag) => tag.toLocaleLowerCase() === name.toLocaleLowerCase()))
+ .length;
+ const expectedState = (name) => {
+ const held = holdersOf(name);
+ return held === 0 ? 'none' : (held === total ? 'all' : 'some');
+ };
+ const tagStateExpectations = ['Shared', 'Methods', 'Review', 'Archive']
+ .map((name) => [name, expectedState(name), holdersOf(name)]);
+
+ await js("document.getElementById('libraryBulkOrganizeBtn').click()");
+ await waitForModal('批量整理 3 本');
+ const actualStates = await js(`(() => {
+ const map = {};
+ document.querySelectorAll('#libraryBulkTags input[type="checkbox"]').forEach((box) => {
+ map[box.value] = { state: box.dataset.state, indeterminate: box.indeterminate, checked: box.checked };
+ });
+ return map;
+ })()`);
+ check(
+ '批量整理按持有比例给出标签三态',
+ tagStateExpectations.every(([name, state]) => {
+ const actual = actualStates[name];
+ if (!actual || actual.state !== state) return false;
+ if (state === 'some') return actual.indeterminate === true && actual.checked === false;
+ if (state === 'all') return actual.indeterminate === false && actual.checked === true;
+ return actual.indeterminate === false && actual.checked === false;
+ })
+ // 三种状态都要真实出现过,否则这条断言可能什么都没验到
+ && new Set(tagStateExpectations.map(([, state]) => state)).size === 3,
+ tagStateExpectations.map(([n, s, h]) => `${n}=${s}(${h}/${total})`).join(' ')
+ );
+ check(
+ '所选书籍书架不一致时默认保持不变',
+ await js("document.getElementById('libraryBulkShelf').value === '__keep__'")
+ );
+
+ // 只勾选一个未被任何书持有的标签,其余保持部分选中
+ await js(`(() => {
+ const box = Array.from(document.querySelectorAll('#libraryBulkTags input[type="checkbox"]'))
+ .find((candidate) => candidate.value === 'Archive');
+ box.click();
+ })()`);
+ await submitModal();
+ await poll(
+ '批量整理写入真实存储',
+ () => Promise.resolve(library.list().every((item) => (item.tags || []).includes('Archive')))
+ );
+ const afterBulk = library.list().reduce((acc, item) => {
+ acc[item.id] = { shelfId: item.shelfId, tags: (item.tags || []).slice() };
+ return acc;
+ }, {});
+ check(
+ '新增标签应用到全部选中项',
+ Object.values(afterBulk).every((entry) => entry.tags.includes('Archive'))
+ );
+ check(
+ '部分选中的标签保持原样,未被覆盖抹掉',
+ Object.entries(beforeBulk).every(([id, before]) => (
+ before.tags.every((tag) => afterBulk[id].tags.includes(tag))
+ )),
+ JSON.stringify(Object.values(afterBulk).map((e) => e.tags))
+ );
+ check(
+ '书架保持不变时未被改动',
+ Object.entries(beforeBulk).every(([id, before]) => afterBulk[id].shelfId === before.shelfId)
+ );
+ check(
+ '批量整理完成后退出多选模式',
+ await js(`document.getElementById('librarySelectionBar').classList.contains('hidden')
+ && document.querySelectorAll('#libGrid .card-select').length === 0`)
+ );
+
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
await pollJs(
'我的笔记页渲染完成',
diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js
index 87d91bc..c570049 100644
--- a/src/_test/ui.test.js
+++ b/src/_test/ui.test.js
@@ -501,6 +501,58 @@ test('书架操作对键盘焦点可见', () => {
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*none/);
});
+test('侧栏行内操作不占据布局,选中高亮与静态筛选项等宽', () => {
+ const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ // 留在文档流里会占掉约 47px,使书架/标签的选中亮条比「全部书籍」窄一截
+ const actionsRule = css.match(
+ /\.library-shelf-actions,\s*\n\.library-tag-actions\s*\{([^}]*)\}/
+ );
+ assert.ok(actionsRule, '书架与标签操作区应共用同一条规则');
+ assert.match(actionsRule[1], /position:\s*absolute/);
+ assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*flex;\s*flex:\s*none/);
+ assert.match(css, /\.library-shelf-row \.library-filter,\s*\n\.library-tag-row \.library-filter\s*\{[^}]*padding-right:\s*48px/);
+ // 书架与标签共用同一个列表容器类,行间距不会一边有一边没有
+ assert.match(css, /\.library-filter-list\s*\{[^}]*gap:\s*1px/);
+ assert.match(html, /id="libraryShelfList" class="library-filter-list"/);
+ assert.match(html, /id="libraryTagList" class="library-filter-list"/);
+ assert.doesNotMatch(css, /\.library-tag-list\s*\{/);
+});
+
+test('书库多选提供全选、批量整理与批量移除', () => {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
+ const library = fs.readFileSync(libFile, 'utf8');
+ assert.match(html, /id="librarySelectModeBtn"[^>]*aria-pressed="false"/);
+ assert.match(html, /id="librarySelectAll"/);
+ assert.match(html, /id="libraryBulkOrganizeBtn"[^>]*disabled/);
+ assert.match(html, /id="libraryBulkRemoveBtn"[^>]*disabled/);
+
+ // 全选只覆盖当前筛选结果,且被筛掉的条目要从选中集合里剔除,
+ // 否则会对用户看不见的书执行批量操作
+ assert.match(library, /visibleIds = items\.map\(\(item\) => String\(item\.id\)\)/);
+ assert.match(library, /if \(!visible\.has\(id\)\) selectedIds\.delete\(id\)/);
+ assert.match(library, /visibleIds\.forEach\(\(id\) => selectedIds\.add\(id\)\)/);
+
+ // 多选时封面不能再触发阅读,否则勾选途中会误开阅读器
+ assert.match(library, /const coverActs = readable && !selectMode/);
+ assert.match(library, /\$\{coverActs \? 'data-act="read"/);
+
+ // 批量标签是增量语义:indeterminate 表示部分持有,跳过即保持原样
+ assert.match(library, /if \(box\.indeterminate\) return;/);
+ assert.match(library, /else if \(box\.dataset\.state !== 'none'\) strip\.push/);
+ assert.match(library, /box\.indeterminate = next === 'some'/);
+ assert.doesNotMatch(library, /#libraryBulkTags input\[type="checkbox"\]:checked/);
+
+ // 书架不一致时默认保持不变,不能把所选书籍统一挪走
+ assert.match(library, /if \(shelfValue !== '__keep__'\) patch\.shelfId/);
+ assert.match(library, /value="__keep__" selected>保持不变/);
+
+ // 批量移除沿用单本移除的两个可选项
+ assert.match(library, /id="bulkDelFiles"/);
+ assert.match(library, /id="bulkDelReadingData"/);
+ assert.match(library, /window\.api\.library\.remove\(item\.id, choice\)/);
+});
+
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
diff --git a/src/ui/index.html b/src/ui/index.html
index a977002..19ed4bd 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -49,15 +49,17 @@
书架
-
-
-
+
@@ -69,9 +71,21 @@
+
+
+
+
未选择
+
+
+
+
+
diff --git a/src/ui/style.css b/src/ui/style.css
index ddd7985..f4f4800 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -398,6 +398,51 @@ body {
color: var(--accent-bright);
}
+/* 书库多选 */
+.library-selection-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: -6px 0 16px;
+ padding: 8px 12px;
+ background: var(--bg-soft);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+}
+.library-select-all {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--text-dim);
+ font-size: 13px;
+ cursor: pointer;
+}
+.tb-btn.ghost.active { color: var(--accent-bright); border-color: var(--accent); }
+#libraryTab.select-mode .card { position: relative; }
+.card-select {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ z-index: 2;
+ display: flex;
+ padding: 4px;
+ background: rgba(20,20,20,0.62);
+ border-radius: 6px;
+ cursor: pointer;
+}
+.card-select input { margin: 0; cursor: pointer; }
+#libraryTab.select-mode .card-cover { transition: none; }
+#libraryTab.select-mode .card:hover .card-cover { transform: none; }
+#libraryTab.select-mode .card.selected .card-cover { border-color: var(--accent); }
+#libraryTab.select-mode .card.selected .card-title { color: var(--accent-bright); }
+.library-bulk-preview {
+ margin: 8px 0 0;
+ padding-left: 20px;
+ color: var(--text-dim);
+ font-size: 13px;
+ line-height: 1.7;
+}
+
/* 书库组织 */
.library-page {
min-height: calc(100vh - 84px);
@@ -445,12 +490,7 @@ body {
}
.library-filter:hover { color: var(--text); background: var(--hover-bg); }
.library-filter.active { color: var(--accent-bright); background: rgba(110,168,254,0.11); }
-.library-shelf-row { display: flex; align-items: center; gap: 3px; }
-.library-shelf-row .library-filter { flex: 1; }
-.library-shelf-actions { display: flex; flex: none; opacity: 0; pointer-events: none; }
-.library-shelf-row:hover .library-shelf-actions,
-.library-shelf-row:focus-within .library-shelf-actions { opacity: 1; pointer-events: auto; }
-.library-shelf-actions .notes-icon-btn { width: 22px; height: 22px; font-size: 12px; }
+.library-filter-list { display: flex; flex-direction: column; gap: 1px; }
.library-sidebar-section {
margin-top: 12px;
padding-top: 12px;
@@ -468,12 +508,25 @@ body {
font-size: 11px;
font-weight: 600;
}
-.library-tag-list { display: flex; flex-direction: column; gap: 1px; }
-.library-tag-row { display: flex; align-items: center; gap: 3px; }
-.library-tag-row .library-filter { flex: 1; }
-.library-tag-actions { display: flex; flex: none; opacity: 0; pointer-events: none; }
+/* 行内操作按钮绝对定位:留在文档流里会占掉 47px,
+ 使书架和标签的选中高亮比「全部书籍」等静态项窄一截 */
+.library-shelf-row,
+.library-tag-row { position: relative; display: flex; align-items: center; }
+.library-shelf-row .library-filter,
+.library-tag-row .library-filter { flex: 1; padding-right: 48px; }
+.library-shelf-actions,
+.library-tag-actions {
+ position: absolute;
+ right: 3px;
+ display: flex;
+ opacity: 0;
+ pointer-events: none;
+}
+.library-shelf-row:hover .library-shelf-actions,
+.library-shelf-row:focus-within .library-shelf-actions,
.library-tag-row:hover .library-tag-actions,
.library-tag-row:focus-within .library-tag-actions { opacity: 1; pointer-events: auto; }
+.library-shelf-actions .notes-icon-btn,
.library-tag-actions .notes-icon-btn { width: 22px; height: 22px; font-size: 12px; }
.library-filter-count { float: right; color: var(--text-dim); }
.library-content { min-width: 0; }
diff --git a/src/ui/views/library.js b/src/ui/views/library.js
index a310536..e49fe9d 100644
--- a/src/ui/views/library.js
+++ b/src/ui/views/library.js
@@ -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) => `${escapeHtml(tag)}`)
.join('');
+ const selectBox = selectMode
+ ? ``
+ : '';
+ // 多选时封面不再触发阅读,否则勾选途中容易误开阅读器
+ const coverActs = readable && !selectMode;
return `
+ ${selectBox}
${it.cover ? '' : `
${escapeHtml(it.title)}
`}
+ ${coverActs ? 'data-act="read" role="button" tabindex="0" title="使用内置阅读器打开"' : ''}>${it.cover ? '' : `
${escapeHtml(it.title)}
`}
${escapeHtml(it.title)}
${(it.authors && it.authors.length) ? `${escapeHtml(it.authors.slice(0, 2).join(', '))}
` : ''}
${badge}
${noteBadge}
${tagBadges ? `${tagBadges}
` : ''}
-
+ ${selectMode ? '' : `
${readable ? cardAction('read', '阅读', true) : ''}
${cardAction('open', readable ? '外部打开' : '打开', !readable, !openable)}
${openable ? cardAction('reveal', '在文件夹中显示') : ''}
${it.url ? cardAction('page', '打开来源页面') : ''}
${cardAction('organize', '整理书架和标签')}
${cardAction('remove', '移除书籍')}
-
+
`}
`;
}
@@ -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 = `${allItems.length
? (searchQuery ? '没有匹配标题或作者的书籍' : '当前分类中没有书籍')
: '书库为空,去「检索」页添加文献 / 图书吧'}
`;
+ 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) => (
+ ``
+ )).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 ``;
+ }).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} 本`, `
+
+ `, 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) => `${escapeHtml(item.title)}`).join('');
+
+ const choice = await openModal('批量移除', `
+ 确定移除以下 ${targets.length} 本书吗?
+
+ ${targets.length > 5 ? `另有 ${targets.length - 5} 本未列出
` : ''}
+ ${withFiles ? `` : ''}
+
+ 默认保留阅读资料,移除后仍可在「我的笔记」中查看。
+ `, () => ({
+ 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) => (
``