From 522b0f74a5788fa10f17332d9dfbe7dd7a910233 Mon Sep 17 00:00:00 2001 From: lofyer Date: Mon, 3 Aug 2026 17:18:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B9=A6=E5=BA=93=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E9=80=89=E6=89=B9=E9=87=8F=E6=95=B4=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=EF=BC=8C=E4=BF=AE=E6=AD=A3=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E4=BA=AE=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具栏新增「选择」模式,卡片显示复选框,工具条提供全选、批量整理与 批量移除。多选时隐藏单卡操作并停用封面阅读入口,避免勾选途中误开 阅读器。全选只覆盖当前筛选结果,切换书架、标签或搜索后会剔除已不 可见的选中项,不对看不见的书执行批量操作。 批量标签用三态复选框:indeterminate 表示部分书持有,跳过即保持原样, 只有明确勾上或取消才统一应用。沿用单本的覆盖语义会静默抹掉其它书 已有的标签。所选书籍分属不同书架时,下拉默认「保持不变」。 侧栏行内的重命名与删除按钮改为绝对定位。此前它们虽然 opacity 为 0, 仍占据 flex 空间,使书架与标签的选中亮条比「全部书籍」窄 47px。书架 列表原为普通 block 无行间距,现与标签列表共用 .library-filter-list, 两处间距一致。 --- .../electron/library-notes.integration.js | 137 +++++++++++ src/_test/ui.test.js | 52 +++++ src/ui/index.html | 22 +- src/ui/style.css | 73 +++++- src/ui/views/library.js | 221 +++++++++++++++++- 5 files changed, 488 insertions(+), 17 deletions(-) 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) => ( ``