auto-save 2026-05-19 08:46 (~3)

This commit is contained in:
2026-05-19 08:46:24 +08:00
parent 167bf612a7
commit 917d0cf30c
3 changed files with 344 additions and 315 deletions

View File

@@ -286,6 +286,13 @@
"message": "auto-save 2026-05-19 00:56 (~5)",
"hash": "0517892",
"files_changed": 5
},
{
"ts": "2026-05-19T08:40:59+08:00",
"type": "commit",
"message": "auto-save 2026-05-19 08:40 (~3)",
"hash": "167bf61",
"files_changed": 3
}
]
}

View File

@@ -1,65 +1,38 @@
'use client';
import { useState } from 'react';
import { useRef, useState } from 'react';
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEMPLATE_SLOT_SUMMARY, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
patent: '六面视图45° 立体图局部放大——外观专利素材',
accessories: '配件六视图连接结构、尺寸和组合关系——独立配件保护 / 打样',
production: '尺寸、材料、颜色、拆件、包装——工厂报价 / 打样',
marketing: '白底商品图、场景图、细节图、社媒图——新品宣发',
patent: '六面视图 · 45° 立体图 · 局部放大',
accessories: '配件六视图 · 连接结构 · 尺寸 · 组合图',
production: '尺寸 · 材料 · 颜色 · 拆件 · 包装',
marketing: '白底商品图 · 场景图 · 细节图 · 社媒图',
};
const PACK_ACCENT: Record<PackKind, { ring: string; chip: string; dot: string; bar: string; soft: string }> = {
patent: {
ring: 'ring-violet-400/30',
chip: 'bg-violet-500/15 text-violet-200 border-violet-400/30',
dot: 'bg-violet-400',
bar: 'from-violet-400 to-indigo-400',
soft: 'from-violet-500/10 to-transparent',
},
production: {
ring: 'ring-amber-400/30',
chip: 'bg-amber-500/15 text-amber-200 border-amber-400/30',
dot: 'bg-amber-400',
bar: 'from-amber-400 to-orange-400',
soft: 'from-amber-500/10 to-transparent',
},
accessories: {
ring: 'ring-sky-400/30',
chip: 'bg-sky-500/15 text-sky-200 border-sky-400/30',
dot: 'bg-sky-400',
bar: 'from-sky-400 to-cyan-400',
soft: 'from-sky-500/10 to-transparent',
},
marketing: {
ring: 'ring-emerald-400/30',
chip: 'bg-emerald-500/15 text-emerald-200 border-emerald-400/30',
dot: 'bg-emerald-400',
bar: 'from-emerald-400 to-teal-400',
soft: 'from-emerald-500/10 to-transparent',
},
const PACK_ACCENT: Record<PackKind, { bar: string; chip: string; icon: string }> = {
patent: { bar: 'from-violet-400 to-indigo-400', chip: 'bg-violet-500/15 text-violet-200 border-violet-400/30', icon: 'P' },
accessories: { bar: 'from-sky-400 to-cyan-400', chip: 'bg-sky-500/15 text-sky-200 border-sky-400/30', icon: 'A' },
production: { bar: 'from-amber-400 to-orange-400', chip: 'bg-amber-500/15 text-amber-200 border-amber-400/30', icon: 'F' },
marketing: { bar: 'from-emerald-400 to-teal-400', chip: 'bg-emerald-500/15 text-emerald-200 border-emerald-400/30', icon: 'M' },
};
const ASPECT_PX: Record<AssetTemplate['aspectRatio'], string> = {
'1:1': '1024 × 1024',
'3:4': '1024 × 1365',
'4:5': '1024 × 1280',
'9:16': '1080 × 1920',
'16:9': '1920 × 1080',
'long': '1024 × 3200',
'1:1': '1024×1024',
'3:4': '1024×1365',
'4:5': '1024×1280',
'9:16': '1080×1920',
'16:9': '1920×1080',
'long': '1024×3200',
};
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
}
function AssetRow({
template,
asset,
accent,
}: {
/* ── Asset Row ────────────────────────────────── */
function AssetRow({ template, asset, accent }: {
template: AssetTemplate;
asset: ToyAsset | undefined;
accent: typeof PACK_ACCENT[PackKind];
@@ -67,66 +40,60 @@ function AssetRow({
const [showPrompt, setShowPrompt] = useState(false);
const ready = !!asset;
return (
<div className="grid grid-cols-[88px_1fr_120px] gap-4 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.12] transition-all">
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/[0.035] ring-1 ring-white/[0.07]">
<div className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
{/* thumbnail */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
{ready ? (
<img src={asset!.url} alt={template.title} className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-[9px] text-white/30">
<div className="text-white/40 font-mono">{template.view}</div>
<div className="mt-1 px-1.5 py-0.5 rounded bg-white/[0.05] text-[8px]">{template.aspectRatio}</div>
<div className="flex flex-col items-center gap-1">
<span className="text-[9px] text-white/30 font-mono">{template.aspectRatio}</span>
</div>
)}
{template.required && (
<span className="absolute top-1 right-1 px-1 py-0 rounded text-[8px] font-semibold bg-violet-500/40 text-white border border-violet-300/40 backdrop-blur">
</span>
{template.required && !ready && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-violet-400 ring-2 ring-violet-400/30" />
)}
</div>
<div className="min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white truncate">{template.title}</span>
<span className={`chip ${accent.chip} text-[10px] py-0.5`}>{template.view}</span>
{ready && (
<span className="chip chip-live text-[10px] py-0.5"> {asset!.version}</span>
{/* info */}
<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 leading-snug">{template.title}</span>
{template.required && !ready && (
<span className="text-[9px] text-violet-300/80 uppercase tracking-widest"></span>
)}
{ready && <span className="chip chip-live text-[10px] py-0"></span>}
</div>
<p className="text-[11px] text-white/55 leading-relaxed line-clamp-2">{template.description}</p>
<p className="text-[11px] text-white/45 leading-relaxed line-clamp-1">{template.description}</p>
<button
onClick={() => setShowPrompt(s => !s)}
className="text-[10px] text-white/40 hover:text-violet-300 transition-colors flex items-center gap-1"
className="text-[10px] text-white/30 hover:text-violet-300 transition-colors flex items-center gap-1"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={showPrompt ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
{showPrompt ? '收起 Prompt' : '查看 Prompt'}
Prompt
</button>
{showPrompt && (
<pre className="mt-1.5 p-2.5 text-[10px] text-white/65 bg-black/40 rounded-lg ring-1 ring-white/[0.08] font-mono leading-relaxed whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
<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 gap-1.5 text-right">
<div className="text-[10px] font-mono text-white/55">{ASPECT_PX[template.aspectRatio]}</div>
<div className="text-[9px] text-white/30 uppercase tracking-wider">{template.aspectRatio}</div>
<div className={`text-[10px] mt-auto ${ready ? 'text-emerald-300' : 'text-white/35'}`}>
{/* meta */}
<div className="flex flex-col items-end justify-between text-right shrink-0">
<span className="text-[10px] font-mono text-white/40">{ASPECT_PX[template.aspectRatio]}</span>
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
{ready ? 'Ready' : '待生成'}
</div>
</span>
</div>
</div>
);
}
function PackSection({
kind,
session,
pack,
isLoading,
onGenerate,
}: {
/* ── Pack Section (collapsible) ───────────────── */
function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate }: {
kind: PackKind;
session: GenSession;
primaryImage: GenImage;
@@ -134,251 +101,286 @@ function PackSection({
isLoading: boolean;
onGenerate: () => void;
}) {
const [open, setOpen] = useState(false);
const accent = PACK_ACCENT[kind];
const templates = PACK_TEMPLATES[kind];
const generatedCount = pack?.assets.length ?? 0;
const total = templates.length;
const progressPct = (generatedCount / total) * 100;
const progressPct = Math.round((generatedCount / total) * 100);
return (
<section className="card p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className={`w-9 h-9 rounded-xl bg-gradient-to-br ${accent.bar} flex items-center justify-center text-white text-xs font-bold shrink-0`}>
{kind === 'patent' ? 'P' : kind === 'accessories' ? 'A' : kind === 'production' ? 'F' : 'M'}
<section className="card overflow-hidden" id={`pack-${kind}`}>
{/* header — always visible */}
<div className="p-4 flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${accent.bar} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
{accent.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white">{PACK_LABELS[kind]}</span>
<span className="text-[10px] text-white/35">{PACK_DESCRIPTIONS[kind]}</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">{PACK_LABELS[kind]}</h3>
<span className="text-[11px] text-white/40">· {total} </span>
{/* progress bar */}
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${accent.bar} transition-all`}
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="text-[11px] text-white/45 mt-0.5">{PACK_DESCRIPTIONS[kind]}</p>
<span className="text-[10px] font-mono text-white/35 shrink-0">{generatedCount}/{total}</span>
</div>
</div>
{/* actions */}
<div className="flex items-center gap-2 shrink-0">
{pack && (
<a
href={manifestUrl(session.id, kind, pack.version)}
className="inline-flex items-center gap-1 text-[10px] text-violet-300 hover:text-violet-200 transition-colors px-2 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08]"
className="text-[10px] text-violet-300 hover:text-violet-200 px-2 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08] transition-colors"
title="下载 manifest"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" strokeLinecap="round" strokeLinejoin="round" />
</svg>
manifest
</a>
)}
<button
onClick={onGenerate}
disabled={isLoading}
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs disabled:opacity-40 disabled:cursor-not-allowed`}
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
>
{isLoading ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
</>
) : pack ? '重新生成本包' : `生成 ${total}`}
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
) : pack ? '重做' : `生成 ${total}`}
</button>
<button
onClick={() => setOpen(v => !v)}
title={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"
>
<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 className="space-y-1.5">
<div className="flex items-center justify-between text-[10px] text-white/40">
<span></span>
<span className="font-mono">{generatedCount} / {total}</span>
{/* asset list — collapsible */}
{open && (
<div className="px-4 pb-4 space-y-2 border-t border-white/[0.05]">
<div className="pt-3 space-y-2">
{templates.map(template => {
const asset = pack?.assets.find(a => a.templateId === template.id);
return (
<AssetRow key={template.id} template={template} asset={asset} accent={accent} />
);
})}
</div>
</div>
<div className="h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${accent.bar} transition-all`}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
<div className="space-y-2">
{templates.map(template => {
const asset = pack?.assets.find(a => a.templateId === template.id);
return (
<AssetRow
key={template.id}
template={template}
asset={asset}
accent={accent}
/>
);
})}
</div>
)}
</section>
);
}
function VideoSection({
videoLoading,
primaryImage,
onGenerateVideo,
}: {
/* ── Text Template Section (collapsible) ──────── */
function TextTemplateSection() {
const [open, setOpen] = useState(false);
const [showPromptId, setShowPromptId] = useState<string | null>(null);
return (
<section className="card overflow-hidden" id="pack-text">
<div className="p-4 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-cyan-400 flex items-center justify-center text-white text-[11px] font-bold shrink-0">T</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white"></span>
<span className="text-[10px] text-white/35"> · · · </span>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06]" />
<span className="text-[10px] font-mono text-white/35 shrink-0">0/{TEXT_TEMPLATES.length}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="chip bg-sky-500/15 text-sky-200 border-sky-400/30 text-[10px]">GPT Text</span>
<button
onClick={() => setOpen(v => !v)}
title={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"
>
<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>
{open && (
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{TEXT_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
return (
<div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-xl bg-gradient-to-br from-sky-500/15 to-cyan-500/15 ring-1 ring-sky-400/20 flex flex-col items-center justify-center text-sky-300 text-[9px] font-mono gap-0.5">
<span>text</span>
<span className="text-[8px] text-sky-400/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-violet-500/15 text-violet-200 border-violet-400/30' : template.kind === 'production' ? 'bg-amber-500/15 text-amber-200 border-amber-400/30' : template.kind === 'accessories' ? 'bg-sky-500/15 text-sky-200 border-sky-400/30' : template.kind === 'marketing' ? 'bg-emerald-500/15 text-emerald-200 border-emerald-400/30' : 'chip-neutral'}`}>
{template.kind}
</span>
{template.required && <span className="text-[9px] text-violet-300/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-sky-300 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>
);
}
/* ── Video Section (collapsible) ──────────────── */
function VideoSection({ videoLoading, primaryImage, onGenerateVideo }: {
videoLoading: boolean;
primaryImage: GenImage;
onGenerateVideo: (image: GenImage, promptTemplate: string) => void;
}) {
const [open, setOpen] = useState(false);
const [showPromptId, setShowPromptId] = useState<string | null>(null);
return (
<section className="card p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-500 flex items-center justify-center text-white text-xs font-bold">V</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">Seedance </h3>
<span className="text-[11px] text-white/40">· {VIDEO_TEMPLATES.length} </span>
</div>
<p className="text-[11px] text-white/45 mt-0.5"> · / </p>
<section className="card overflow-hidden" id="pack-video">
<div className="p-4 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-fuchsia-500 to-violet-500 flex items-center justify-center text-white text-[11px] font-bold shrink-0">V</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">Seedance </span>
<span className="text-[10px] text-white/35"> · / </span>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06]" />
<span className="text-[10px] font-mono text-white/35 shrink-0">{VIDEO_TEMPLATES.length} </span>
</div>
</div>
<span className="chip chip-violet">Seedance</span>
<div className="flex items-center gap-2 shrink-0">
<span className="chip chip-violet text-[10px]">Seedance</span>
<button
onClick={() => setOpen(v => !v)}
title={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"
>
<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 className="space-y-2">
{VIDEO_TEMPLATES.map(template => {
const open = showPromptId === template.id;
return (
<div key={template.id} className="grid grid-cols-[88px_1fr_120px] gap-4 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.12] transition-all">
<div className="aspect-square rounded-xl bg-gradient-to-br from-fuchsia-500/20 to-violet-500/20 ring-1 ring-fuchsia-400/20 flex items-center justify-center text-fuchsia-200 text-[10px] font-mono">
video
</div>
<div className="min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white truncate">{template.title}</span>
<span className="chip bg-fuchsia-500/15 text-fuchsia-200 border-fuchsia-400/30 text-[10px] py-0.5">
{template.duration}s
</span>
<span className="chip chip-neutral text-[10px] py-0.5">
{template.ratio}
</span>
<span className="chip chip-neutral text-[10px] py-0.5">
1080p
</span>
{open && (
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{VIDEO_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
return (
<div key={template.id} className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-xl bg-gradient-to-br from-fuchsia-500/15 to-violet-500/15 ring-1 ring-fuchsia-400/20 flex flex-col items-center justify-center text-fuchsia-300 text-[9px] font-mono gap-1">
<span></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-fuchsia-300 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>
<p className="text-[11px] text-white/55 leading-relaxed line-clamp-2">{template.description}</p>
<button
onClick={() => setShowPromptId(open ? null : template.id)}
className="text-[10px] text-white/40 hover:text-fuchsia-300 transition-colors flex items-center gap-1"
>
<svg width="10" height="10" 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>
{open ? '收起 Prompt' : '查看 Prompt'}
</button>
{open && (
<pre className="mt-1.5 p-2.5 text-[10px] text-white/65 bg-black/40 rounded-lg ring-1 ring-white/[0.08] font-mono leading-relaxed whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<div className="flex flex-col items-end justify-between gap-1.5">
<div className="text-[10px] text-white/30 uppercase tracking-wider">video</div>
<button
onClick={() => onGenerateVideo(primaryImage, template.promptTemplate)}
disabled={videoLoading}
className="btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
>
{videoLoading ? '提交中' : '提交'}
{videoLoading ? '...' : '提交'}
</button>
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</section>
);
}
function SlotOverview() {
const stats = [
{ label: '总预留位', value: TEMPLATE_SLOT_SUMMARY.totalReservedSlotCount },
{ label: '图片/脚本资产位', value: TEMPLATE_SLOT_SUMMARY.imageAssetCount },
{ label: '设计说明文字位', value: TEMPLATE_SLOT_SUMMARY.textAssetCount },
{ label: '必备图位', value: TEMPLATE_SLOT_SUMMARY.requiredImageAssetCount },
{ label: 'Seedance 视频任务', value: TEMPLATE_SLOT_SUMMARY.videoTaskCount },
];
/* ── Section Nav ──────────────────────────────── */
const NAV_ITEMS = [
{ id: 'pack-patent', label: '专利', accent: 'from-violet-400 to-indigo-400' },
{ id: 'pack-accessories', label: '配件', accent: 'from-sky-400 to-cyan-400' },
{ id: 'pack-production', label: '生产', accent: 'from-amber-400 to-orange-400' },
{ id: 'pack-marketing', label: '宣发', accent: 'from-emerald-400 to-teal-400' },
{ id: 'pack-text', label: '文字', accent: 'from-sky-500 to-cyan-400' },
{ id: 'pack-video', label: '视频', accent: 'from-fuchsia-500 to-violet-500' },
];
function SectionNav({ active, onChange }: { active: string; onChange: (id: string) => void }) {
return (
<div className="grid grid-cols-5 gap-2">
{stats.map(item => (
<div key={item.label} className="rounded-2xl bg-white/[0.035] ring-1 ring-white/[0.08] p-3">
<div className="text-lg font-semibold text-white font-mono">{item.value}</div>
<div className="mt-1 text-[10px] text-white/40">{item.label}</div>
</div>
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(item => (
<button
key={item.id}
onClick={() => {
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 ${
active === item.id
? `text-white bg-gradient-to-r ${item.accent} shadow-sm`
: 'text-white/45 hover:text-white/70 hover:bg-white/[0.05]'
}`}
>
{item.label}
</button>
))}
</div>
);
}
function TextTemplateSection() {
const [showPromptId, setShowPromptId] = useState<string | null>(null);
return (
<section className="card p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-sky-500 to-cyan-400 flex items-center justify-center text-white text-xs font-bold">T</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white"></h3>
<span className="text-[11px] text-white/40">· {TEXT_TEMPLATES.length} </span>
</div>
<p className="text-[11px] text-white/45 mt-0.5"></p>
</div>
</div>
<span className="chip bg-sky-500/15 text-sky-200 border-sky-400/30">GPT Text</span>
</div>
<div className="space-y-2">
{TEXT_TEMPLATES.map(template => {
const open = showPromptId === template.id;
return (
<div key={template.id} className="grid grid-cols-[88px_1fr_120px] gap-4 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.12] transition-all">
<div className="aspect-square rounded-xl bg-gradient-to-br from-sky-500/20 to-cyan-500/20 ring-1 ring-sky-400/20 flex flex-col items-center justify-center text-sky-200 text-[10px] font-mono">
<span>text</span>
{template.required && <span className="mt-1 text-[8px] text-white/70">required</span>}
</div>
<div className="min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white truncate">{template.title}</span>
<span className="chip bg-sky-500/15 text-sky-200 border-sky-400/30 text-[10px] py-0.5">{template.kind}</span>
<span className="chip chip-neutral text-[10px] py-0.5">{template.outputFormat}</span>
{template.required && <span className="chip chip-live text-[10px] py-0.5"></span>}
</div>
<p className="text-[11px] text-white/55 leading-relaxed line-clamp-2">{template.description}</p>
<button
onClick={() => setShowPromptId(open ? null : template.id)}
className="text-[10px] text-white/40 hover:text-sky-300 transition-colors flex items-center gap-1"
>
<svg width="10" height="10" 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>
{open ? '收起 Prompt' : '查看 Prompt'}
</button>
{open && (
<pre className="mt-1.5 p-2.5 text-[10px] text-white/65 bg-black/40 rounded-lg ring-1 ring-white/[0.08] font-mono leading-relaxed whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<div className="flex flex-col items-end justify-between gap-1.5 text-right">
<div className="text-[10px] font-mono text-white/55">{template.filenamePart}</div>
<div className="text-[9px] text-white/30 uppercase tracking-wider">{template.outputFormat}</div>
<div className="text-[10px] text-white/35"></div>
</div>
</div>
);
})}
</div>
</section>
);
}
/* ── Main Export ──────────────────────────────── */
const totalImageSlots = PACK_ORDER.reduce((s, k) => s + PACK_TEMPLATES[k].length, 0);
export default function PackPanel({
session,
@@ -401,6 +403,7 @@ export default function PackPanel({
onLockCharacter: (image: GenImage) => void;
onGenerateVideo: (image: GenImage, prompt: string) => 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 ?? [];
@@ -409,12 +412,12 @@ export default function PackPanel({
return (
<section className="card p-6">
<div className="flex items-start gap-4">
<div className="w-9 h-9 rounded-xl bg-white/[0.05] border border-white/[0.08] flex items-center justify-center text-white/40 text-sm"></div>
<div className="w-8 h-8 rounded-xl bg-white/[0.05] border border-white/[0.08] flex items-center justify-center text-white/30 text-sm"></div>
<div>
<span className="section-eyebrow">Step · 03 · Lock Character</span>
<h2 className="mt-2 text-sm font-semibold text-white"></h2>
<p className="mt-1 text-[11px] text-white/45 leading-relaxed">
/ / /
<span className="section-eyebrow">Step · 03 · Assets</span>
<h2 className="mt-1.5 text-sm font-semibold text-white"></h2>
<p className="mt-1 text-[11px] text-white/40">
{totalImageSlots} + {TEXT_TEMPLATES.length} + {VIDEO_TEMPLATES.length}
</p>
</div>
</div>
@@ -422,79 +425,103 @@ export default function PackPanel({
);
}
const generatedTotal = packs.reduce((s, p) => s + p.assets.length, 0);
return (
<div className="space-y-6">
{/* Step 03 Header */}
<section className="card p-6 space-y-5">
<div className="space-y-4">
{/* Step 03 header card */}
<section className="card p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex-1 min-w-0">
<span className="section-eyebrow">Step · 03 · Lock & Generate</span>
<h2 className="mt-2 text-lg font-semibold text-white"> & </h2>
<p className="mt-1 text-[11px] text-white/45 leading-relaxed max-w-[480px]">
Prompt
<h2 className="mt-1.5 text-base font-semibold text-white"> & </h2>
<p className="mt-0.5 text-[11px] text-white/40 max-w-[440px]">
+ + Prompt
</p>
</div>
<div className="relative w-20 h-20 rounded-2xl overflow-hidden ring-1 ring-white/15 shrink-0 shadow-glow-violet">
<img src={primaryImage.url} alt="selected source" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-1 left-1 right-1 text-[9px] font-semibold text-white/90 uppercase tracking-wider truncate"></div>
{/* primary image + stats */}
<div className="flex items-center gap-4 shrink-0">
<div className="text-right text-[11px] text-white/40 space-y-0.5">
<div className="font-mono text-white/70">{generatedTotal} <span className="text-white/30">/ {totalImageSlots} </span></div>
<div className="text-[10px]"></div>
</div>
<div className="relative w-16 h-16 rounded-2xl overflow-hidden ring-1 ring-white/15 shadow-glow-violet shrink-0">
<img src={primaryImage.url} alt="selected" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div className="absolute bottom-1 inset-x-0 text-center text-[8px] font-semibold text-white/80 uppercase tracking-wider"></div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* action buttons */}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => onLockCharacter(primaryImage)}
disabled={characterLoading || !!loadingKind || allLoading}
className="btn btn-glass text-xs disabled:opacity-40 disabled:cursor-not-allowed"
className="btn btn-glass text-xs disabled:opacity-40"
>
{characterLoading ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
</>
) : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" strokeLinecap="round" />
</svg>
)}
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
</button>
<button
onClick={() => onGenerateAll(primaryImage)}
disabled={allLoading || !!loadingKind || characterLoading}
className="btn btn-primary text-xs disabled:opacity-40 disabled:cursor-not-allowed"
className="btn btn-primary text-xs disabled:opacity-40"
>
{allLoading ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
</>
) : '✨ 一键三包 · ' + (PACK_TEMPLATES.patent.length + PACK_TEMPLATES.production.length + PACK_TEMPLATES.marketing.length) + ' 张'}
) : (
`一键全包 · ${totalImageSlots}`
)}
</button>
</div>
<SlotOverview />
{/* CharacterSpec */}
{session.characterSpec && (
<div className="card-2 p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<div className="text-sm font-semibold text-white">{session.characterSpec.name}</div>
<div className="text-[11px] text-white/55 mt-1 line-clamp-2 max-w-[480px]">{session.characterSpec.oneLiner}</div>
<div className="card-2 p-3.5">
<div className="flex items-center justify-between gap-3 mb-2.5">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-white truncate">{session.characterSpec.name}</div>
<div className="text-[11px] text-white/50 mt-0.5 line-clamp-1">{session.characterSpec.oneLiner}</div>
</div>
<span className="chip chip-violet shrink-0">CharacterSpec · v1</span>
<span className="chip chip-violet text-[10px] shrink-0">CharacterSpec</span>
</div>
<div className="divider-line mb-3" />
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="flex gap-2"><span className="text-white/40 w-12"></span><span className="text-white/85">{session.characterSpec.speciesShape}</span></div>
<div className="flex gap-2"><span className="text-white/40 w-12"></span><span className="text-white/85">{session.characterSpec.bodyRatio}</span></div>
<div className="flex gap-2"><span className="text-white/40 w-12"></span><span className="text-white/85">{session.characterSpec.colorPalette.join('、')}</span></div>
<div className="flex gap-2"><span className="text-white/40 w-12"></span><span className="text-white/85">{session.characterSpec.materials.join('、')}</span></div>
<div className="divider-line mb-2.5" />
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
{[
['形态', session.characterSpec.speciesShape],
['比例', session.characterSpec.bodyRatio],
['配色', session.characterSpec.colorPalette.join('、')],
['材料', session.characterSpec.materials.join('、')],
].map(([label, value]) => (
<div key={label} className="flex gap-2 min-w-0">
<span className="text-white/35 w-10 shrink-0">{label}</span>
<span className="text-white/75 truncate">{value}</span>
</div>
))}
</div>
</div>
)}
{/* Section nav */}
<SectionNav active={activeNav} onChange={setActiveNav} />
</section>
{/* Pack Sections */}
{/* Pack sections */}
{PACK_ORDER.map(kind => {
const pack = packs.find(p => p.kind === kind && p.sourceImageId === primaryImage.id);
return (
@@ -510,14 +537,9 @@ export default function PackPanel({
);
})}
{/* Text + Video */}
<TextTemplateSection />
{/* Video Section */}
<VideoSection
videoLoading={videoLoading}
primaryImage={primaryImage}
onGenerateVideo={onGenerateVideo}
/>
<VideoSection videoLoading={videoLoading} primaryImage={primaryImage} onGenerateVideo={onGenerateVideo} />
</div>
);
}

File diff suppressed because one or more lines are too long