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(
'我的笔记页渲染完成',