524 lines
23 KiB
TypeScript
524 lines
23 KiB
TypeScript
'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>
|
||
);
|
||
}
|