fix: merge pack progress into project brief

This commit is contained in:
2026-05-20 18:40:30 +08:00
parent 94bca09305
commit f0b85dddd9
4 changed files with 282 additions and 149 deletions

View File

@@ -479,7 +479,7 @@ input, textarea {
.project-board-grid {
display: grid;
grid-template-columns: clamp(330px, 22vw, 410px) minmax(0, 1fr) 88px;
grid-template-columns: clamp(360px, 24vw, 450px) minmax(0, 1fr) 88px;
gap: 14px;
min-height: 0;
flex: 1;
@@ -606,6 +606,112 @@ input, textarea {
padding: 13px;
}
.project-pack-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-radius: 8px;
background: rgba(255,255,255,0.035);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.07);
padding: 9px;
}
.project-pack-row--locked {
opacity: 0.58;
}
.project-pack-index {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 8px;
background: linear-gradient(135deg, rgba(230,245,120,0.22), rgba(214,179,106,0.18));
box-shadow: inset 0 0 0 1px rgba(230,245,120,0.18);
color: #e6f578;
font-size: 10px;
font-weight: 700;
}
.project-pack-status {
white-space: nowrap;
border-radius: 999px;
background: rgba(255,255,255,0.045);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
padding: 3px 6px;
color: rgba(255,255,255,0.44);
font-size: 10px;
line-height: 1;
}
.project-pack-status--done {
background: rgba(230,245,120,0.13);
box-shadow: inset 0 0 0 1px rgba(230,245,120,0.22);
color: #e6f578;
}
.project-pack-status--ready {
background: rgba(214,179,106,0.12);
box-shadow: inset 0 0 0 1px rgba(214,179,106,0.20);
color: #f2d38c;
}
.project-pack-status--locked {
background: rgba(255,255,255,0.045);
color: rgba(255,255,255,0.42);
}
.project-pack-action,
.project-pack-jump,
.project-spec-action {
display: inline-grid;
place-items: center;
min-height: 28px;
border-radius: 8px;
background: rgba(255,255,255,0.055);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
color: rgba(255,255,255,0.62);
font-size: 10px;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.project-pack-action,
.project-spec-action {
padding: 0 8px;
}
.project-pack-jump {
width: 28px;
}
.project-pack-action:hover:not(:disabled),
.project-pack-jump:hover,
.project-spec-action:hover:not(:disabled) {
background: rgba(255,255,255,0.10);
box-shadow: inset 0 0 0 1px rgba(230,245,120,0.18);
color: rgba(255,255,255,0.92);
}
.project-pack-action:disabled,
.project-spec-action:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.project-pack-action:focus-visible,
.project-pack-jump:focus-visible,
.project-spec-action:focus-visible {
outline: 2px solid rgba(230,245,120,0.45);
outline-offset: 2px;
}
.project-spec-action--primary {
background: rgba(230,245,120,0.13);
box-shadow: inset 0 0 0 1px rgba(230,245,120,0.22);
color: #e6f578;
}
.project-spec-row {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
@@ -739,7 +845,7 @@ input, textarea {
}
.project-board-grid {
grid-template-columns: clamp(370px, 22vw, 440px) minmax(0, 1fr) 96px;
grid-template-columns: clamp(390px, 24vw, 480px) minmax(0, 1fr) 96px;
gap: 18px;
}
}

View File

@@ -7,7 +7,7 @@ import Sidebar from '@/components/Sidebar';
import PackPanel from '@/components/PackPanel';
import ProjectGalleryDrawer from '@/components/ProjectGalleryDrawer';
import { OasisCanvas } from '@/components/login/OasisCanvas';
import { PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates';
import type {
GenImage,
GenSession,
@@ -51,6 +51,13 @@ function packSlotTotal() {
return PACK_ORDER.reduce((sum, kind) => sum + PACK_TEMPLATES[kind].length, 0);
}
const PACK_BRIEF_DESCRIPTIONS: Record<PackKind, string> = {
patent: '六面视图 · 45° 立体图 · 局部放大',
accessories: '配件六视图 · 连接结构 · 尺寸 · 组合图',
production: '尺寸 · 材料 · 颜色 · 拆件 · 包装',
marketing: '白底商品图 · 场景图 · 细节图 · 社媒图',
};
function ProjectStat({ label, value, tone }: { label: string; value: string | number; tone?: 'accent' | 'soft' }) {
return (
<div className={`project-stat ${tone === 'accent' ? 'project-stat--accent' : ''}`}>
@@ -88,7 +95,114 @@ function ReferenceStrip({ session }: { session: GenSession }) {
);
}
function ProjectBrief({ session }: { session: GenSession }) {
function packForKind(session: GenSession, kind: PackKind, sourceImageId?: string) {
return session.packs?.find(pack => pack.kind === kind && (!sourceImageId || pack.sourceImageId === sourceImageId));
}
function isPackComplete(session: GenSession, kind: PackKind, sourceImageId?: string) {
const pack = packForKind(session, kind, sourceImageId);
return Boolean(pack && pack.assets.length >= PACK_TEMPLATES[kind].length);
}
function ProjectPackOverview({
session,
primaryImage,
loadingKind,
onGeneratePack,
}: {
session: GenSession;
primaryImage: GenImage | null;
loadingKind: PackKind | null;
onGeneratePack: (image: GenImage, kind: PackKind) => void;
}) {
if (!primaryImage) return null;
const sourceImage = primaryImage;
return (
<div className="project-spec-card mt-5">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold text-white/80"></span>
<span className="text-[10px] text-white/36"></span>
</div>
<div className="space-y-2">
{PACK_ORDER.map((kind, index) => {
const pack = packForKind(session, kind, sourceImage.id);
const total = PACK_TEMPLATES[kind].length;
const count = pack?.assets.length ?? 0;
const complete = count >= total;
const previousKind = index > 0 ? PACK_ORDER[index - 1] : null;
const locked = !session.characterSpec || Boolean(previousKind && !isPackComplete(session, previousKind, sourceImage.id));
const running = loadingKind === kind;
const progress = Math.round((count / total) * 100);
function handleGenerate() {
if (locked || running) return;
if (pack) {
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${count} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
if (!ok) return;
}
onGeneratePack(sourceImage, kind);
}
return (
<div key={kind} className={`project-pack-row ${locked ? 'project-pack-row--locked' : ''}`}>
<div className="project-pack-index">{index + 1}</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[12px] font-semibold text-white">{PACK_LABELS[kind]}</span>
<span className="truncate text-[10px] text-white/36">{PACK_BRIEF_DESCRIPTIONS[kind]}</span>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-gradient-to-r from-[#e6f578] to-[#d6b36a]" style={{ width: `${progress}%` }} />
</div>
<span className="shrink-0 font-mono text-[10px] text-white/36">{count}/{total}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}>
{complete ? '完成' : locked ? '锁定' : '可生成'}
</span>
<button
onClick={handleGenerate}
disabled={running || locked}
className="project-pack-action"
title={locked ? '先完成前置步骤' : pack ? '重新生成该包' : '生成该包'}
>
{running ? '...' : pack ? '重做' : '生成'}
</button>
<button
type="button"
className="project-pack-jump"
title="查看明细"
onClick={() => document.getElementById(`pack-${kind}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 6l6 6-6 6" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
function ProjectBrief({
session,
loadingKind,
characterLoading,
onGeneratePack,
onLockCharacter,
}: {
session: GenSession;
loadingKind: PackKind | null;
characterLoading: boolean;
onGeneratePack: (image: GenImage, kind: PackKind) => void;
onLockCharacter: (image: GenImage) => void;
}) {
const selectedImages = session.images.filter(image => image.status === 'selected');
const primaryImage = selectedImages[0] ?? session.images[0] ?? null;
const generatedAssets = (session.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0);
@@ -110,7 +224,7 @@ function ProjectBrief({ session }: { session: GenSession }) {
{session.characterSpec?.oneLiner || session.prompt || '当前项目暂无描述'}
</p>
</div>
<span className="rounded-full bg-[#e6f578]/15 px-3 py-1 text-[10px] font-semibold text-[#e6f578] ring-1 ring-[#e6f578]/25">
<span className="shrink-0 whitespace-nowrap rounded-full bg-[#e6f578]/15 px-3 py-1 text-[10px] font-semibold text-[#e6f578] ring-1 ring-[#e6f578]/25">
{modeLabel(session.inputMode)}
</span>
</div>
@@ -155,11 +269,28 @@ function ProjectBrief({ session }: { session: GenSession }) {
<ProjectStat label="产出" value={`${generatedAssets}/${totalSlots}`} />
</div>
<ProjectPackOverview
session={session}
primaryImage={primaryImage}
loadingKind={loadingKind}
onGeneratePack={onGeneratePack}
/>
{session.characterSpec ? (
<div className="project-spec-card mt-5">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold text-white/80"></span>
<span className="text-[10px] text-[#e6f578]/70"></span>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[10px] text-[#e6f578]/70"></span>
<button
type="button"
onClick={() => primaryImage && onLockCharacter(primaryImage)}
disabled={!primaryImage || characterLoading || !!loadingKind}
className="project-spec-action"
>
{characterLoading ? '锁定中' : '刷新'}
</button>
</div>
</div>
<div className="mt-3 space-y-2 text-[11px]">
<ProjectSpecField label="形态" value={session.characterSpec.speciesShape} />
@@ -174,7 +305,17 @@ function ProjectBrief({ session }: { session: GenSession }) {
</div>
) : (
<div className="project-spec-card mt-5">
<div className="text-[11px] font-semibold text-white/80"></div>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold text-white/80"></div>
<button
type="button"
onClick={() => primaryImage && onLockCharacter(primaryImage)}
disabled={!primaryImage || characterLoading || !!loadingKind}
className="project-spec-action project-spec-action--primary"
>
{characterLoading ? '锁定中' : '锁定'}
</button>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-white/45">
</p>
@@ -487,15 +628,17 @@ export default function Home() {
</div>
<div className="project-board-grid">
<ProjectBrief session={current} />
<ProjectBrief
session={current}
loadingKind={loadingKind}
characterLoading={characterLoading}
onGeneratePack={handleGeneratePack}
onLockCharacter={handleLockCharacter}
/>
<section className="project-production-panel">
<PackPanel
session={current}
loadingKind={loadingKind}
characterLoading={characterLoading}
videoLoading={videoLoading}
onGenerate={handleGeneratePack}
onLockCharacter={handleLockCharacter}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo}
/>

View File

@@ -154,14 +154,12 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
}
/* ── Pack Section (collapsible) ───────────────── */
function PackSection({ kind, pack, isLoading, locked, lockReason, stepIndex, onGenerate, onRegenerateAsset }: {
function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: {
kind: PackKind;
pack: AssetPack | undefined;
isLoading: boolean;
locked: boolean;
lockReason?: string;
stepIndex: number;
onGenerate: () => void;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
}) {
const [open, setOpen] = useState(kind === 'patent');
@@ -169,67 +167,32 @@ function PackSection({ kind, pack, isLoading, locked, lockReason, stepIndex, onG
const templates = PACK_TEMPLATES[kind];
const generatedCount = pack?.assets.length ?? 0;
const total = templates.length;
const progressPct = Math.round((generatedCount / total) * 100);
const complete = generatedCount >= total;
function handleGenerateClick() {
if (locked) return;
if (pack) {
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
if (!ok) return;
}
onGenerate();
}
return (
<section className={`card ${locked ? 'opacity-55' : ''}`} id={`pack-${kind}`} aria-disabled={locked}>
{/* header — always visible */}
<div className="p-4 flex items-center gap-3">
<div className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${locked ? 'bg-white/[0.06] text-white/28' : `bg-gradient-to-br ${accent.bar} text-white`} flex items-center justify-center text-[11px] font-bold shrink-0`}>
{stepIndex}
</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-sm font-semibold text-white">{PACK_LABELS[kind]}</span>
<span className="text-[10px] text-white/35">{PACK_DESCRIPTIONS[kind]}</span>
{complete && <span className="chip chip-live text-[10px] py-0"></span>}
{locked && <span className="chip chip-neutral text-[10px] py-0"></span>}
</div>
{/* 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>
<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">
<button
onClick={handleGenerateClick}
disabled={isLoading || locked}
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
title={locked ? lockReason : undefined}
>
{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>
) : locked ? '等待前一步' : 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>
<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>
{locked && lockReason && (
@@ -481,20 +444,12 @@ function previousPackLabel(kind: PackKind) {
export default function PackPanel({
session,
loadingKind,
characterLoading,
videoLoading,
onGenerate,
onLockCharacter,
onRegenerateAsset,
onGenerateVideo,
}: {
session: GenSession;
loadingKind: PackKind | null;
characterLoading: boolean;
videoLoading: boolean;
onGenerate: (image: GenImage, kind: PackKind) => void;
onLockCharacter: (image: GenImage) => void;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
}) {
@@ -525,89 +480,19 @@ export default function PackPanel({
);
}
const generatedTotal = packs.reduce((s, p) => s + p.assets.length, 0);
const completedPackCount = PACK_ORDER.filter(kind => isPackComplete(packForKind(packs, kind, primaryImage.id), kind)).length;
const imagePacksComplete = completedPackCount === PACK_ORDER.length;
return (
<div className="flex h-full min-h-0 flex-col gap-4">
{/* Step 03 header card */}
<section className="card shrink-0 space-y-4 p-4">
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
<div className="flex-1 min-w-0">
<span className="section-eyebrow">Step · 03 · Lock & Generate</span>
<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]">
</p>
</div>
{/* primary image + stats */}
<div className="flex items-center justify-between gap-4 shrink-0 2xl:justify-end">
<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-[8px] overflow-hidden ring-1 ring-white/15 shadow-glow-violet shrink-0">
<img src={primaryImage.url} alt="selected" className="w-full h-full object-contain bg-white" />
<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 className="flex h-full min-h-0 flex-col gap-3">
<section className="card shrink-0 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="section-eyebrow">Asset Details</span>
<h2 className="mt-1 text-sm font-semibold text-white"></h2>
</div>
<span className="shrink-0 text-[10px] text-white/35"></span>
</div>
{/* action buttons */}
<div className="grid grid-cols-1 gap-2 2xl:grid-cols-2">
<button
onClick={() => onLockCharacter(primaryImage)}
disabled={characterLoading || !!loadingKind}
className="btn btn-glass justify-center 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>
) : (
<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>
<div className="rounded-[8px] bg-white/[0.035] px-3 py-2 text-[11px] text-white/52 ring-1 ring-white/[0.07]">
{completedPackCount}/{PACK_ORDER.length}
</div>
</div>
{/* CharacterSpec */}
{session.characterSpec && (
<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 text-[10px] shrink-0">CharacterSpec</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('、')],
['L1', session.characterSpec.cleanReferenceImageUrl ? '白底锚图已生成' : '未生成'],
].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>
@@ -629,11 +514,9 @@ export default function PackPanel({
key={kind}
kind={kind}
pack={pack}
isLoading={loadingKind === kind}
locked={locked}
lockReason={lockReason}
stepIndex={index + 1}
onGenerate={() => onGenerate(primaryImage, kind)}
onRegenerateAsset={onRegenerateAsset}
/>
);