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');