fix: switch asset tabs as single panel

This commit is contained in:
2026-05-20 19:22:24 +08:00
parent d3d9349e8b
commit 2e3bc5078f

View File

@@ -247,7 +247,7 @@ function DetailItem({ label, value }: { label: string; value: string | number })
); );
} }
/* ── Pack Section (collapsible) ───────────────── */ /* ── Pack Section ─────────────────────────────── */
function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: { function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: {
kind: PackKind; kind: PackKind;
pack: AssetPack | undefined; pack: AssetPack | undefined;
@@ -256,7 +256,6 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs
stepIndex: number; stepIndex: number;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>; onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
}) { }) {
const [open, setOpen] = useState(kind === 'patent');
const [detail, setDetail] = useState<AssetDetail | null>(null); const [detail, setDetail] = useState<AssetDetail | null>(null);
const accent = PACK_ACCENT[kind]; const accent = PACK_ACCENT[kind];
const templates = PACK_TEMPLATES[kind]; const templates = PACK_TEMPLATES[kind];
@@ -279,15 +278,6 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs
</div> </div>
</div> </div>
<span className="shrink-0 font-mono text-[10px] text-white/35">{generatedCount}/{total}</span> <span className="shrink-0 font-mono text-[10px] text-white/35">{generatedCount}/{total}</span>
<button
onClick={() => setOpen(v => !v)}
title={open ? '收起' : '展开'}
className="w-7 h-7 shrink-0 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={open ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
</button>
</div> </div>
{locked && lockReason && ( {locked && lockReason && (
@@ -296,32 +286,28 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs
</div> </div>
)} )}
{/* asset list — collapsible */} <div className="border-t border-white/[0.05] px-4 pb-4">
{open && ( <div className="asset-strip pt-3">
<div className="border-t border-white/[0.05] px-4 pb-4"> {templates.map(template => {
<div className="asset-strip pt-3"> const asset = pack?.assets.find(a => a.templateId === template.id);
{templates.map(template => { return (
const asset = pack?.assets.find(a => a.templateId === template.id); <AssetTile
return ( key={template.id}
<AssetTile template={template}
key={template.id} asset={asset}
template={template} onOpen={() => setDetail({ template, asset })}
asset={asset} />
onOpen={() => setDetail({ template, asset })} );
/> })}
);
})}
</div>
</div> </div>
)} </div>
<AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} onRegenerate={onRegenerateAsset} /> <AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} onRegenerate={onRegenerateAsset} />
</section> </section>
); );
} }
/* ── Text Template Section (collapsible) ──────── */ /* ── Text Template Section ────────────────────── */
function TextTemplateSection({ locked }: { locked: boolean }) { function TextTemplateSection({ locked }: { locked: boolean }) {
const [open, setOpen] = useState(false);
const [showPromptId, setShowPromptId] = useState<string | null>(null); const [showPromptId, setShowPromptId] = useState<string | null>(null);
return ( return (
@@ -340,76 +326,61 @@ function TextTemplateSection({ locked }: { locked: boolean }) {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="chip bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30 text-[10px]">GPT Text</span> <span className="chip bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30 text-[10px]">GPT Text</span>
<button
onClick={() => {
if (!locked) setOpen(v => !v);
}}
disabled={locked}
title={locked ? '四个图片包完成后解锁文案任务' : open ? '收起' : '展开'}
className="w-7 h-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors disabled:cursor-not-allowed disabled:opacity-35"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={open ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
</button>
</div> </div>
</div> </div>
{open && ( <div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3"> {TEXT_TEMPLATES.map(template => {
{TEXT_TEMPLATES.map(template => { const isOpen = showPromptId === template.id;
const isOpen = showPromptId === template.id; return (
return ( <div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all"> <div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#8cb478]/15 to-[#d6b36a]/15 ring-1 ring-[#8cb478]/20 flex flex-col items-center justify-center text-[#cfe7a7] text-[9px] font-mono gap-0.5">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#8cb478]/15 to-[#d6b36a]/15 ring-1 ring-[#8cb478]/20 flex flex-col items-center justify-center text-[#cfe7a7] text-[9px] font-mono gap-0.5"> <span>text</span>
<span>text</span> <span className="text-[8px] text-[#cfe7a7]/60">{template.outputFormat}</span>
<span className="text-[8px] text-[#cfe7a7]/60">{template.outputFormat}</span>
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium text-white">{template.title}</span>
<span className={`chip text-[10px] py-0 ${template.kind === 'patent' ? 'bg-[#e6f578]/15 text-[#e6f578] border-[#e6f578]/30' : template.kind === 'production' ? 'bg-[#d6b36a]/15 text-[#f2d38c] border-[#d6b36a]/30' : template.kind === 'accessories' ? 'bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30' : template.kind === 'marketing' ? 'bg-[#b6df72]/15 text-[#dff5a8] border-[#b6df72]/30' : 'chip-neutral'}`}>
{template.kind}
</span>
{template.required && <span className="text-[9px] text-[#e6f578]/80 uppercase tracking-widest"></span>}
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#cfe7a7] transition-colors flex items-center gap-1"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={isOpen ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
Prompt
</button>
{isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<div className="flex flex-col items-end justify-between text-right shrink-0">
<span className="text-[9px] text-white/25 uppercase tracking-wider">{template.outputFormat}</span>
<span className="text-[10px] text-white/25"></span>
</div>
</div> </div>
); <div className="min-w-0 space-y-1">
})} <div className="flex items-center gap-1.5 flex-wrap">
</div> <span className="text-[13px] font-medium text-white">{template.title}</span>
)} <span className={`chip text-[10px] py-0 ${template.kind === 'patent' ? 'bg-[#e6f578]/15 text-[#e6f578] border-[#e6f578]/30' : template.kind === 'production' ? 'bg-[#d6b36a]/15 text-[#f2d38c] border-[#d6b36a]/30' : template.kind === 'accessories' ? 'bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30' : template.kind === 'marketing' ? 'bg-[#b6df72]/15 text-[#dff5a8] border-[#b6df72]/30' : 'chip-neutral'}`}>
{template.kind}
</span>
{template.required && <span className="text-[9px] text-[#e6f578]/80 uppercase tracking-widest"></span>}
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#cfe7a7] transition-colors flex items-center gap-1"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={isOpen ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
Prompt
</button>
{isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<div className="flex flex-col items-end justify-between text-right shrink-0">
<span className="text-[9px] text-white/25 uppercase tracking-wider">{template.outputFormat}</span>
<span className="text-[10px] text-white/25"></span>
</div>
</div>
);
})}
</div>
</section> </section>
); );
} }
/* ── Video Section (collapsible) ──────────────── */ /* ── Video Section ────────────────────────────── */
function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: { function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
videoLoading: boolean; videoLoading: boolean;
primaryImage: GenImage; primaryImage: GenImage;
locked: boolean; locked: boolean;
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void; onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
}) { }) {
const [open, setOpen] = useState(false);
const [showPromptId, setShowPromptId] = useState<string | null>(null); const [showPromptId, setShowPromptId] = useState<string | null>(null);
return ( return (
@@ -428,67 +399,53 @@ function VideoSection({ videoLoading, primaryImage, locked, onGenerateVideo }: {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="chip chip-violet text-[10px]">Seedance</span> <span className="chip chip-violet text-[10px]">Seedance</span>
<button
onClick={() => {
if (!locked) setOpen(v => !v);
}}
disabled={locked}
title={locked ? '四个图片包完成后解锁视频任务' : open ? '收起' : '展开'}
className="w-7 h-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] flex items-center justify-center text-white/50 transition-colors disabled:cursor-not-allowed disabled:opacity-35"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={open ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
</button>
</div> </div>
</div> </div>
{open && ( <div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3"> {VIDEO_TEMPLATES.map(template => {
{VIDEO_TEMPLATES.map(template => { const isOpen = showPromptId === template.id;
const isOpen = showPromptId === template.id; return (
return ( <div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all"> <div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#e6f578]/15 to-[#8cb478]/15 ring-1 ring-[#e6f578]/20 flex flex-col items-center justify-center text-[#e6f578] text-[9px] font-mono gap-1">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#e6f578]/15 to-[#8cb478]/15 ring-1 ring-[#e6f578]/20 flex flex-col items-center justify-center text-[#e6f578] text-[9px] font-mono gap-1"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <path d="M8 5.2v13.6L18.5 12 8 5.2z" />
<path d="M8 5.2v13.6L18.5 12 8 5.2z" /> </svg>
</svg> <span className="text-[8px]">{template.duration}s</span>
<span className="text-[8px]">{template.duration}s</span>
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium text-white">{template.title}</span>
<span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span>
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={isOpen ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
Prompt
</button>
{isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<button
onClick={() => onGenerateVideo(primaryImage, template)}
disabled={videoLoading || locked}
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>
{locked ? '锁定' : videoLoading ? '...' : '提交'}
</button>
</div> </div>
); <div className="min-w-0 space-y-1">
})} <div className="flex items-center gap-1.5 flex-wrap">
</div> <span className="text-[13px] font-medium text-white">{template.title}</span>
)} <span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span>
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={isOpen ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
Prompt
</button>
{isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<button
onClick={() => onGenerateVideo(primaryImage, template)}
disabled={videoLoading || locked}
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>
{locked ? '锁定' : videoLoading ? '...' : '提交'}
</button>
</div>
);
})}
</div>
</section> </section>
); );
} }
@@ -509,10 +466,7 @@ function SectionNav({ active, onChange }: { active: string; onChange: (id: strin
{NAV_ITEMS.map(item => ( {NAV_ITEMS.map(item => (
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => onChange(item.id)}
onChange(item.id);
document.getElementById(item.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
className={`shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${ className={`shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
active === item.id active === item.id
? `text-[#081006] bg-gradient-to-r ${item.accent} shadow-sm` ? `text-[#081006] bg-gradient-to-r ${item.accent} shadow-sm`
@@ -543,6 +497,20 @@ function previousPackLabel(kind: PackKind) {
return PACK_LABELS[PACK_ORDER[index - 1]]; return PACK_LABELS[PACK_ORDER[index - 1]];
} }
function packPanelState(packs: AssetPack[], kind: PackKind, primaryImageId: string, characterReady: boolean) {
const pack = packForKind(packs, kind, primaryImageId);
const index = PACK_ORDER.indexOf(kind);
const previousKind = index > 0 ? PACK_ORDER[index - 1] : null;
const previousComplete = !previousKind || isPackComplete(packForKind(packs, previousKind, primaryImageId), previousKind);
const locked = !characterReady || !previousComplete;
const lockReason = !characterReady
? '先锁定角色设定,然后从专利包开始。'
: previousComplete
? undefined
: `请先完成${previousPackLabel(kind)},再生成${PACK_LABELS[kind]}`;
return { pack, index, locked, lockReason };
}
export default function PackPanel({ export default function PackPanel({
session, session,
videoLoading, videoLoading,
@@ -583,6 +551,7 @@ export default function PackPanel({
const completedPackCount = PACK_ORDER.filter(kind => isPackComplete(packForKind(packs, kind, primaryImage.id), kind)).length; const completedPackCount = PACK_ORDER.filter(kind => isPackComplete(packForKind(packs, kind, primaryImage.id), kind)).length;
const imagePacksComplete = completedPackCount === PACK_ORDER.length; const imagePacksComplete = completedPackCount === PACK_ORDER.length;
const activePackKind = PACK_ORDER.find(kind => activeNav === `pack-${kind}`);
return ( return (
<div className="flex h-full min-h-0 flex-col gap-3"> <div className="flex h-full min-h-0 flex-col gap-3">
@@ -597,23 +566,12 @@ export default function PackPanel({
<SectionNav active={activeNav} onChange={setActiveNav} /> <SectionNav active={activeNav} onChange={setActiveNav} />
</section> </section>
<div className="pack-scroll min-h-0 flex-1 space-y-3 overflow-y-auto pr-1"> <div className="pack-scroll min-h-0 flex-1 overflow-y-auto pr-1" key={activeNav}>
{/* Pack sections */} {activePackKind ? (() => {
{PACK_ORDER.map(kind => { const { pack, index, locked, lockReason } = packPanelState(packs, activePackKind, primaryImage.id, characterReady);
const pack = packForKind(packs, kind, primaryImage.id);
const index = PACK_ORDER.indexOf(kind);
const previousKind = index > 0 ? PACK_ORDER[index - 1] : null;
const previousComplete = !previousKind || isPackComplete(packForKind(packs, previousKind, primaryImage.id), previousKind);
const locked = !characterReady || !previousComplete;
const lockReason = !characterReady
? '先锁定角色设定,然后从专利包开始。'
: previousComplete
? undefined
: `请先完成${previousPackLabel(kind)},再生成${PACK_LABELS[kind]}`;
return ( return (
<PackSection <PackSection
key={kind} kind={activePackKind}
kind={kind}
pack={pack} pack={pack}
locked={locked} locked={locked}
lockReason={lockReason} lockReason={lockReason}
@@ -621,23 +579,25 @@ export default function PackPanel({
onRegenerateAsset={onRegenerateAsset} onRegenerateAsset={onRegenerateAsset}
/> />
); );
})} })() : (
<div className={imagePacksComplete ? '' : 'opacity-55'}>
{/* Text + Video */}
<div className={imagePacksComplete ? '' : 'opacity-55'}>
{!imagePacksComplete && ( {!imagePacksComplete && (
<div className="mb-3 rounded-[8px] border border-white/[0.06] bg-black/20 px-3 py-2 text-[11px] text-white/38"> <div className="mb-3 rounded-[8px] border border-white/[0.06] bg-black/20 px-3 py-2 text-[11px] text-white/38">
</div> </div>
)} )}
<TextTemplateSection locked={!imagePacksComplete} /> {activeNav === 'pack-text' ? (
<VideoSection <TextTemplateSection locked={!imagePacksComplete} />
videoLoading={videoLoading} ) : (
primaryImage={primaryImage} <VideoSection
locked={!imagePacksComplete} videoLoading={videoLoading}
onGenerateVideo={onGenerateVideo} primaryImage={primaryImage}
/> locked={!imagePacksComplete}
</div> onGenerateVideo={onGenerateVideo}
/>
)}
</div>
)}
</div> </div>
</div> </div>
); );