696 lines
28 KiB
TypeScript
696 lines
28 KiB
TypeScript
import {
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import {
|
||
Check,
|
||
ImagePlus,
|
||
Loader2,
|
||
Minus,
|
||
Plus,
|
||
RefreshCw,
|
||
Sparkles,
|
||
Trash2,
|
||
WandSparkles,
|
||
} from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Select } from '@/components/ui/select';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { uploadImageWorkspaceAsset } from '@/lib/image-workspace';
|
||
import { cn } from '@/lib/utils';
|
||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||
import type {
|
||
DesignCompilationIssue,
|
||
DesignSpecificationValues,
|
||
DesignUserFieldOperation,
|
||
DesignWorkspace,
|
||
} from '../../../shared/image-workspace';
|
||
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
|
||
import { hasActiveCreationPlan } from './plan-lifecycle';
|
||
import {
|
||
findUnboundReferenceNumbers,
|
||
findReferenceMentionRange,
|
||
insertReferenceToken,
|
||
promptReferencesImage,
|
||
referenceToken,
|
||
removeReferenceTokenAndShift,
|
||
} from './reference-tokens';
|
||
import { youthCompilationIssueMessage } from './youth-issue-copy';
|
||
|
||
type ReferenceValue = DesignSpecificationValues['references'][number];
|
||
type SaveState = 'settled' | 'saving' | 'repricing' | 'error';
|
||
|
||
export type YouthCreationCardProps = {
|
||
workspace: DesignWorkspace;
|
||
quoteBlockers: DesignCompilationIssue[];
|
||
generationAvailable: boolean;
|
||
onOpenFineTune?: () => void;
|
||
onFocusComposer?: () => void;
|
||
onQuoteOffered?: () => void;
|
||
};
|
||
|
||
const ASPECT_RATIOS = [
|
||
{ value: '1:1', label: '1:1', orientation: 'square' },
|
||
{ value: '3:4', label: '3:4', orientation: 'portrait' },
|
||
{ value: '9:16', label: '9:16', orientation: 'portrait' },
|
||
{ value: '4:3', label: '4:3', orientation: 'landscape' },
|
||
{ value: '16:9', label: '16:9', orientation: 'landscape' },
|
||
] as const;
|
||
|
||
function createReferenceId(): string {
|
||
const random = globalThis.crypto?.randomUUID?.()
|
||
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
return `reference-${random}`.slice(0, 64);
|
||
}
|
||
|
||
function canonicalCreatorPrompt(workspace: DesignWorkspace): string {
|
||
const values = workspace.form.specification.values;
|
||
return values.content.concept?.trim()
|
||
|| values.content.narrative?.trim()
|
||
|| '';
|
||
}
|
||
|
||
function Stepper({
|
||
label,
|
||
value,
|
||
suffix,
|
||
minimum,
|
||
maximum,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
suffix?: string;
|
||
minimum: number;
|
||
maximum: number;
|
||
disabled: boolean;
|
||
onChange: (value: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||
<span className="inline-flex h-8 items-center overflow-hidden rounded-lg border border-border/80 bg-background">
|
||
<button
|
||
type="button"
|
||
aria-label={`减少${label}`}
|
||
className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35"
|
||
disabled={disabled || value <= minimum}
|
||
onClick={() => onChange(Math.max(minimum, value - 1))}
|
||
>
|
||
<Minus className="h-3.5 w-3.5" />
|
||
</button>
|
||
<span className="min-w-11 border-x border-border/70 px-2 text-center text-xs tabular-nums text-foreground">
|
||
{value}{suffix ? ` ${suffix}` : ''}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`增加${label}`}
|
||
className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35"
|
||
disabled={disabled || value >= maximum}
|
||
onClick={() => onChange(Math.min(maximum, value + 1))}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function YouthCreationCard({
|
||
workspace,
|
||
quoteBlockers,
|
||
generationAvailable,
|
||
onQuoteOffered,
|
||
}: YouthCreationCardProps) {
|
||
const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations);
|
||
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
|
||
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
|
||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||
const values = workspace.form.specification.values;
|
||
const canonicalPrompt = canonicalCreatorPrompt(workspace);
|
||
const offeredQuote = workspace.form.activeQuotes.find((quote) => quote.status === 'offered') ?? null;
|
||
const [prompt, setPrompt] = useState(canonicalPrompt);
|
||
const [promptDirty, setPromptDirty] = useState(false);
|
||
const [saveState, setSaveState] = useState<SaveState>('settled');
|
||
const [localBusy, setLocalBusy] = useState(false);
|
||
const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null);
|
||
const [replaceIndex, setReplaceIndex] = useState<number | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [medium, setMedium] = useState(values.intent.media ?? 'image');
|
||
const [aspectRatio, setAspectRatio] = useState(values.output.aspect_ratio ?? '1:1');
|
||
const [duration, setDuration] = useState(values.video.total_duration_seconds ?? 6);
|
||
const [variantCount, setVariantCount] = useState(values.output.variant_count ?? 1);
|
||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
setPrompt(canonicalPrompt);
|
||
setPromptDirty(false);
|
||
setMentionRange(null);
|
||
}, [canonicalPrompt, workspace.workspace.workspaceId]);
|
||
|
||
useEffect(() => {
|
||
setMedium(values.intent.media ?? 'image');
|
||
setAspectRatio(values.output.aspect_ratio ?? '1:1');
|
||
setDuration(values.video.total_duration_seconds ?? 6);
|
||
setVariantCount(values.output.variant_count ?? 1);
|
||
}, [
|
||
values.intent.media,
|
||
values.output.aspect_ratio,
|
||
values.output.variant_count,
|
||
values.video.total_duration_seconds,
|
||
workspace.workspace.workspaceId,
|
||
]);
|
||
|
||
const references = values.references;
|
||
const unboundReferenceNumbers = findUnboundReferenceNumbers(prompt, references.length);
|
||
const submitting = Object.values(pendingOperations).some((operation) => (
|
||
operation.command.workspaceId === workspace.workspace.workspaceId
|
||
));
|
||
const confirming = Object.values(pendingOperations).some((operation) => (
|
||
operation.status === 'submitting'
|
||
&& operation.command.kind === 'confirm_generation'
|
||
&& operation.command.workspaceId === workspace.workspace.workspaceId
|
||
));
|
||
const busy = localBusy || submitting || uploading;
|
||
const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker');
|
||
const canPrepareQuote = generationAvailable
|
||
&& prompt.trim().length > 0
|
||
&& Boolean(medium)
|
||
&& Boolean(aspectRatio)
|
||
&& unboundReferenceNumbers.length === 0
|
||
&& blockers.length === 0;
|
||
|
||
const setPromptAndCursor = (value: string, cursor: number) => {
|
||
setPrompt(value);
|
||
setPromptDirty(value.trim() !== canonicalPrompt);
|
||
setMentionRange(null);
|
||
globalThis.requestAnimationFrame?.(() => {
|
||
promptRef.current?.focus();
|
||
promptRef.current?.setSelectionRange(cursor, cursor);
|
||
});
|
||
};
|
||
|
||
const runOperations = async (
|
||
operations: DesignUserFieldOperation[],
|
||
options: { ensureQuote?: boolean; nextPrompt?: string } = {},
|
||
) => {
|
||
if (busy || operations.length === 0) return null;
|
||
const shouldRefreshQuote = options.ensureQuote === true || Boolean(offeredQuote);
|
||
setLocalBusy(true);
|
||
setSaveState('saving');
|
||
let saved = false;
|
||
try {
|
||
const updated = await applyFieldOperations(operations);
|
||
saved = true;
|
||
if (options.nextPrompt !== undefined) {
|
||
setPrompt(options.nextPrompt);
|
||
setPromptDirty(false);
|
||
}
|
||
if (shouldRefreshQuote) {
|
||
setSaveState('repricing');
|
||
const quoted = await requestQuote();
|
||
onQuoteOffered?.();
|
||
setSaveState('settled');
|
||
return quoted;
|
||
}
|
||
setSaveState('settled');
|
||
return updated;
|
||
} catch {
|
||
setSaveState('error');
|
||
toast.error(saved
|
||
? '修改已保存,但制作方案暂时没有重新核价'
|
||
: '修改没有保存,请再试一次');
|
||
return null;
|
||
} finally {
|
||
setLocalBusy(false);
|
||
}
|
||
};
|
||
|
||
const savePrompt = async (ensureQuote = false) => {
|
||
if (!promptDirty && !ensureQuote) return;
|
||
const nextPrompt = prompt.trim();
|
||
const operations: DesignUserFieldOperation[] = nextPrompt
|
||
? [{ kind: 'set', path: 'content.concept', value: nextPrompt }]
|
||
: [{ kind: 'clear', path: 'content.concept', resolution: 'open' }];
|
||
if (!promptDirty && ensureQuote) {
|
||
setLocalBusy(true);
|
||
setSaveState('repricing');
|
||
try {
|
||
await requestQuote();
|
||
onQuoteOffered?.();
|
||
setSaveState('settled');
|
||
} catch {
|
||
setSaveState('error');
|
||
toast.error('制作方案还没准备好,请稍后再试');
|
||
} finally {
|
||
setLocalBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
await runOperations(operations, { ensureQuote, nextPrompt });
|
||
};
|
||
|
||
const changeParameter = (
|
||
operations: DesignUserFieldOperation[],
|
||
optimisticUpdate: () => void,
|
||
) => {
|
||
if (busy) return;
|
||
optimisticUpdate();
|
||
void runOperations(operations);
|
||
};
|
||
|
||
const uploadReference = async (file: File) => {
|
||
if (busy) return;
|
||
setUploading(true);
|
||
try {
|
||
const asset = await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file);
|
||
const replacing = replaceIndex;
|
||
const nextReferences: ReferenceValue[] = replacing === null
|
||
? [...references, {
|
||
id: createReferenceId(),
|
||
asset_id: asset.assetId,
|
||
asset_revision: null,
|
||
role: 'inspiration',
|
||
preserve: [],
|
||
adapt: [],
|
||
do_not_copy: [],
|
||
reviewed_observations: [],
|
||
}]
|
||
: references.map((reference, index) => (
|
||
index === replacing ? { ...reference, asset_id: asset.assetId, asset_revision: null } : reference
|
||
));
|
||
const inserted = replacing === null
|
||
? promptReferencesImage(prompt, nextReferences.length - 1)
|
||
? { value: prompt, cursor: prompt.length }
|
||
: insertReferenceToken(prompt, nextReferences.length - 1, mentionRange)
|
||
: { value: prompt, cursor: prompt.length };
|
||
const operations: DesignUserFieldOperation[] = [
|
||
{ kind: 'set', path: 'references', value: nextReferences },
|
||
];
|
||
if (inserted.value.trim() !== canonicalPrompt) {
|
||
operations.push({ kind: 'set', path: 'content.concept', value: inserted.value.trim() });
|
||
}
|
||
setUploading(false);
|
||
await runOperations(operations, { nextPrompt: inserted.value.trim() });
|
||
setMentionRange(null);
|
||
toast.success(replacing === null ? '参考图已添加并写入提示词' : '参考图已替换');
|
||
} catch {
|
||
toast.error('参考图没有上传成功,请再试一次');
|
||
} finally {
|
||
setUploading(false);
|
||
setReplaceIndex(null);
|
||
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const removeReference = (index: number) => {
|
||
if (busy) return;
|
||
const nextReferences = references.filter((_, itemIndex) => itemIndex !== index);
|
||
const nextPrompt = removeReferenceTokenAndShift(prompt, index);
|
||
const operations: DesignUserFieldOperation[] = [
|
||
nextReferences.length > 0
|
||
? { kind: 'set', path: 'references', value: nextReferences }
|
||
: { kind: 'clear', path: 'references' },
|
||
nextPrompt
|
||
? { kind: 'set', path: 'content.concept', value: nextPrompt }
|
||
: { kind: 'clear', path: 'content.concept', resolution: 'open' },
|
||
];
|
||
void runOperations(operations, { nextPrompt });
|
||
};
|
||
|
||
const insertExistingReference = (index: number) => {
|
||
if (!mentionRange && promptReferencesImage(prompt, index)) {
|
||
promptRef.current?.focus();
|
||
return;
|
||
}
|
||
const inserted = insertReferenceToken(prompt, index, mentionRange);
|
||
setPromptAndCursor(inserted.value, inserted.cursor);
|
||
};
|
||
|
||
const status = useMemo(() => {
|
||
if (saveState === 'saving') return { text: '正在保存方案', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
|
||
if (saveState === 'repricing') return { text: '正在重新核价', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
|
||
if (saveState === 'error') return { text: '方案需要重试', icon: <RefreshCw className="h-4 w-4" /> };
|
||
if (promptDirty) return { text: '提示词尚未保存', icon: <Sparkles className="h-4 w-4" /> };
|
||
return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: <Check className="h-4 w-4" /> };
|
||
}, [offeredQuote, promptDirty, saveState]);
|
||
|
||
if (!hasActiveCreationPlan(workspace)) return null;
|
||
|
||
return (
|
||
<section
|
||
data-testid="youth-creation-card"
|
||
aria-labelledby="youth-creation-card-title"
|
||
className="overflow-hidden rounded-2xl border border-border/75 bg-background shadow-sm"
|
||
>
|
||
<header className="flex items-start justify-between gap-3 border-b border-border/60 px-4 py-3.5 sm:px-5">
|
||
<div>
|
||
<h2 id="youth-creation-card-title" className="text-base font-semibold text-foreground">制作方案</h2>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">确认前可以直接修改最终内容</p>
|
||
</div>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand/[0.055] px-2.5 py-1 text-[11px] font-medium text-brand">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
AI 已根据对话整理
|
||
</span>
|
||
</header>
|
||
|
||
<div className="space-y-3 px-4 py-4 sm:px-5">
|
||
<section className="rounded-xl border border-border/70 bg-surface-subtle/25 p-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<label htmlFor="creator-final-prompt" className="text-xs font-semibold text-foreground">创作提示词</label>
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background px-2.5 text-xs font-medium text-foreground hover:border-brand/25 hover:text-brand"
|
||
disabled={busy}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => {
|
||
setReplaceIndex(null);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
<ImagePlus className="h-3.5 w-3.5" />
|
||
添加参考图
|
||
</button>
|
||
</div>
|
||
<div className="relative mt-2">
|
||
<Textarea
|
||
ref={promptRef}
|
||
id="creator-final-prompt"
|
||
aria-label="创作提示词"
|
||
value={prompt}
|
||
maxLength={1000}
|
||
rows={4}
|
||
disabled={busy}
|
||
placeholder="AI 会根据前面的对话整理成最终创作提示词,你也可以直接修改。"
|
||
className="min-h-[104px] resize-y bg-background pr-12 text-sm leading-6"
|
||
onChange={(event) => {
|
||
const value = event.currentTarget.value;
|
||
const cursor = event.currentTarget.selectionStart ?? value.length;
|
||
setPrompt(value);
|
||
setPromptDirty(value.trim() !== canonicalPrompt);
|
||
setMentionRange(findReferenceMentionRange(value, cursor));
|
||
if (saveState === 'error') setSaveState('settled');
|
||
}}
|
||
onClick={(event) => {
|
||
const cursor = event.currentTarget.selectionStart ?? prompt.length;
|
||
setMentionRange(findReferenceMentionRange(prompt, cursor));
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') setMentionRange(null);
|
||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||
event.preventDefault();
|
||
void savePrompt(Boolean(offeredQuote));
|
||
}
|
||
}}
|
||
onBlur={() => {
|
||
if (promptDirty && !mentionRange) void savePrompt(Boolean(offeredQuote));
|
||
}}
|
||
/>
|
||
|
||
{mentionRange && (
|
||
<div
|
||
role="listbox"
|
||
aria-label="可引用的图片"
|
||
className="absolute left-2 top-full z-20 mt-1 w-[min(320px,calc(100%-1rem))] overflow-hidden rounded-xl border border-border/80 bg-background p-1.5 shadow-float"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
>
|
||
{references.map((reference, index) => {
|
||
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null;
|
||
return (
|
||
<button
|
||
key={reference.id}
|
||
type="button"
|
||
role="option"
|
||
aria-selected="false"
|
||
className="flex min-h-10 w-full items-center gap-2 rounded-lg px-2 text-left hover:bg-surface-subtle"
|
||
onClick={() => insertExistingReference(index)}
|
||
>
|
||
<DesignAssetThumbnail asset={asset} className="h-7 w-7 rounded-md" />
|
||
<span className="text-xs font-medium text-foreground">{referenceToken(index)}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
role="option"
|
||
aria-selected="false"
|
||
className="flex min-h-10 w-full items-center gap-2 rounded-lg px-2 text-left text-xs font-medium text-brand hover:bg-brand/[0.05]"
|
||
onClick={() => {
|
||
setReplaceIndex(null);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
上传新的参考图
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||
<span>输入 @ 可引用已添加的图片</span>
|
||
<span className="tabular-nums">{prompt.length} / 1000</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section aria-label="参考图" className="border-t border-border/60 pt-3">
|
||
<h3 className="text-xs font-semibold text-foreground">参考图 {references.length > 0 ? references.length : ''}</h3>
|
||
<div className="mt-2 grid gap-2 lg:grid-cols-2">
|
||
{references.map((reference, index) => {
|
||
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null;
|
||
const referenced = promptReferencesImage(prompt, index);
|
||
return (
|
||
<article key={reference.id} className="flex min-h-14 items-center gap-2.5 rounded-xl border border-border/70 bg-background p-2">
|
||
<DesignAssetThumbnail asset={asset} className="h-11 w-11 rounded-lg" />
|
||
<button
|
||
type="button"
|
||
title={`在提示词中插入 ${referenceToken(index)}`}
|
||
className="min-w-0 flex-1 text-left"
|
||
onClick={() => insertExistingReference(index)}
|
||
>
|
||
<span className="block truncate text-xs font-semibold text-foreground">{referenceToken(index)}</span>
|
||
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
|
||
{asset ? `参考图 ${index + 1} · ${asset.width}×${asset.height}` : '图片正在同步'}
|
||
</span>
|
||
</button>
|
||
<span className={cn(
|
||
'shrink-0 rounded-md px-1.5 py-1 text-[10px] font-medium',
|
||
referenced ? 'bg-emerald-50 text-emerald-700' : 'bg-surface-subtle text-muted-foreground',
|
||
)}>
|
||
{referenced ? '已引用' : '未引用'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`替换参考图 ${index + 1}`}
|
||
title="替换"
|
||
disabled={busy}
|
||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-40"
|
||
onClick={() => {
|
||
setReplaceIndex(index);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={`删除参考图 ${index + 1}`}
|
||
title="删除"
|
||
disabled={busy}
|
||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-destructive/[0.05] hover:text-destructive disabled:opacity-40"
|
||
onClick={() => removeReference(index)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</article>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
className="flex min-h-14 items-center justify-center gap-2 rounded-xl border border-dashed border-border px-3 text-xs font-medium text-foreground hover:border-brand/30 hover:bg-brand/[0.025]"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setReplaceIndex(null);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||
添加参考图
|
||
</button>
|
||
</div>
|
||
|
||
{unboundReferenceNumbers.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50/65 px-3 py-2">
|
||
<p className="text-xs leading-5 text-amber-900">
|
||
{unboundReferenceNumbers.map((number) => `@图片${number}`).join('、')} 还没有绑定图片
|
||
</p>
|
||
{unboundReferenceNumbers.every((number) => number > 0) ? (
|
||
<button
|
||
type="button"
|
||
className="h-8 rounded-lg bg-background px-2.5 text-xs font-medium text-amber-900 shadow-sm hover:bg-amber-100"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setReplaceIndex(null);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
上传 {referenceToken(references.length)}
|
||
</button>
|
||
) : (
|
||
<span className="text-[11px] text-amber-800">图片编号需从 1 开始</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={uploadInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
className="hidden"
|
||
aria-label="选择参考图片"
|
||
onChange={(event) => {
|
||
const file = event.currentTarget.files?.[0];
|
||
if (file) void uploadReference(file);
|
||
}}
|
||
/>
|
||
</section>
|
||
|
||
<section aria-label="制作参数" className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border/60 pt-3">
|
||
<label className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||
类型
|
||
<Select
|
||
aria-label="类型"
|
||
value={medium}
|
||
disabled={busy}
|
||
className="h-8 w-[82px] rounded-lg px-2 py-0 pr-7 text-xs"
|
||
onChange={(event) => {
|
||
const next = event.currentTarget.value as 'image' | 'video';
|
||
const operations: DesignUserFieldOperation[] = [{ kind: 'set', path: 'intent.media', value: next }];
|
||
if (next === 'video' && values.video.total_duration_seconds === null) {
|
||
operations.push({ kind: 'set', path: 'video.total_duration_seconds', value: duration });
|
||
}
|
||
changeParameter(operations, () => setMedium(next));
|
||
}}
|
||
>
|
||
<option value="image">图片</option>
|
||
<option value="video">视频</option>
|
||
</Select>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||
画幅
|
||
<Select
|
||
aria-label="画幅"
|
||
value={aspectRatio}
|
||
disabled={busy}
|
||
className="h-8 w-[78px] rounded-lg px-2 py-0 pr-7 text-xs"
|
||
onChange={(event) => {
|
||
const next = event.currentTarget.value;
|
||
const orientation = ASPECT_RATIOS.find((item) => item.value === next)?.orientation ?? null;
|
||
changeParameter([
|
||
{ kind: 'set', path: 'output.aspect_ratio', value: next },
|
||
orientation
|
||
? { kind: 'set', path: 'output.orientation', value: orientation }
|
||
: { kind: 'clear', path: 'output.orientation' },
|
||
], () => setAspectRatio(next));
|
||
}}
|
||
>
|
||
{ASPECT_RATIOS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</Select>
|
||
</label>
|
||
|
||
{medium === 'video' && (
|
||
<Stepper
|
||
label="时长"
|
||
value={duration}
|
||
suffix="秒"
|
||
minimum={1}
|
||
maximum={30}
|
||
disabled={busy}
|
||
onChange={(next) => changeParameter(
|
||
[{ kind: 'set', path: 'video.total_duration_seconds', value: next }],
|
||
() => setDuration(next),
|
||
)}
|
||
/>
|
||
)}
|
||
|
||
<Stepper
|
||
label="数量"
|
||
value={variantCount}
|
||
minimum={1}
|
||
maximum={4}
|
||
disabled={busy}
|
||
onChange={(next) => changeParameter(
|
||
[{ kind: 'set', path: 'output.variant_count', value: next }],
|
||
() => setVariantCount(next),
|
||
)}
|
||
/>
|
||
</section>
|
||
|
||
{blockers.length > 0 && (
|
||
<div className="rounded-xl border border-destructive/15 bg-destructive/[0.035] px-3 py-2 text-xs leading-5 text-destructive">
|
||
{blockers.map((blocker) => (
|
||
<p key={`${blocker.code}-${blocker.path ?? ''}`}>{youthCompilationIssueMessage(blocker)}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{offeredQuote?.warnings.map((warning) => (
|
||
<p key={`${warning.code}-${warning.path ?? ''}`} className="text-xs leading-5 text-amber-800">
|
||
{youthCompilationIssueMessage(warning)}
|
||
</p>
|
||
))}
|
||
</div>
|
||
|
||
<footer className="flex flex-col gap-3 border-t border-border/70 bg-surface-subtle/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||
<span className={cn(
|
||
'text-emerald-600',
|
||
(saveState === 'saving' || saveState === 'repricing') && 'text-brand',
|
||
saveState === 'error' && 'text-destructive',
|
||
)}>
|
||
{status.icon}
|
||
</span>
|
||
<span>{status.text}</span>
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<span className="text-xs text-muted-foreground sm:mr-2">
|
||
{offeredQuote
|
||
? <>预计使用 <strong className="font-semibold text-foreground">{offeredQuote.quotedDesignPoints}</strong> 设计点</>
|
||
: '确认前会先核算设计点'}
|
||
</span>
|
||
{offeredQuote ? (
|
||
<Button
|
||
type="button"
|
||
className="h-10 rounded-xl px-5"
|
||
disabled={busy || confirming || promptDirty || unboundReferenceNumbers.length > 0 || !generationAvailable}
|
||
onClick={() => {
|
||
void confirmGeneration(offeredQuote.quoteId).catch(() => {
|
||
toast.error('这次没有开始制作,请稍后再试');
|
||
});
|
||
}}
|
||
>
|
||
{confirming ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <WandSparkles className="mr-2 h-4 w-4" />}
|
||
确认并开始制作
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
className="h-10 rounded-xl px-5"
|
||
disabled={busy || !canPrepareQuote}
|
||
onClick={() => void savePrompt(true)}
|
||
>
|
||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <WandSparkles className="mr-2 h-4 w-4" />}
|
||
准备制作方案
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</footer>
|
||
</section>
|
||
);
|
||
}
|