Parameter Notebook: Implement Dashboard Layout and Hero Gallery

This commit is contained in:
DemonGatanjieu
2026-08-06 20:33:56 +08:00
parent 469e510c5b
commit 23f4dbb05b
3 changed files with 290 additions and 46 deletions
+15
View File
@@ -1,5 +1,20 @@
# AI Changelog
## [Snapshot] 2026-08-06 - Parameter Notebook UI Redesign & Hero Gallery
**Implemented**
- **Parameter Notebook Layout**: Completely redesigned the parameter notebook to use a modern Dashboard layout. The parameter summary now uses a spacious CSS Grid format (`.anomalous-recipe-detail-summary-grid`).
- **Node Cards & Smart Expansion**: Each node's parameters are wrapped in a styled card (`.anomalous-recipe-detail-parameter-node`). The inner widgets use a responsive grid layout to eliminate vertical cramming. Overly long nodes automatically collapse to a maximum height (`is-collapsed`) with a smooth gradient mask and a bottom expand button.
- **Hero Image Gallery (Plan B)**: Replaced the bottom inline gallery section with a prominent Hero Section (`.anomalous-recipe-detail-hero`) at the top. The first generated image acts as a large cover banner.
- **Modal Gallery Viewer**: If a recipe has multiple generated images, a sleek glassmorphism button overlay appears on the hero image (e.g., `🖼️ 图库 (5)`). Clicking it opens a native HTML `<dialog>` containing the full gallery grid, preventing the parameter view from bloating vertically while keeping the core browsing experience focused.
**Validation**
- Node widget grid correctly scales based on available width.
- Smart expansion accurately toggles `max-height` via CSS classes without recalculating DOM dimensions.
- Native `<dialog>` modal manages z-index and backdrop natively, avoiding clashes with the main UI shell.
## [Snapshot] 2026-08-06 - Parameter Notebook UI Tweaks
**Implemented**
+109 -46
View File
@@ -1419,7 +1419,12 @@ function renderParameterNotebookEditor(wrapper, owner, recipe, parameterState, s
: workflowNode.widgets_values.map((value, index) => ({ name: `${t('recipeDetailWidget')} ${index + 1}`, index, value }));
const block = document.createElement('article');
block.className = 'anomalous-recipe-detail-parameter-node';
appendText(block, 'strong', [node.title, node.type].filter(Boolean).join(' · ') || t('recipeDetailUnknownNode'));
const titleText = [node.title, node.type].filter(Boolean).join(' · ') || t('recipeDetailUnknownNode');
appendText(block, 'strong', titleText, 'anomalous-recipe-detail-node-title');
const widgetsContainer = document.createElement('div');
widgetsContainer.className = 'anomalous-recipe-detail-node-widgets';
for (let visibleIndex = 0; visibleIndex < widgets.length && rendered < 1200; visibleIndex += 1) {
const widget = widgets[visibleIndex] || {};
const index = Number.isInteger(widget.index) ? widget.index : visibleIndex;
@@ -1427,7 +1432,7 @@ function renderParameterNotebookEditor(wrapper, owner, recipe, parameterState, s
const value = workflowNode.widgets_values[index];
const row = document.createElement('label');
row.className = 'anomalous-recipe-detail-parameter-row anomalous-recipe-parameter-editor-row';
appendText(row, 'span', widget.name || `${t('recipeDetailWidget')} ${index + 1}`, 'anomalous-recipe-detail-label');
appendText(row, 'span', widget.name || `${t('recipeDetailWidget')} ${index + 1}`, 'anomalous-recipe-detail-parameter-name');
const volatile = isVolatileParameter(node, widget, index);
const sensitive = /(?:api.?key|access.?token|auth|password|passwd|secret|credential)/i.test(String(widget.name || ''));
if (volatile || sensitive) {
@@ -1450,14 +1455,32 @@ function renderParameterNotebookEditor(wrapper, owner, recipe, parameterState, s
input.classList.remove('is-invalid');
} catch (error) {
input.classList.add('is-invalid');
console.warn('Parameter input invalid:', error);
}
};
row.appendChild(input);
}
block.appendChild(row);
widgetsContainer.appendChild(row);
rendered += 1;
}
if (block.querySelector('.anomalous-recipe-parameter-editor-row')) nodeList.appendChild(block);
if (widgetsContainer.childElementCount) {
block.appendChild(widgetsContainer);
if (widgetsContainer.childElementCount > 4) {
block.classList.add('is-collapsed');
const expandOverlay = document.createElement('div');
expandOverlay.className = 'anomalous-recipe-detail-node-expand';
const expandBtn = document.createElement('button');
expandBtn.className = 'anomalous-btn-ghost';
expandBtn.innerHTML = '▼';
expandBtn.onclick = () => {
block.classList.remove('is-collapsed');
expandOverlay.remove();
};
expandOverlay.appendChild(expandBtn);
block.appendChild(expandOverlay);
}
nodeList.appendChild(block);
}
}
if (!nodeList.childElementCount) appendText(nodeList, 'p', t('recipeDetailNoSavedParameters'), 'anomalous-recipe-detail-muted');
editor.appendChild(nodeList);
@@ -1703,7 +1726,9 @@ function renderRecipeParameters(content, owner, recipe, gallery, refreshGallery,
const block = document.createElement('article');
block.className = 'anomalous-recipe-detail-parameter-node';
const title = [node.title, node.type].filter(Boolean).join(' · ') || t('recipeDetailUnknownNode');
appendText(block, 'strong', title);
appendText(block, 'strong', title, 'anomalous-recipe-detail-node-title');
const widgetsContainer = document.createElement('div');
widgetsContainer.className = 'anomalous-recipe-detail-node-widgets';
for (let visibleIndex = 0; visibleIndex < widgets.length && renderedWidgets < 1200; visibleIndex += 1) {
const widget = widgets[visibleIndex] || {};
const index = Number.isInteger(widget.index) ? widget.index : visibleIndex;
@@ -1712,59 +1737,97 @@ function renderRecipeParameters(content, owner, recipe, gallery, refreshGallery,
: widget.value;
const label = widget.name || `${t('recipeDetailWidget')} ${index + 1}`;
const volatile = isVolatileParameter(node, widget, index);
if (renderParameterField(block, label, volatile ? 0 : value, {
if (renderParameterField(widgetsContainer, label, volatile ? 0 : value, {
redact: volatile,
copy: false,
collapse: false,
})) renderedWidgets += 1;
}
if (block.querySelector('.anomalous-recipe-detail-parameter-row')) nodeList.appendChild(block);
if (widgetsContainer.childElementCount) {
block.appendChild(widgetsContainer);
if (widgetsContainer.childElementCount > 4) {
block.classList.add('is-collapsed');
const expandOverlay = document.createElement('div');
expandOverlay.className = 'anomalous-recipe-detail-node-expand';
const expandBtn = document.createElement('button');
expandBtn.className = 'anomalous-btn-ghost';
expandBtn.innerHTML = '▼';
expandBtn.onclick = () => {
block.classList.remove('is-collapsed');
expandOverlay.remove();
};
expandOverlay.appendChild(expandBtn);
block.appendChild(expandOverlay);
}
nodeList.appendChild(block);
}
}
if (nodeList.childElementCount) nodesSection.appendChild(nodeList);
else appendText(nodesSection, 'p', t('recipeDetailNoSavedParameters'), 'anomalous-recipe-detail-muted');
wrapper.append(intro, summary, nodesSection);
const gallerySection = document.createElement('section');
gallerySection.className = 'anomalous-recipe-detail-section anomalous-recipe-gallery';
const heading = document.createElement('div');
heading.className = 'anomalous-recipe-detail-section-heading';
appendText(heading, 'h5', t('recipeParameterGallery'));
const refreshButton = button(heading, t('recipeGalleryRefresh'), 'anomalous-btn-ghost anomalous-recipe-gallery-refresh');
refreshButton.onclick = () => { void refreshGallery(true); };
gallerySection.appendChild(heading);
if (gallery.status === 'loading') {
appendText(gallerySection, 'p', t('recipeParameterGalleryLoading'), 'anomalous-recipe-detail-muted');
} else if (gallery.status === 'error') {
appendText(gallerySection, 'p', t('recipeParameterGalleryError'), 'anomalous-recipe-detail-muted');
} else if (gallery.status === 'ready' && gallery.images.length) {
appendText(gallerySection, 'small', t('recipeGalleryScanHint').replace('{count}', String(gallery.scanned || 0)), 'anomalous-recipe-detail-muted');
const grid = document.createElement('div');
grid.className = 'anomalous-recipe-gallery-grid';
for (const sourceImage of gallery.images) {
const card = document.createElement('article');
card.className = 'anomalous-recipe-gallery-card';
const url = outputImageUrl(sourceImage);
const image = document.createElement('img');
image.src = url;
image.alt = t('recipeGalleryOpenImage');
image.loading = 'lazy';
image.onclick = () => { void showGalleryComparison(card, owner, sourceImage); };
card.appendChild(image);
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-gallery-card-actions';
const viewImage = button(actions, t('recipeGalleryOpenImage'), 'anomalous-btn-ghost');
viewImage.onclick = () => owner.showGalleryViewer?.(url);
const compare = button(actions, t('recipeGalleryCompare'), 'anomalous-btn-ghost');
compare.onclick = () => { void showGalleryComparison(card, owner, sourceImage); };
card.appendChild(actions);
grid.appendChild(card);
// Replace old bottom gallery with Hero Section at the top
if (gallery.status === 'ready' && gallery.images.length) {
const heroSection = document.createElement('section');
heroSection.className = 'anomalous-recipe-detail-hero';
const heroImage = document.createElement('img');
heroImage.src = outputImageUrl(gallery.images[0]);
heroImage.className = 'anomalous-recipe-detail-hero-image';
heroImage.onclick = () => owner.showGalleryViewer?.(heroImage.src);
heroSection.appendChild(heroImage);
if (gallery.images.length > 1) {
const galleryButton = document.createElement('button');
galleryButton.className = 'anomalous-recipe-detail-hero-gallery-btn';
galleryButton.innerHTML = `🖼️ ${t('recipeParameterGallery')} (${gallery.images.length})`;
galleryButton.onclick = () => {
const dialog = document.createElement('dialog');
dialog.className = 'anomalous-recipe-gallery-dialog';
const closeBtn = document.createElement('button');
closeBtn.className = 'anomalous-dialog-close anomalous-btn-ghost';
closeBtn.innerHTML = '✕';
closeBtn.onclick = () => dialog.close();
dialog.appendChild(closeBtn);
const heading = document.createElement('h3');
heading.textContent = t('recipeParameterGallery');
heading.className = 'anomalous-recipe-gallery-dialog-title';
dialog.appendChild(heading);
const grid = document.createElement('div');
grid.className = 'anomalous-recipe-gallery-grid';
for (const sourceImage of gallery.images) {
const card = document.createElement('article');
card.className = 'anomalous-recipe-gallery-card';
const url = outputImageUrl(sourceImage);
const image = document.createElement('img');
image.src = url;
image.loading = 'lazy';
image.onclick = () => { void showGalleryComparison(card, owner, sourceImage); };
card.appendChild(image);
const actions = document.createElement('div');
actions.className = 'anomalous-recipe-gallery-card-actions';
const viewBtn = button(actions, t('recipeGalleryOpenImage'), 'anomalous-btn-ghost');
viewBtn.onclick = () => owner.showGalleryViewer?.(url);
const compareBtn = button(actions, t('recipeGalleryCompare'), 'anomalous-btn-ghost');
compareBtn.onclick = () => { void showGalleryComparison(card, owner, sourceImage); };
card.appendChild(actions);
grid.appendChild(card);
}
dialog.appendChild(grid);
document.body.appendChild(dialog);
dialog.addEventListener('close', () => dialog.remove());
dialog.showModal();
};
heroSection.appendChild(galleryButton);
}
gallerySection.appendChild(grid);
} else if (gallery.status === 'ready') {
appendText(gallerySection, 'p', t('recipeParameterGalleryEmpty'), 'anomalous-recipe-detail-muted');
wrapper.insertBefore(heroSection, intro);
}
wrapper.appendChild(gallerySection);
layout.append(sidebar, wrapper);
content.appendChild(layout);
}
+166
View File
@@ -2961,3 +2961,169 @@
.anomalous-recipe-parameter-notebook-item.is-switching {
animation: anomalous-parameter-switch 0.25s cubic-bezier(0.1, 0.9, 0.2, 1) forwards;
}
/* Parameter Summary Grid Styling */
.anomalous-recipe-detail-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
margin-bottom: 24px;
}
.anomalous-recipe-detail-summary-grid .anomalous-recipe-detail-parameter-row.is-wide {
grid-column: 1 / -1;
}
/* Node Parameter Card Styling */
.anomalous-recipe-detail-parameter-node {
position: relative;
padding: 16px;
margin-bottom: 16px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
transition: max-height 0.3s ease;
}
.anomalous-recipe-detail-node-title {
display: block;
margin-bottom: 12px;
font-size: 1.05rem;
color: #e0e0e0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 8px;
}
/* Grid Layout for Node Widgets */
.anomalous-recipe-detail-node-widgets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
/* Smart Expansion (Collapsible Nodes) */
.anomalous-recipe-detail-parameter-node.is-collapsed {
max-height: 180px;
overflow: hidden;
}
.anomalous-recipe-detail-node-expand {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 70px;
background: linear-gradient(to bottom, transparent 0%, #202020 80%, #202020 100%);
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 8px;
z-index: 10;
border-radius: 0 0 8px 8px;
}
.anomalous-recipe-detail-node-expand button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 4px 16px;
font-size: 0.8rem;
color: #ccc;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.anomalous-recipe-detail-node-expand button:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* Hero Section Styling */
.anomalous-recipe-detail-hero {
position: relative;
width: 100%;
max-height: 350px;
margin-bottom: 24px;
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
}
.anomalous-recipe-detail-hero-image {
width: 100%;
height: 100%;
max-height: 350px;
object-fit: cover;
display: block;
cursor: pointer;
transition: transform 0.3s ease;
}
.anomalous-recipe-detail-hero-image:hover {
transform: scale(1.02);
}
.anomalous-recipe-detail-hero-gallery-btn {
position: absolute;
bottom: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
cursor: pointer;
backdrop-filter: blur(8px);
transition: background 0.2s, transform 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.anomalous-recipe-detail-hero-gallery-btn:hover {
background: rgba(0, 0, 0, 0.8);
transform: translateY(-2px);
}
/* Modal Gallery Dialog Styling */
.anomalous-recipe-gallery-dialog {
background: #1e1e1e;
color: #eee;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
width: 90vw;
max-width: 1200px;
max-height: 90vh;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
overflow-y: auto;
}
.anomalous-recipe-gallery-dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.anomalous-recipe-gallery-dialog-title {
margin-top: 0;
margin-bottom: 16px;
font-size: 1.4rem;
font-weight: 600;
}
.anomalous-dialog-close {
position: absolute;
top: 16px;
right: 16px;
font-size: 1.2rem;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
}