fix: sync side gallery with active asset panel

This commit is contained in:
2026-05-20 20:16:29 +08:00
parent cacb0bd40c
commit 3f087edd60
4 changed files with 232 additions and 19 deletions

View File

@@ -961,6 +961,54 @@ input, textarea {
display: none;
}
.gallery-rail-thumb {
box-shadow: 0 12px 32px -22px rgba(230, 245, 120, 0.65);
}
.gallery-rail-thumb:hover,
.gallery-rail-thumb:focus-visible {
outline: none;
transform: translateX(-2px);
box-shadow:
0 0 0 1px rgba(230, 245, 120, 0.45),
0 18px 42px -24px rgba(230, 245, 120, 0.82);
}
.gallery-rail-empty,
.gallery-empty-state {
display: grid;
min-height: 88px;
place-items: center;
border-radius: 8px;
border: 1px dashed rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.16);
color: rgba(255, 255, 255, 0.34);
font-size: 10px;
text-align: center;
}
.gallery-center-preview {
position: fixed;
z-index: 96;
left: 50%;
top: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
border-radius: 8px;
background: #fff;
padding: 10px;
box-shadow:
0 34px 110px -28px rgba(0, 0, 0, 0.92),
0 0 0 1px rgba(255, 255, 255, 0.22);
}
.gallery-center-preview img {
display: block;
width: min(74vw, 960px);
height: 82vh;
object-fit: contain;
}
.gallery-backdrop {
position: fixed;
inset: 0;
@@ -993,6 +1041,36 @@ input, textarea {
transform: translateX(0);
}
.gallery-drawer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
align-items: start;
}
.gallery-drawer-card {
min-width: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
padding: 10px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.gallery-drawer-card__image {
display: grid;
place-items: center;
overflow: hidden;
border-radius: 8px;
background: #fff;
}
.gallery-drawer-card__image img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.result-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));

View File

