feat: simplify AI design for young creators
This commit is contained in:
1 parent
7552cf5952
commit
756638a28a
17 files changed
+3595
-297
No files matched your search
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
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';
|
||||
import { CreativeChoiceGrid } from './CreativeChoiceGrid';
|
||||
|
||||
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 resolveDecisionPrompt = useImageWorkspaceStore((state) => state.resolveDecisionPrompt);
|
||||
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const model = projectYouthCreationCard(workspace, { quoteBlockers });
|
||||
const submittingInput = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
|
||||
);
|
||||
const requesting = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
|
||||
);
|
||||
|
||||
const choose = (optionId: string) => {
|
||||
if (!model.activeChoice || submittingInput) return;
|
||||
void resolveDecisionPrompt(model.activeChoice.id, 'select', optionId).catch(() => {
|
||||
toast.error('这个选择没有保存,请再试一次');
|
||||
});
|
||||
};
|
||||
|
||||
const rejectChoice = () => {
|
||||
if (!model.activeChoice || submittingInput) return;
|
||||
void resolveDecisionPrompt(model.activeChoice.id, 'reject').catch(() => {
|
||||
toast.error('这个选择没有保存,请再试一次');
|
||||
});
|
||||
};
|
||||
|
||||
const request = () => {
|
||||
if (!generationAvailable || requesting || !model.canRequestQuote) return;
|
||||
void requestQuote().catch(() => {
|
||||
toast.error('制作方案还没准备好,请稍后再试');
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
我的创作
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="space-y-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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReferenceSection model={model} />
|
||||
<VideoSection model={model} />
|
||||
|
||||
{model.activeChoice && (
|
||||
<CreativeChoiceGrid
|
||||
choice={model.activeChoice}
|
||||
disabled={submittingInput}
|
||||
onSelect={choose}
|
||||
onReject={rejectChoice}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionFooter
|
||||
model={model}
|
||||
generationAvailable={generationAvailable}
|
||||
requesting={requesting}
|
||||
onFocusComposer={onFocusComposer}
|
||||
onOpenFineTune={onOpenFineTune}
|
||||
onRequestQuote={request}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user