feat: add two-column import window layout with batch generate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Saberlve 2026-04-15 20:28:16 +08:00
parent 4350f9b3a1
commit aae8a6340a
2 changed files with 215 additions and 32 deletions

View File

@ -236,6 +236,26 @@
grid-template-columns: 22rem minmax(0, 1fr); grid-template-columns: 22rem minmax(0, 1fr);
min-height: 0; min-height: 0;
} }
.import-split { display: grid; grid-template-columns: 40% 60%; height: 100%; gap: 1px; background: var(--line); }
.import-left, .import-right { background: var(--surface); overflow-y: auto; padding: 1rem; }
.import-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; font-weight: 600; }
.import-list { display: flex; flex-direction: column; gap: 0.25rem; }
.import-item { display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; border-radius: 6px; cursor: pointer; }
.import-item.pending { background: var(--panel); }
.import-item.done { background: transparent; opacity: 0.7; }
.import-item.selected { outline: 2px solid var(--accent, #4a90d9); }
.import-item input[type="checkbox"] { margin-top: 0.25rem; }
.import-item.done input[type="checkbox"] { display: none; }
.import-item .item-title { font-size: 0.875rem; font-weight: 500; }
.import-item .item-meta { font-size: 0.75rem; color: var(--ink-dim, #666); }
.import-list-footer { margin-top: 0.75rem; font-weight: 600; font-size: 0.875rem; }
.import-actions { padding: 0.75rem 1rem; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; }
#generate-cards-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.link-btn { background: none; border: none; color: var(--accent, #4a90d9); cursor: pointer; font-size: 0.8rem; }
#item-preview .preview-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
#item-preview .preview-meta { font-size: 0.8rem; color: var(--ink-dim); margin-bottom: 0.75rem; }
#item-preview .preview-abstract { font-size: 0.85rem; line-height: 1.5; }
.hint { color: var(--ink-dim); font-size: 0.875rem; }
.window-pane { .window-pane {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
@ -359,14 +379,27 @@
</div> </div>
</div> </div>
<div class="window-body"> <div class="window-body">
<div class="window-pane collections"> <div class="import-split">
<small>Collections</small> <div class="import-left">
<div id="window-collection-status" class="status"></div> <div class="import-list-header">
<div id="window-collection-tree" class="window-scroll tree-list"></div> <span>Pending (<span id="pending-count">0</span>)</span>
<button type="button" id="select-all-pending" class="link-btn">Select All</button>
</div> </div>
<div class="window-pane items"> <div class="import-list" id="import-items-list">
<small>Collection Items</small> <!-- populated by JS -->
<div id="window-collection-items" class="window-scroll collection-items-list"></div> </div>
<div class="import-list-footer">
Done (<span id="done-count">0</span>)
</div>
</div>
<div class="import-right">
<div id="item-preview">
<p class="hint">Click an item to preview</p>
</div>
</div>
</div>
<div class="import-actions">
<button type="button" id="generate-cards-btn" class="primary" disabled>生成选中卡片 (0)</button>
</div> </div>
</div> </div>
<div id="item-preview-popover" class="preview-popover" hidden></div> <div id="item-preview-popover" class="preview-popover" hidden></div>
@ -495,6 +528,10 @@
hoveredItemKey: null, hoveredItemKey: null,
hoverTimerId: null, hoverTimerId: null,
previewItemKey: null, previewItemKey: null,
importItems: [],
selectedPendingKeys: new Set(),
isGenerating: false,
generatingProgress: { current: 0, total: 0 },
}; };
const elements = { const elements = {
@ -690,6 +727,13 @@
target.classList.toggle("error", Boolean(isError)); target.classList.toggle("error", Boolean(isError));
} }
function escapeHtml(str) {
if (str === null || str === undefined) return "";
const div = document.createElement("div");
div.textContent = String(str);
return div.innerHTML;
}
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, { const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) }, headers: { "Content-Type": "application/json", ...(options.headers || {}) },
@ -1095,6 +1139,98 @@
closeImportWindow(); closeImportWindow();
} }
async function loadImportState() {
const resp = await fetch(`/api/projects/${state.currentProjectId}/import-state`);
const data = await resp.json();
state.importItems = data.items;
renderImportItemList();
updateImportCounts(data.pending_count, data.done_count);
}
function renderImportItemList() {
const list = document.getElementById('import-items-list');
list.innerHTML = '';
state.importItems.forEach(item => {
const isDone = item.card_status === 'done';
const div = document.createElement('div');
div.className = `import-item ${item.card_status}`;
div.dataset.key = item.item_key;
div.innerHTML = `
<input type="checkbox" ${isDone ? 'disabled' : ''} data-key="${item.item_key}">
<div>
<div class="item-title">${escapeHtml(item.title)}</div>
<div class="item-meta">${item.creators.join(', ')} · ${item.year || 'n.d.'}</div>
</div>
`;
if (!isDone) {
div.querySelector('input').addEventListener('change', (e) => {
if (e.target.checked) state.selectedPendingKeys.add(item.item_key);
else state.selectedPendingKeys.delete(item.item_key);
updateGenerateButton();
div.classList.toggle('selected', e.target.checked);
});
}
div.addEventListener('click', (e) => { if (e.target.tagName !== 'INPUT') showItemPreview(item); });
list.appendChild(div);
});
}
function showItemPreview(item) {
document.getElementById('item-preview').innerHTML = `
<div class="preview-title">${escapeHtml(item.title)}</div>
<div class="preview-meta">${item.creators.join(', ')} · ${item.year || 'n.d.'} · ${item.item_type}</div>
<div class="preview-abstract">${escapeHtml(item.abstract || 'No abstract available.')}</div>
`;
}
function updateGenerateButton() {
const btn = document.getElementById('generate-cards-btn');
const count = state.selectedPendingKeys.size;
btn.disabled = count === 0 || state.isGenerating;
btn.textContent = state.isGenerating
? `生成中 ${state.generatingProgress.current}/${state.generatingProgress.total}`
: `生成选中卡片 (${count})`;
}
async function generateSelectedCards() {
const keys = Array.from(state.selectedPendingKeys);
if (!keys.length) return;
state.isGenerating = true;
state.generatingProgress = { current: 0, total: keys.length };
updateGenerateButton();
try {
const resp = await fetch(`/api/projects/${state.currentProjectId}/cards/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_keys: keys }),
});
const data = await resp.json();
data.items.forEach(updated => {
const item = state.importItems.find(i => i.item_key === updated.item_key);
if (item) item.card_status = updated.card_status;
});
state.selectedPendingKeys.clear();
renderImportItemList();
const counts = {
pending: state.importItems.filter(i => i.card_status === 'pending').length,
done: state.importItems.filter(i => i.card_status === 'done').length,
};
updateImportCounts(counts.pending, counts.done);
} finally {
state.isGenerating = false;
updateGenerateButton();
}
}
function updateImportCounts(pending, done) {
document.getElementById('pending-count').textContent = pending;
document.getElementById('done-count').textContent = done;
}
async function openImportWindow() { async function openImportWindow() {
state.isImportWindowOpen = true; state.isImportWindowOpen = true;
elements.importWindowShell.hidden = false; elements.importWindowShell.hidden = false;
@ -1103,25 +1239,7 @@
cancelAbstractPreview(); cancelAbstractPreview();
updateImportActionState(); updateImportActionState();
initWindowFromSession(); initWindowFromSession();
if (!state.collectionTree.length) { await loadImportState();
try {
await loadCollectionTree();
} catch (error) {
setStatus(elements.windowCollectionStatus, error.message, true);
}
} else if (
state.selectedCollectionKey &&
(!state.collectionItemsLoaded && state.loadingCollectionKey !== state.selectedCollectionKey)
) {
try {
await loadCollectionItems(state.selectedCollectionKey);
} catch (error) {
setStatus(elements.windowCollectionStatus, error.message, true);
}
} else {
renderCollectionTree();
renderCollectionItems();
}
} }
function closeImportWindow() { function closeImportWindow() {
@ -1130,6 +1248,8 @@
elements.importWindowShell.setAttribute("aria-hidden", "true"); elements.importWindowShell.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false); document.body.classList.toggle("modal-open", false);
cancelAbstractPreview(); cancelAbstractPreview();
state.importItems = [];
state.selectedPendingKeys.clear();
} }
async function loadProjects() { async function loadProjects() {
@ -1215,6 +1335,15 @@
setStatus(elements.windowCollectionStatus, error.message, true); setStatus(elements.windowCollectionStatus, error.message, true);
}); });
}); });
document.getElementById('select-all-pending').addEventListener('click', () => {
document.querySelectorAll('.import-item.pending input[type="checkbox"]').forEach(cb => {
cb.checked = true;
state.selectedPendingKeys.add(cb.dataset.key);
cb.closest('.import-item').classList.add('selected');
});
updateGenerateButton();
});
document.getElementById('generate-cards-btn').addEventListener('click', generateSelectedCards);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportWindowOpen) { if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportWindow(); closeImportWindow();

View File

@ -37,12 +37,13 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
assert 'id="window-restore-button"' in html assert 'id="window-restore-button"' in html
assert 'id="import-window-minimized-bar"' in html assert 'id="import-window-minimized-bar"' in html
assert 'id="import-window-project-label"' in html assert 'id="import-window-project-label"' in html
assert 'id="window-collection-tree"' in html # New two-column import layout
assert 'id="window-collection-items"' in html assert 'id="import-items-list"' in html
assert 'id="window-selected-count"' in html assert 'id="item-preview"' in html
assert 'id="window-clear-selection-button"' in html assert 'id="pending-count"' in html
assert 'id="window-import-selected-items-button"' in html assert 'id="done-count"' in html
assert 'id="item-preview-popover"' in html assert 'id="select-all-pending"' in html
assert 'id="generate-cards-btn"' in html
assert 'isImportWindowOpen: false' in html assert 'isImportWindowOpen: false' in html
assert "function openImportWindow()" in html assert "function openImportWindow()" in html
assert "function closeImportWindow()" in html assert "function closeImportWindow()" in html
@ -65,3 +66,56 @@ def test_index_window_frame_css(tmp_path) -> None:
assert "restoreWindow" in html assert "restoreWindow" in html
assert "initWindowFromSession" in html assert "initWindowFromSession" in html
assert "persistWindowSessionState" in html assert "persistWindowSessionState" in html
def test_index_import_window_has_two_column_layout(tmp_path) -> None:
html = _get_index_html(tmp_path)
# Two-column split layout structure
assert 'class="import-split"' in html
assert 'class="import-left"' in html
assert 'class="import-right"' in html
# Left column: list header, list, footer
assert 'class="import-list-header"' in html
assert 'id="import-items-list"' in html
assert 'class="import-list-footer"' in html
assert 'id="pending-count"' in html
assert 'id="done-count"' in html
assert 'id="select-all-pending"' in html
# Right column: preview
assert 'id="item-preview"' in html
# Action bar
assert 'class="import-actions"' in html
assert 'id="generate-cards-btn"' in html
# CSS: import-split grid layout
assert ".import-split" in html
# CSS: import-item classes
assert ".import-item" in html
assert ".import-item.pending" in html
assert ".import-item.done" in html
def test_index_has_import_state_and_cards_generate_api_calls(tmp_path) -> None:
html = _get_index_html(tmp_path)
# loadImportState function fetches import-state
assert "function loadImportState()" in html
assert "/api/projects/" in html
assert "import-state" in html
# generateSelectedCards function calls cards/generate
assert "function generateSelectedCards()" in html
assert "/cards/generate" in html
# openImportWindow calls loadImportState
assert "openImportWindow()" in html
# Check loadImportState is called inside openImportWindow (await statement appears)
assert "await loadImportState()" in html
# generate-cards-btn has click listener for generateSelectedCards
assert 'generate-cards-btn' in html
assert 'generateSelectedCards' in html