Files
ai-toy-patent-workflow/src/components/PackPanel.tsx
2026-05-19 00:56:41 +08:00

524 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { 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';
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
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 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',
};
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
}
function AssetRow({
template,
asset,
accent,
}: {
template: AssetTemplate;
asset: ToyAsset | undefined;
accent: typeof PACK_ACCENT[PackKind];
}) {
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]">
{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>
)}
{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>
)}
</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>
)}
</div>
<p className="text-[11px] text-white/55 leading-relaxed line-clamp-2">{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"
>
<svg width="10" height="10" 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'}
</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">
{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'}`}>
{ready ? 'Ready' : '待生成'}
</div>
</div>
</div>
);
}
function PackSection({
kind,
session,
pack,
isLoading,
onGenerate,
}: {
kind: PackKind;
session: GenSession;
primaryImage: GenImage;
pack: AssetPack | undefined;
isLoading: boolean;
onGenerate: () => void;
}) {
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;
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'}
</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>
</div>
<p className="text-[11px] text-white/45 mt-0.5">{PACK_DESCRIPTIONS[kind]}</p>
</div>
</div>
<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]"
>
<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`}
>
{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}`}
</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>
</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,
}: {
videoLoading: boolean;
primaryImage: GenImage;
onGenerateVideo: (image: GenImage, promptTemplate: string) => void;
}) {
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>
</div>
</div>
<span className="chip chip-violet">Seedance</span>
</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>
</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"
>
{videoLoading ? '提交中' : '提交'}
</button>
</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 },
];
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>
);
}
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>
);
}
export default function PackPanel({
session,
loadingKind,
allLoading,
characterLoading,
videoLoading,
onGenerate,
onGenerateAll,
onLockCharacter,
onGenerateVideo,
}: {
session: GenSession;
loadingKind: PackKind | null;
allLoading: boolean;
characterLoading: boolean;
videoLoading: boolean;
onGenerate: (image: GenImage, kind: PackKind) => void;
onGenerateAll: (image: GenImage) => void;
onLockCharacter: (image: GenImage) => void;
onGenerateVideo: (image: GenImage, prompt: string) => void;
}) {
const selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? null;
const packs = session.packs ?? [];
if (!primaryImage) {
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>
<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">
/ / /
</p>
</div>
</div>
</section>
);
}
return (
<div className="space-y-6">
{/* Step 03 Header */}
<section className="card p-6 space-y-5">
<div className="flex items-start justify-between gap-4">
<div>
<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
</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>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => onLockCharacter(primaryImage)}
disabled={characterLoading || !!loadingKind || allLoading}
className="btn btn-glass text-xs disabled:opacity-40 disabled:cursor-not-allowed"
>
{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 ? '刷新角色设定' : '锁定角色设定'}
</button>
<button
onClick={() => onGenerateAll(primaryImage)}
disabled={allLoading || !!loadingKind || characterLoading}
className="btn btn-primary text-xs disabled:opacity-40 disabled:cursor-not-allowed"
>
{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) + ' 张'}
</button>
</div>
<SlotOverview />
{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>
<span className="chip chip-violet shrink-0">CharacterSpec · v1</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>
</div>
)}
</section>
{/* Pack Sections */}
{PACK_ORDER.map(kind => {
const pack = packs.find(p => p.kind === kind && p.sourceImageId === primaryImage.id);
return (
<PackSection
key={kind}
kind={kind}
session={session}
primaryImage={primaryImage}
pack={pack}
isLoading={loadingKind === kind}
onGenerate={() => onGenerate(primaryImage, kind)}
/>
);
})}
<TextTemplateSection />
{/* Video Section */}
<VideoSection
videoLoading={videoLoading}
primaryImage={primaryImage}
onGenerateVideo={onGenerateVideo}
/>
</div>
);
}