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
@@ -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(
'我的笔记页渲染完成',
+52
View File
@@ -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');
+18 -4
View File
@@ -49,15 +49,17 @@
<h2>书架</h2>
<button id="addShelfBtn" class="notes-icon-btn" title="新建书架" aria-label="新建书架">+</button>
</div>
<button class="library-filter active" data-shelf="">全部书籍</button>
<button class="library-filter" data-shelf="__uncategorized__">未分类</button>
<div id="libraryShelfList"></div>
<div class="library-filter-list">
<button class="library-filter active" data-shelf="">全部书籍</button>
<button class="library-filter" data-shelf="__uncategorized__">未分类</button>
<div id="libraryShelfList" class="library-filter-list"></div>
</div>
<div class="library-sidebar-section">
<div class="library-sidebar-section-head">
<h3>标签</h3>
<button id="addTagBtn" class="notes-icon-btn" title="新建标签" aria-label="新建标签">+</button>
</div>
<div id="libraryTagList" class="library-tag-list"></div>
<div id="libraryTagList" class="library-filter-list"></div>
</div>
</aside>
<div class="library-content">
@@ -69,9 +71,21 @@
</div>
<span id="libStatus" class="status-bar"></span>
<div class="spacer"></div>
<button id="librarySelectModeBtn" class="tb-btn ghost" aria-pressed="false">选择</button>
<button id="rescanBtn" class="tb-btn ghost">重新扫描</button>
<button id="addLocalBtn" class="tb-btn">+ 添加本地</button>
</div>
<div id="librarySelectionBar" class="library-selection-bar hidden">
<label class="library-select-all">
<input type="checkbox" id="librarySelectAll" />
<span id="librarySelectAllLabel">全选</span>
</label>
<span id="librarySelectionCount" class="status-bar">未选择</span>
<div class="spacer"></div>
<button id="libraryBulkOrganizeBtn" class="tb-btn ghost" disabled>批量整理</button>
<button id="libraryBulkRemoveBtn" class="tb-btn danger" disabled>批量移除</button>
<button id="librarySelectExitBtn" class="tb-btn ghost">退出选择</button>
</div>
<div id="libGrid" class="grid"></div>
</div>
</div>
+63 -10
View File
@@ -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; }
+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>`