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);
|
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 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>
|
||||||
<div class="window-pane items">
|
<div class="import-actions">
|
||||||
<small>Collection Items</small>
|
<button type="button" id="generate-cards-btn" class="primary" disabled>生成选中卡片 (0)</button>
|
||||||
<div id="window-collection-items" class="window-scroll collection-items-list"></div>
|
|
||||||
</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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user