Files
makelore/src/pages/ImageCanvas/YouthCreationCard.tsx
brother7 945b40de11
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat(design): integrate conversation-first creation flow
2026-09-04 07:39:58 +08:00

471 lines
18 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.

import {
Ban,
Check,
ChevronDown,
Film,
ImageIcon,
Lightbulb,
ListChecks,
Loader2,
MessageCircle,
Palette,
Play,
SlidersHorizontal,
Sparkles,
WandSparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignCompilationIssue,
DesignWorkspace,
} from '../../../shared/image-workspace';
import {
projectYouthCreationCard,
type YouthCreationCardModel,
} from './youth-form-projection';
export type YouthCreationCardProps = {
workspace: DesignWorkspace;
quoteBlockers: DesignCompilationIssue[];
generationAvailable: boolean;
onOpenFineTune: () => void;
onFocusComposer: () => void;
};
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds)) return `${seconds} 秒`;
if (Number.isInteger(seconds)) return `${seconds} 秒`;
return `${seconds.toFixed(1)} 秒`;
}
function MediumIcon({ medium }: { medium: 'image' | 'video' }) {
return medium === 'video'
? <Film className="h-3.5 w-3.5" aria-hidden="true" />
: <ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />;
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs font-medium text-foreground">
{children}
</span>
);
}
function TagSection({
title,
items,
icon,
tone = 'default',
}: {
title: string;
items: string[];
icon: React.ReactNode;
tone?: 'default' | 'avoid';
}) {
if (items.length === 0) return null;
return (
<section>
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<span className={cn('text-brand', tone === 'avoid' && 'text-rose-600')}>
{icon}
</span>
<h3>{title}</h3>
</div>
<ul className="mt-2 flex flex-wrap gap-2">
{items.map((item, index) => (
<li
key={`${item}-${index}`}
className={cn(
'rounded-xl border px-2.5 py-1.5 text-xs leading-5',
tone === 'avoid'
? 'border-rose-200/80 bg-rose-50/70 text-rose-900'
: 'border-border/70 bg-background text-foreground',
)}
>
{item}
</li>
))}
</ul>
</section>
);
}
function Readiness({ model }: { model: YouthCreationCardModel }) {
return (
<section
data-testid="youth-creation-readiness"
className={cn(
'rounded-xl border px-3.5 py-3',
model.phase === 'ready' || model.phase === 'result' || model.phase === 'confirming'
? 'border-brand/20 bg-brand/[0.045]'
: model.phase === 'generating'
? 'border-sky-200 bg-sky-50/70'
: 'border-border/70 bg-background',
)}
>
<div className="flex items-start gap-2.5">
<span className="mt-0.5 text-brand">
{model.phase === 'generating'
? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
: model.phase === 'ready' || model.phase === 'result' || model.phase === 'confirming'
? <Check className="h-4 w-4" aria-hidden="true" />
: <Lightbulb className="h-4 w-4" aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{model.readinessMessage}</p>
{model.blockers.length > 0 && (
<ul className="mt-2 space-y-1 text-xs leading-5 text-muted-foreground">
{model.blockers.map((blocker) => (
<li key={`${blocker.code}-${blocker.path ?? ''}`}>{blocker.message}</li>
))}
</ul>
)}
</div>
</div>
</section>
);
}
function ReferenceSection({ model }: { model: YouthCreationCardModel }) {
if (model.references.length === 0) return null;
return (
<section>
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<ImageIcon className="h-3.5 w-3.5 text-brand" aria-hidden="true" />
<h3>参考图片</h3>
</div>
<div className="mt-2 space-y-2">
{model.references.map((reference, index) => (
<article key={reference.id} className="rounded-xl border border-border/70 bg-background p-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.08] text-brand">
<ImageIcon className="h-4 w-4" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">参考图 {index + 1}</p>
<p className="text-[11px] text-muted-foreground">
{reference.asset
? `${reference.asset.width} × ${reference.asset.height}`
: '图片正在同步'}
</p>
</div>
</div>
{(reference.preserve.length > 0
|| reference.adapt.length > 0
|| reference.doNotCopy.length > 0) && (
<div className="mt-2.5 grid gap-2 text-xs leading-5 sm:grid-cols-3">
{reference.preserve.length > 0 && (
<div>
<p className="text-muted-foreground">最想保留</p>
<p className="mt-0.5 text-foreground">{reference.preserve.join('、')}</p>
</div>
)}
{reference.adapt.length > 0 && (
<div>
<p className="text-muted-foreground">可以改变</p>
<p className="mt-0.5 text-foreground">{reference.adapt.join('、')}</p>
</div>
)}
{reference.doNotCopy.length > 0 && (
<div>
<p className="text-muted-foreground">不要照着画</p>
<p className="mt-0.5 text-foreground">{reference.doNotCopy.join('、')}</p>
</div>
)}
</div>
)}
</article>
))}
</div>
</section>
);
}
function VideoSection({ model }: { model: YouthCreationCardModel }) {
if (!model.video) return null;
const { durationSeconds, pacing, endState, storyBeats } = model.video;
return (
<section>
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Film className="h-3.5 w-3.5 text-brand" aria-hidden="true" />
<h3>视频安排</h3>
</div>
<div className="mt-2 grid gap-2 rounded-xl border border-border/70 bg-background p-3 text-xs sm:grid-cols-3">
{durationSeconds !== null && (
<div>
<p className="text-muted-foreground">时长</p>
<p className="mt-0.5 font-medium text-foreground">{formatDuration(durationSeconds)}</p>
</div>
)}
{pacing && (
<div>
<p className="text-muted-foreground">节奏</p>
<p className="mt-0.5 font-medium text-foreground">{pacing}</p>
</div>
)}
{endState && (
<div>
<p className="text-muted-foreground">结尾</p>
<p className="mt-0.5 font-medium text-foreground">{endState}</p>
</div>
)}
{storyBeats.length > 0 && (
<div className="sm:col-span-3">
<p className="text-muted-foreground">画面顺序</p>
<ol className="mt-1 space-y-1 text-foreground">
{storyBeats.map((beat, index) => <li key={`${beat}-${index}`}>{index + 1}. {beat}</li>)}
</ol>
</div>
)}
</div>
</section>
);
}
function ActionFooter({
model,
generationAvailable,
requesting,
onFocusComposer,
onOpenFineTune,
onRequestQuote,
}: {
model: YouthCreationCardModel;
generationAvailable: boolean;
requesting: boolean;
onFocusComposer: () => void;
onOpenFineTune: () => void;
onRequestQuote: () => void;
}) {
switch (model.phase) {
case 'empty':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground">先在对话里说说脑中的画面,不用一次想完整。</p>
<Button type="button" className="min-h-11 rounded-xl" onClick={onFocusComposer}>
<MessageCircle className="mr-2 h-4 w-4" aria-hidden="true" />
开始描述
</Button>
</div>
);
case 'exploring':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground">继续描述你想看到的画面,AI 会接着问一个关键问题。</p>
<Button type="button" className="min-h-11 rounded-xl" onClick={onFocusComposer}>
<MessageCircle className="mr-2 h-4 w-4" aria-hidden="true" />
回到对话
</Button>
</div>
);
case 'ready':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground">
{generationAvailable ? '先看看制作方案,再决定要不要开始。' : '现在还不能开始制作,但你可以继续完善想法。'}
</p>
<Button
type="button"
className="min-h-11 rounded-xl"
disabled={requesting}
onClick={generationAvailable ? onRequestQuote : onFocusComposer}
>
{requesting
? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
: <WandSparkles className="mr-2 h-4 w-4" aria-hidden="true" />}
{generationAvailable ? '看看制作方案' : '继续完善想法'}
</Button>
</div>
);
case 'confirming':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground">方案准备好了,确认后就开始制作。</p>
<Button type="button" variant="outline" className="min-h-11 rounded-xl" onClick={onOpenFineTune}>
<SlidersHorizontal className="mr-2 h-4 w-4" aria-hidden="true" />
继续调整
</Button>
</div>
);
case 'generating':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground">作品做好后会出现在下面,先去做点别的吧。</p>
<Button type="button" className="min-h-11 rounded-xl" disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
正在制作
</Button>
</div>
);
case 'result':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground">喜欢这个方向?可以再做一个版本。</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" className="min-h-11 rounded-xl" onClick={onOpenFineTune}>
<SlidersHorizontal className="mr-2 h-4 w-4" aria-hidden="true" />
继续调整
</Button>
<Button
type="button"
className="min-h-11 rounded-xl"
disabled={requesting || !generationAvailable || !model.canRequestQuote}
onClick={onRequestQuote}
>
{requesting
? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
: <Play className="mr-2 h-4 w-4" aria-hidden="true" />}
{model.canRequestQuote ? '做一个新版本' : '制作方案已准备'}
</Button>
</div>
</div>
);
default:
return null;
}
}
export function YouthCreationCard({
workspace,
quoteBlockers,
generationAvailable,
onOpenFineTune,
onFocusComposer,
}: YouthCreationCardProps) {
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const model = projectYouthCreationCard(workspace, { quoteBlockers });
const requesting = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
);
const request = () => {
if (!generationAvailable || requesting || !model.canRequestQuote) return;
void requestQuote().catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
};
const hasMoreDetails = model.mustInclude.length > 0
|| model.mustPreserve.length > 0
|| model.mustAvoid.length > 0
|| model.references.length > 0
|| model.video !== null;
return (
<section
data-testid="youth-creation-card"
aria-labelledby="youth-creation-card-title"
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm sm:p-5"
>
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
<h2 id="youth-creation-card-title" className="text-base font-semibold text-foreground">
AI 听懂的想法
</h2>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
会跟着聊天自动更新,不需要逐项填写。
</p>
</div>
<Button
type="button"
variant="ghost"
aria-label="手动调整(可选)"
className="min-h-11 shrink-0 rounded-xl px-2.5 text-xs"
onClick={onOpenFineTune}
>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
手动调整
</Button>
</header>
<div className="mt-4 space-y-4">
<Readiness model={model} />
{model.summary && (
<section>
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Lightbulb className="h-3.5 w-3.5 text-brand" aria-hidden="true" />
<h3>目前听懂的是</h3>
</div>
<p className="mt-2 rounded-xl bg-surface-subtle/75 px-3 py-2.5 text-sm leading-6 text-foreground">
{model.summary}
</p>
</section>
)}
{(model.medium || model.destination || model.style || model.delegatedSummary) && (
<div className="flex flex-wrap gap-2" aria-label="创作选择">
{model.medium && (
<Chip>
<MediumIcon medium={model.medium} />
{model.medium === 'video' ? '视频' : '图片'}
</Chip>
)}
{model.destination && (
<>
<Chip>{model.destination.label}</Chip>
<Chip>{model.destination.aspectRatio}</Chip>
</>
)}
{model.style && (
<Chip>
<Palette className="h-3.5 w-3.5 text-brand" aria-hidden="true" />
{model.style.label}
</Chip>
)}
{model.delegatedSummary && <Chip>{model.delegatedSummary}</Chip>}
</div>
)}
{hasMoreDetails && (
<details
data-testid="youth-creation-details"
className="group rounded-xl border border-border/65 bg-surface-subtle/35"
>
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-3 text-xs font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 [&::-webkit-details-marker]:hidden">
<span>查看画面细节 <span className="font-normal text-muted-foreground">(可选)</span></span>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="space-y-4 border-t border-border/60 px-3 py-4">
<TagSection
title="一定要有"
items={model.mustInclude}
icon={<ListChecks className="h-3.5 w-3.5" aria-hidden="true" />}
/>
<TagSection
title="想保留"
items={model.mustPreserve}
icon={<Check className="h-3.5 w-3.5" aria-hidden="true" />}
/>
<TagSection
title="不要出现"
items={model.mustAvoid}
icon={<Ban className="h-3.5 w-3.5" aria-hidden="true" />}
tone="avoid"
/>
<ReferenceSection model={model} />
<VideoSection model={model} />
</div>
</details>
)}
<ActionFooter
model={model}
generationAvailable={generationAvailable}
requesting={requesting}
onFocusComposer={onFocusComposer}
onOpenFineTune={onOpenFineTune}
onRequestQuote={request}
/>
</div>
</section>
);
}