@@ -109,11 +109,13 @@ function ProjectPackOverview({
primaryImage,
loadingKind,
onGeneratePack,
onOpenPack,
}: {
session: GenSession;
primaryImage: GenImage | null;
loadingKind: PackKind | null;
onGeneratePack: (image: GenImage, kind: PackKind) => void;
onOpenPack: (kind: PackKind) => void;
}) {
if (!primaryImage) return null;
const sourceImage = primaryImage;
@@ -175,7 +177,7 @@ function ProjectPackOverview({
type="button"
className="project-pack-jump"
title="查看明细"
onClick={() => document.getElementById(`pack-${kind}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
onClick={() => onOpenPack(kind)}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 6l6 6-6 6" strokeLinecap="round" />
@@ -196,12 +198,14 @@ function ProjectBrief({
characterLoading,
onGeneratePack,
onLockCharacter,
onOpenPack,
}: {
session: GenSession;
loadingKind: PackKind | null;
characterLoading: boolean;
onGeneratePack: (image: GenImage, kind: PackKind) => void;
onLockCharacter: (image: GenImage) => void;
onOpenPack: (kind: PackKind) => void;
}) {
const selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? session.images[0] ?? null;
@@ -274,6 +278,7 @@ function ProjectBrief({
primaryImage={primaryImage}
loadingKind={loadingKind}
onGeneratePack={onGeneratePack}
onOpenPack={onOpenPack}
/>
{session.characterSpec ? (
@@ -335,6 +340,7 @@ export default function Home() {
const [uploadLoading, setUploadLoading] = useState(false);
const [provider, setProvider] = useState<string>('?');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeAssetPanel, setActiveAssetPanel] = useState('pack-patent');
const refreshSessions = useCallback(async () => {
const r = await fetch('/api/sessions');
@@ -344,6 +350,7 @@ export default function Home() {
}, []);
useEffect(() => { refreshSessions(); }, [refreshSessions]);
useEffect(() => { setActiveAssetPanel('pack-patent'); }, [current?.id]);
async function handleGenerate(opts: { prompt: string; refImages: string[]; count: number; style?: string }) {
setLoading(true);
@@ -634,16 +641,23 @@ export default function Home() {
characterLoading={characterLoading}
onGeneratePack={handleGeneratePack}
onLockCharacter={handleLockCharacter}
onOpenPack={kind => setActiveAssetPanel(`pack-${kind}`)}
/>
<section className="project-production-panel">
<PackPanel
session={current}
activeNav={activeAssetPanel}
onActiveNavChange={setActiveAssetPanel}
videoLoading={videoLoading}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo}
/>
</section>
<ProjectGalleryDrawer session={current} onAction={handleAction} />
<ProjectGalleryDrawer
session={current}
activeNav={activeAssetPanel}
onAction={handleAction}
/>
</div>
</section>
)}

View File

@@ -513,16 +513,19 @@ function packPanelState(packs: AssetPack[], kind: PackKind, primaryImageId: stri
export default function PackPanel({
session,
activeNav,
onActiveNavChange,
videoLoading,
onRegenerateAsset,
onGenerateVideo,
}: {
session: GenSession;
activeNav: string;
onActiveNavChange: (id: string) => void;
videoLoading: boolean;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
}) {
const [activeNav, setActiveNav] = useState('pack-patent');
const selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? null;
const packs = session.packs ?? [];
@@ -563,7 +566,7 @@ export default function PackPanel({
</div>
<span className="shrink-0 text-[10px] text-white/35"></span>
</div>
<SectionNav active={activeNav} onChange={setActiveNav} />
<SectionNav active={activeNav} onChange={onActiveNavChange} />
</section>
<div className="pack-scroll min-h-0 flex-1 overflow-y-auto pr-1" key={activeNav}>

View File

@@ -2,24 +2,103 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { GenSession } from '@/lib/types';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates';
import type { GenSession, PackKind } from '@/lib/types';
import ResultGrid from './ResultGrid';
export type ProjectGalleryDrawerProps = {
session: GenSession;
activeNav: string;
onAction: (imageId: string, action: 'select' | 'reject' | 'reset') => void;
};
function statusClass(status: string) {
type GalleryItem = {
id: string;
url: string;
title: string;
description?: string;
status?: string;
aspectRatio?: string;
};
type GalleryPanel = {
mode: 'pack' | 'empty' | 'project';
label: string;
description: string;
total: number;
images: GalleryItem[];
};
function statusClass(status?: string) {
if (status === 'selected') return 'ring-[#e6f578]/80 shadow-[0_0_24px_-12px_rgba(230,245,120,0.9)]';
if (status === 'rejected') return 'opacity-45 grayscale ring-white/10';
return 'ring-white/12 hover:ring-white/28';
}
export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalleryDrawerProps) {
function aspectCss(aspectRatio?: string) {
if (!aspectRatio) return '1 / 1';
if (aspectRatio === 'long') return '1 / 3';
return aspectRatio.replace(':', ' / ');
}
function activeKindFromNav(activeNav: string): PackKind | null {
const kind = PACK_ORDER.find(item => activeNav === `pack-${item}`);
return kind ?? null;
}
function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel {
const kind = activeKindFromNav(activeNav);
const selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? session.images[0] ?? null;
if (kind) {
const pack = session.packs?.find(item => item.kind === kind && (!primaryImage || item.sourceImageId === primaryImage.id))
?? session.packs?.find(item => item.kind === kind);
const images: GalleryItem[] = pack?.assets.map(asset => ({
id: asset.id,
url: asset.url,
title: asset.title,
description: asset.description,
status: asset.status,
aspectRatio: asset.aspectRatio,
})) ?? [];
return {
mode: 'pack' as const,
label: PACK_LABELS[kind],
description: `${PACK_LABELS[kind]}当前区块图片`,
total: PACK_TEMPLATES[kind].length,
images,
};
}
if (activeNav === 'pack-text') {
return { mode: 'empty' as const, label: '文字', description: '文字区块暂无图片素材', total: 0, images: [] as GalleryItem[] };
}
if (activeNav === 'pack-video') {
return { mode: 'empty' as const, label: '视频', description: '视频区块暂无图片素材', total: 0, images: [] as GalleryItem[] };
}
return {
mode: 'project',
label: '项目',
description: session.characterSpec?.name || session.prompt || '当前项目',
total: session.images.length,
images: session.images.map((image, index) => ({
id: image.id,
url: image.url,
title: `主图库 ${index + 1}`,
description: undefined,
status: image.status,
aspectRatio: '1:1',
})),
};
}
export default function ProjectGalleryDrawer({ session, activeNav, onAction }: ProjectGalleryDrawerProps) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const selectedCount = session.images.filter(image => image.status === 'selected').length;
const [preview, setPreview] = useState<GalleryItem | null>(null);
const gallery = galleryForPanel(session, activeNav);
useEffect(() => {
setMounted(true);
@@ -41,9 +120,9 @@ export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalle
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-white/10 p-5">
<div className="min-w-0">
<span className="section-eyebrow">Project Gallery</span>
<h2 className="mt-2 text-lg font-semibold text-white"></h2>
<h2 className="mt-2 text-lg font-semibold text-white">{gallery.label}</h2>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-white/42">
{session.characterSpec?.name || session.prompt || '当前项目'}
{gallery.description}
</p>
</div>
<button
@@ -59,20 +138,50 @@ export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalle
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{open && <ResultGrid images={session.images} onAction={onAction} />}
{open && gallery.mode === 'project' ? (
<ResultGrid images={session.images} onAction={onAction} />
) : (
<div className="gallery-drawer-grid">
{gallery.images.map((image, index) => (
<div key={image.id} className="gallery-drawer-card">
<div className="gallery-drawer-card__image" style={{ aspectRatio: aspectCss(image.aspectRatio) }}>
<img src={image.url} alt={image.title} />
</div>
<div className="mt-3 min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[12px] font-semibold text-white">{image.title}</span>
<span className="shrink-0 font-mono text-[10px] text-[#dff5a8]">{index + 1}</span>
</div>
{image.description && (
<p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-white/42">{image.description}</p>
)}
</div>
</div>
))}
{!gallery.images.length && (
<div className="gallery-empty-state"></div>
)}
</div>
)}
</div>
</aside>
</>
);
const centerPreview = preview ? (
<div className="gallery-center-preview" aria-hidden="true">
<img src={preview.url} alt="" />
</div>
) : null;
return (
<>
<aside className="gallery-rail flex h-full min-h-0 flex-col items-center gap-3 overflow-hidden rounded-[8px] border border-white/10 bg-white/[0.045] p-2 backdrop-blur-xl">
<button
onClick={() => setOpen(true)}
className="flex h-12 w-full cursor-pointer flex-col items-center justify-center rounded-[8px] bg-[#e6f578] text-[#081006] transition-colors hover:bg-[#f0fb82]"
title="打开项目图库"
aria-label="打开项目图库"
title={`打开${gallery.label}图库`}
aria-label={`打开${gallery.label}图库`}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" />
@@ -83,18 +192,23 @@ export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalle
</button>
<div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42">
<b className="block text-[13px] text-[#e6f578]">{selectedCount}</b>
<span>/ {session.images.length}</span>
<b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
<span>/ {gallery.total}</span>
</div>
<div className="gallery-thumb-list flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto pr-0.5">
{session.images.map((image, index) => (
{gallery.images.map((image, index) => (
<button
key={image.id}
onClick={() => setOpen(true)}
className={`relative aspect-square w-full cursor-pointer overflow-hidden rounded-[8px] bg-white ring-1 transition-all ${statusClass(image.status)}`}
title={`打开第 ${index + 1}`}
aria-label={`打开第 ${index + 1}`}
onMouseEnter={() => setPreview(image)}
onMouseLeave={() => setPreview(null)}
onFocus={() => setPreview(image)}
onBlur={() => setPreview(null)}
className={`gallery-rail-thumb relative w-full cursor-pointer overflow-hidden rounded-[8px] bg-white ring-1 transition-all ${statusClass(image.status)}`}
style={{ aspectRatio: aspectCss(image.aspectRatio) }}
title={`查看${image.title}`}
aria-label={`查看${image.title}`}
>
<img src={image.url} alt="" className="h-full w-full object-contain" />
<span className="absolute left-1 top-1 rounded-[6px] bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
@@ -108,10 +222,14 @@ export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalle
)}
</button>
))}
{!gallery.images.length && (
<div className="gallery-rail-empty"></div>
)}
</div>
</aside>
{mounted ? createPortal(drawer, document.body) : null}
{mounted && centerPreview ? createPortal(centerPreview, document.body) : null}
</>
);
}