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:
parent
4350f9b3a1
commit
aae8a6340a
@ -236,6 +236,26 @@
|
||||
grid-template-columns: 22rem minmax(0, 1fr);
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
@ -359,14 +379,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-body">
|
||||
<div class="window-pane collections">
|
||||
<small>Collections</small>
|
||||
<div id="window-collection-status" class="status"></div>
|
||||
<div id="window-collection-tree" class="window-scroll tree-list"></div>
|
||||
<div class="import-split">
|
||||
<div class="import-left">
|
||||
<div class="import-list-header">
|
||||
<span>Pending (<span id="pending-count">0</span>)</span>
|
||||
<button type="button" id="select-all-pending" class="link-btn">Select All</button>
|
||||
</div>
|
||||
<div class="window-pane items">
|
||||
<small>Collection Items</small>
|
||||
<div id="window-collection-items" class="window-scroll collection-items-list"></div>
|
||||
<div class="import-list" id="import-items-list">
|
||||
<!-- populated by JS -->
|
||||
</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 id="item-preview-popover" class="preview-popover" hidden></div>
|
||||
@ -495,6 +528,10 @@
|
||||
hoveredItemKey: null,
|
||||
hoverTimerId: null,
|
||||
previewItemKey: null,
|
||||
importItems: [],
|
||||
selectedPendingKeys: new Set(),
|
||||
isGenerating: false,
|
||||
generatingProgress: { current: 0, total: 0 },
|
||||
};
|
||||
|
||||
const elements = {
|
||||
@ -690,6 +727,13 @@
|
||||
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 = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
@ -1095,6 +1139,98 @@
|
||||
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() {
|
||||
state.isImportWindowOpen = true;
|
||||
elements.importWindowShell.hidden = false;
|
||||
@ -1103,25 +1239,7 @@
|
||||
cancelAbstractPreview();
|
||||
updateImportActionState();
|
||||
initWindowFromSession();
|
||||
if (!state.collectionTree.length) {
|
||||
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();
|
||||
}
|
||||
await loadImportState();
|
||||
}
|
||||
|
||||
function closeImportWindow() {
|
||||
@ -1130,6 +1248,8 @@
|
||||
elements.importWindowShell.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.toggle("modal-open", false);
|
||||
cancelAbstractPreview();
|
||||
state.importItems = [];
|
||||
state.selectedPendingKeys.clear();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
@ -1215,6 +1335,15 @@
|
||||
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) => {
|
||||
if (event.key === "Escape" && state.isImportWindowOpen) {
|
||||
closeImportWindow();
|
||||
|
||||
@ -37,12 +37,13 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
|
||||
assert 'id="window-restore-button"' in html
|
||||
assert 'id="import-window-minimized-bar"' in html
|
||||
assert 'id="import-window-project-label"' in html
|
||||
assert 'id="window-collection-tree"' in html
|
||||
assert 'id="window-collection-items"' in html
|
||||
assert 'id="window-selected-count"' in html
|
||||
assert 'id="window-clear-selection-button"' in html
|
||||
assert 'id="window-import-selected-items-button"' in html
|
||||
assert 'id="item-preview-popover"' in html
|
||||
# New two-column import layout
|
||||
assert 'id="import-items-list"' in html
|
||||
assert 'id="item-preview"' in html
|
||||
assert 'id="pending-count"' in html
|
||||
assert 'id="done-count"' in html
|
||||
assert 'id="select-all-pending"' in html
|
||||
assert 'id="generate-cards-btn"' in html
|
||||
assert 'isImportWindowOpen: false' in html
|
||||
assert "function openImportWindow()" 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 "initWindowFromSession" 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user