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; 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 { .gallery-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -993,6 +1041,36 @@ input, textarea {
transform: translateX(0); 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 { .result-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));

View File

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

View File

@@ -513,16 +513,19 @@ function packPanelState(packs: AssetPack[], kind: PackKind, primaryImageId: stri
export default function PackPanel({ export default function PackPanel({
session, session,
activeNav,
onActiveNavChange,
videoLoading, videoLoading,
onRegenerateAsset, onRegenerateAsset,
onGenerateVideo, onGenerateVideo,
}: { }: {
session: GenSession; session: GenSession;
activeNav: string;
onActiveNavChange: (id: string) => void;
videoLoading: boolean; videoLoading: boolean;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>; onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => 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 selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? null; const primaryImage = selectedImages[0] ?? null;
const packs = session.packs ?? []; const packs = session.packs ?? [];
@@ -563,7 +566,7 @@ export default function PackPanel({
</div> </div>
<span className="shrink-0 text-[10px] text-white/35"></span> <span className="shrink-0 text-[10px] text-white/35"></span>
</div> </div>
<SectionNav active={activeNav} onChange={setActiveNav} /> <SectionNav active={activeNav} onChange={onActiveNavChange} />
</section> </section>
<div className="pack-scroll min-h-0 flex-1 overflow-y-auto pr-1" key={activeNav}> <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 { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; 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'; import ResultGrid from './ResultGrid';
export type ProjectGalleryDrawerProps = { export type ProjectGalleryDrawerProps = {
session: GenSession; session: GenSession;
activeNav: string;
onAction: (imageId: string, action: 'select' | 'reject' | 'reset') => void; 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 === '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'; if (status === 'rejected') return 'opacity-45 grayscale ring-white/10';
return 'ring-white/12 hover:ring-white/28'; 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 [open, setOpen] = useState(false);
const [mounted, setMounted] = 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(() => { useEffect(() => {
setMounted(true); 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="flex shrink-0 items-start justify-between gap-4 border-b border-white/10 p-5">
<div className="min-w-0"> <div className="min-w-0">
<span className="section-eyebrow">Project Gallery</span> <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"> <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-white/42">
{session.characterSpec?.name || session.prompt || '当前项目'} {gallery.description}
</p> </p>
</div> </div>
<button <button
@@ -59,20 +138,50 @@ export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalle
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-5"> <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> </div>
</aside> </aside>
</> </>
); );
const centerPreview = preview ? (
<div className="gallery-center-preview" aria-hidden="true">
<img src={preview.url} alt="" />
</div>
) : null;
return ( 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"> <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 <button
onClick={() => setOpen(true)} 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]" 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="打开项目图库" title={`打开${gallery.label}图库`}
aria-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"> <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" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -83,18 +192,23 @@ export default function ProjectGalleryDrawer({ session, onAction }: ProjectGalle
</button> </button>
<div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42"> <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> <b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
<span>/ {session.images.length}</span> <span>/ {gallery.total}</span>
</div> </div>
<div className="gallery-thumb-list flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto pr-0.5"> <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 <button
key={image.id} key={image.id}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className={`relative aspect-square w-full cursor-pointer overflow-hidden rounded-[8px] bg-white ring-1 transition-all ${statusClass(image.status)}`} onMouseEnter={() => setPreview(image)}
title={`打开第 ${index + 1}`} onMouseLeave={() => setPreview(null)}
aria-label={`打开第 ${index + 1}`} 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" /> <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"> <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> </button>
))} ))}
{!gallery.images.length && (
<div className="gallery-rail-empty"></div>
)}
</div> </div>
</aside> </aside>
{mounted ? createPortal(drawer, document.body) : null} {mounted ? createPortal(drawer, document.body) : null}
{mounted && centerPreview ? createPortal(centerPreview, document.body) : null}
</> </>
); );
} }