feat: 书库支持多选批量整理与移除,修正侧栏选中亮条
工具栏新增「选择」模式,卡片显示复选框,工具条提供全选、批量整理与 批量移除。多选时隐藏单卡操作并停用封面阅读入口,避免勾选途中误开 阅读器。全选只覆盖当前筛选结果,切换书架、标签或搜索后会剔除已不 可见的选中项,不对看不见的书执行批量操作。 批量标签用三态复选框:indeterminate 表示部分书持有,跳过即保持原样, 只有明确勾上或取消才统一应用。沿用单本的覆盖语义会静默抹掉其它书 已有的标签。所选书籍分属不同书架时,下拉默认「保持不变」。 侧栏行内的重命名与删除按钮改为绝对定位。此前它们虽然 opacity 为 0, 仍占据 flex 空间,使书架与标签的选中亮条比「全部书籍」窄 47px。书架 列表原为普通 block 无行间距,现与标签列表共用 .library-filter-list, 两处间距一致。
This commit is contained in:
@@ -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(
|
||||
'我的笔记页渲染完成',
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user