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');
|
||||
|
||||
+18
-4
@@ -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
@@ -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
@@ -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>`
|
||||
|
||||
Reference in New Issue
Block a user