fix: merge pack progress into project brief
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,4 +21,5 @@ __pycache__/
|
|||||||
|
|
||||||
# UI 验证产物(Playwright MCP / 手动截图),不入库
|
# UI 验证产物(Playwright MCP / 手动截图),不入库
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.playwright-cli/
|
||||||
ui-*.png
|
ui-*.png
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ input, textarea {
|
|||||||
|
|
||||||
.project-board-grid {
|
.project-board-grid {
|
||||||
display: 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;
|
gap: 14px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -606,6 +606,112 @@ input, textarea {
|
|||||||
padding: 13px;
|
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 {
|
.project-spec-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 42px minmax(0, 1fr);
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
@@ -739,7 +845,7 @@ input, textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-board-grid {
|
.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;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
163
src/app/page.tsx
163
src/app/page.tsx
@@ -7,7 +7,7 @@ import Sidebar from '@/components/Sidebar';
|
|||||||
import PackPanel from '@/components/PackPanel';
|
import PackPanel from '@/components/PackPanel';
|
||||||
import ProjectGalleryDrawer from '@/components/ProjectGalleryDrawer';
|
import ProjectGalleryDrawer from '@/components/ProjectGalleryDrawer';
|
||||||
import { OasisCanvas } from '@/components/login/OasisCanvas';
|
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 {
|
import type {
|
||||||
GenImage,
|
GenImage,
|
||||||
GenSession,
|
GenSession,
|
||||||
@@ -51,6 +51,13 @@ function packSlotTotal() {
|
|||||||
return PACK_ORDER.reduce((sum, kind) => sum + PACK_TEMPLATES[kind].length, 0);
|
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' }) {
|
function ProjectStat({ label, value, tone }: { label: string; value: string | number; tone?: 'accent' | 'soft' }) {
|
||||||
return (
|
return (
|
||||||
<div className={`project-stat ${tone === 'accent' ? 'project-stat--accent' : ''}`}>
|
<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 selectedImages = session.images.filter(image => image.status === 'selected');
|
||||||
const primaryImage = selectedImages[0] ?? session.images[0] ?? null;
|
const primaryImage = selectedImages[0] ?? session.images[0] ?? null;
|
||||||
const generatedAssets = (session.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0);
|
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 || '当前项目暂无描述'}
|
{session.characterSpec?.oneLiner || session.prompt || '当前项目暂无描述'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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)}
|
{modeLabel(session.inputMode)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,11 +269,28 @@ function ProjectBrief({ session }: { session: GenSession }) {
|
|||||||
<ProjectStat label="产出" value={`${generatedAssets}/${totalSlots}`} />
|
<ProjectStat label="产出" value={`${generatedAssets}/${totalSlots}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProjectPackOverview
|
||||||
|
session={session}
|
||||||
|
primaryImage={primaryImage}
|
||||||
|
loadingKind={loadingKind}
|
||||||
|
onGeneratePack={onGeneratePack}
|
||||||
|
/>
|
||||||
|
|
||||||
{session.characterSpec ? (
|
{session.characterSpec ? (
|
||||||
<div className="project-spec-card mt-5">
|
<div className="project-spec-card mt-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-[11px] font-semibold text-white/80">角色设定</span>
|
<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>
|
||||||
<div className="mt-3 space-y-2 text-[11px]">
|
<div className="mt-3 space-y-2 text-[11px]">
|
||||||
<ProjectSpecField label="形态" value={session.characterSpec.speciesShape} />
|
<ProjectSpecField label="形态" value={session.characterSpec.speciesShape} />
|
||||||
@@ -174,7 +305,17 @@ function ProjectBrief({ session }: { session: GenSession }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="project-spec-card mt-5">
|
<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 className="mt-2 text-[11px] leading-relaxed text-white/45">
|
||||||
从右侧图库选中主方案后,在生产矩阵里锁定角色设定。
|
从右侧图库选中主方案后,在生产矩阵里锁定角色设定。
|
||||||
</p>
|
</p>
|
||||||
@@ -487,15 +628,17 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="project-board-grid">
|
<div className="project-board-grid">
|
||||||
<ProjectBrief session={current} />
|
<ProjectBrief
|
||||||
|
session={current}
|
||||||
|
loadingKind={loadingKind}
|
||||||
|
characterLoading={characterLoading}
|
||||||
|
onGeneratePack={handleGeneratePack}
|
||||||
|
onLockCharacter={handleLockCharacter}
|
||||||
|
/>
|
||||||
<section className="project-production-panel">
|
<section className="project-production-panel">
|
||||||
<PackPanel
|
<PackPanel
|
||||||
session={current}
|
session={current}
|
||||||
loadingKind={loadingKind}
|
|
||||||
characterLoading={characterLoading}
|
|
||||||
videoLoading={videoLoading}
|
videoLoading={videoLoading}
|
||||||
onGenerate={handleGeneratePack}
|
|
||||||
onLockCharacter={handleLockCharacter}
|
|
||||||
onRegenerateAsset={handleRegenerateAsset}
|
onRegenerateAsset={handleRegenerateAsset}
|
||||||
onGenerateVideo={handleGenerateVideo}
|
onGenerateVideo={handleGenerateVideo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -154,14 +154,12 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Pack Section (collapsible) ───────────────── */
|
/* ── Pack Section (collapsible) ───────────────── */
|
||||||
function PackSection({ kind, pack, isLoading, locked, lockReason, stepIndex, onGenerate, onRegenerateAsset }: {
|
function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: {
|
||||||
kind: PackKind;
|
kind: PackKind;
|
||||||
pack: AssetPack | undefined;
|
pack: AssetPack | undefined;
|
||||||
isLoading: boolean;
|
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
lockReason?: string;
|
lockReason?: string;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
onGenerate: () => void;
|
|
||||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(kind === 'patent');
|
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 templates = PACK_TEMPLATES[kind];
|
||||||
const generatedCount = pack?.assets.length ?? 0;
|
const generatedCount = pack?.assets.length ?? 0;
|
||||||
const total = templates.length;
|
const total = templates.length;
|
||||||
const progressPct = Math.round((generatedCount / total) * 100);
|
|
||||||
const complete = generatedCount >= total;
|
const complete = generatedCount >= total;
|
||||||
|
|
||||||
function handleGenerateClick() {
|
|
||||||
if (locked) return;
|
|
||||||
if (pack) {
|
|
||||||
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
onGenerate();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`card ${locked ? 'opacity-55' : ''}`} id={`pack-${kind}`} aria-disabled={locked}>
|
<section className={`card ${locked ? 'opacity-55' : ''}`} id={`pack-${kind}`} aria-disabled={locked}>
|
||||||
{/* header — always visible */}
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<div className="p-4 flex items-center gap-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`}>
|
<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}
|
{stepIndex}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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>
|
<span className="text-[10px] text-white/35">{PACK_DESCRIPTIONS[kind]}</span>
|
||||||
{complete && <span className="chip chip-live text-[10px] py-0">完成</span>}
|
{complete && <span className="chip chip-live text-[10px] py-0">完成</span>}
|
||||||
{locked && <span className="chip chip-neutral text-[10px] py-0">锁定</span>}
|
{locked && <span className="chip chip-neutral text-[10px] py-0">锁定</span>}
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{locked && lockReason && (
|
{locked && lockReason && (
|
||||||
@@ -481,20 +444,12 @@ function previousPackLabel(kind: PackKind) {
|
|||||||
|
|
||||||
export default function PackPanel({
|
export default function PackPanel({
|
||||||
session,
|
session,
|
||||||
loadingKind,
|
|
||||||
characterLoading,
|
|
||||||
videoLoading,
|
videoLoading,
|
||||||
onGenerate,
|
|
||||||
onLockCharacter,
|
|
||||||
onRegenerateAsset,
|
onRegenerateAsset,
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
}: {
|
}: {
|
||||||
session: GenSession;
|
session: GenSession;
|
||||||
loadingKind: PackKind | null;
|
|
||||||
characterLoading: boolean;
|
|
||||||
videoLoading: boolean;
|
videoLoading: boolean;
|
||||||
onGenerate: (image: GenImage, kind: PackKind) => void;
|
|
||||||
onLockCharacter: (image: GenImage) => void;
|
|
||||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => 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 completedPackCount = PACK_ORDER.filter(kind => isPackComplete(packForKind(packs, kind, primaryImage.id), kind)).length;
|
||||||
const imagePacksComplete = completedPackCount === PACK_ORDER.length;
|
const imagePacksComplete = completedPackCount === PACK_ORDER.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
{/* Step 03 header card */}
|
<section className="card shrink-0 p-3">
|
||||||
<section className="card shrink-0 space-y-4 p-4">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
|
<div className="min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<span className="section-eyebrow">Asset Details</span>
|
||||||
<span className="section-eyebrow">Step · 03 · Lock & Generate</span>
|
<h2 className="mt-1 text-sm font-semibold text-white">生成明细</h2>
|
||||||
<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>
|
</div>
|
||||||
|
<span className="shrink-0 text-[10px] text-white/35">查看与单张重做</span>
|
||||||
</div>
|
</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} />
|
<SectionNav active={activeNav} onChange={setActiveNav} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -629,11 +514,9 @@ export default function PackPanel({
|
|||||||
key={kind}
|
key={kind}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
pack={pack}
|
pack={pack}
|
||||||
isLoading={loadingKind === kind}
|
|
||||||
locked={locked}
|
locked={locked}
|
||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
stepIndex={index + 1}
|
stepIndex={index + 1}
|
||||||
onGenerate={() => onGenerate(primaryImage, kind)}
|
|
||||||
onRegenerateAsset={onRegenerateAsset}
|
onRegenerateAsset={onRegenerateAsset}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user