feat(canvas): cut over design agent to living form v2
This commit is contained in:
206
src/pages/ImageCanvas/DesignConversationPane.tsx
Normal file
206
src/pages/ImageCanvas/DesignConversationPane.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Check, Loader2, MessageSquareText, Send, Sparkles, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type { DesignWorkspace } from '../../../shared/image-workspace';
|
||||
|
||||
const STARTER_MESSAGES = [
|
||||
'帮我做一张新品发布主视觉,先问我最关键的问题',
|
||||
'我有一个模糊想法,请帮我整理成专业的图片设计方案',
|
||||
'我要做一段产品短视频,请先规划叙事和分镜',
|
||||
];
|
||||
|
||||
export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
|
||||
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
|
||||
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
|
||||
const resolveDecisionPrompt = useImageWorkspaceStore((state) => state.resolveDecisionPrompt);
|
||||
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const streams = useMemo(
|
||||
() => Object.entries(assistantStreams).filter(([, text]) => text.trim()),
|
||||
[assistantStreams],
|
||||
);
|
||||
const submittingChat = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollAnchor = scrollAnchorRef.current;
|
||||
if (typeof scrollAnchor?.scrollIntoView === 'function') {
|
||||
scrollAnchor.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [streams, workspace.form.decisionPrompts.length, workspace.turns.length]);
|
||||
|
||||
const submit = () => {
|
||||
if (!chatDraft.trim() || submittingChat) return;
|
||||
void sendChat().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '消息发送失败');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="image-workspace-conversation"
|
||||
className="flex min-h-0 flex-1 flex-col bg-surface-subtle/45"
|
||||
>
|
||||
<header className="border-b border-border/70 bg-background px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-brand" />
|
||||
<h2 className="text-sm font-semibold text-foreground">与设计 Agent 对话</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
你说的每句话都会先进入设计规格,再由服务端编译成专业生成方案。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
{workspace.turns.length === 0 && streams.length === 0 && (
|
||||
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8">
|
||||
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-2xl bg-brand/[0.09] text-brand">
|
||||
<MessageSquareText className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">从自然语言开始</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
不需要一次把需求说完整。Agent 会把信息写入右侧表单,并把关键取舍留给你确认。
|
||||
</p>
|
||||
<div className="mt-5 space-y-2">
|
||||
{STARTER_MESSAGES.map((message) => (
|
||||
<button
|
||||
key={message}
|
||||
type="button"
|
||||
className="w-full rounded-xl border border-border/70 bg-background px-3 py-2.5 text-left text-xs leading-5 text-foreground transition-colors hover:border-brand/30 hover:bg-brand/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
|
||||
onClick={() => setChatDraft(message)}
|
||||
>
|
||||
{message}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
{workspace.turns.map((turn) => (
|
||||
<div key={turn.turnId} className="space-y-3">
|
||||
{turn.userMessage && (
|
||||
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-brand px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
|
||||
{turn.userMessage}
|
||||
</div>
|
||||
)}
|
||||
{turn.assistantMessage && (
|
||||
<div className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-border/65 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm">
|
||||
{turn.assistantMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streams.map(([operationId, text]) => (
|
||||
<div
|
||||
key={operationId}
|
||||
className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-brand/20 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm"
|
||||
>
|
||||
{text}
|
||||
<span className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{workspace.form.decisionPrompts.map((prompt) => (
|
||||
<article key={prompt.id} className="rounded-2xl border border-brand/20 bg-background p-4 shadow-sm">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-brand/[0.09] text-brand">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{prompt.title}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{prompt.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{prompt.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={submittingChat}
|
||||
className="flex w-full items-start gap-2 rounded-xl border border-border/70 px-3 py-2.5 text-left transition-colors hover:border-brand/30 hover:bg-brand/[0.03] disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void resolveDecisionPrompt(prompt.id, 'select', option.id).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '决策提交失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-foreground">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submittingChat}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void resolveDecisionPrompt(prompt.id, 'reject').catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '决策提交失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
暂不采用
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div ref={scrollAnchorRef} />
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border/70 bg-background p-4">
|
||||
<div className={cn(
|
||||
'rounded-2xl border border-border/80 bg-surface-input p-2 transition-colors focus-within:border-brand/35 focus-within:ring-2 focus-within:ring-brand/10',
|
||||
submittingChat && 'opacity-80',
|
||||
)}>
|
||||
<Textarea
|
||||
aria-label="给设计 Agent 留言"
|
||||
value={chatDraft}
|
||||
rows={3}
|
||||
disabled={submittingChat}
|
||||
placeholder="描述想法、补充信息,或要求 Agent 调整右侧表单…"
|
||||
className="min-h-[72px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(event) => setChatDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-1 pt-1">
|
||||
<span className="text-[11px] text-muted-foreground">Enter 发送,Shift + Enter 换行</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-xl"
|
||||
disabled={!chatDraft.trim() || submittingChat}
|
||||
aria-label="发送消息"
|
||||
onClick={submit}
|
||||
>
|
||||
{submittingChat
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
893
src/pages/ImageCanvas/DesignFormPane.tsx
Normal file
893
src/pages/ImageCanvas/DesignFormPane.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Lock,
|
||||
Plus,
|
||||
Trash2,
|
||||
Unlock,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DisclosureContent } from '@/components/ui/disclosure';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignFieldDecision,
|
||||
DesignSpecificationValues,
|
||||
DesignUserFieldOperation,
|
||||
DesignWorkspace,
|
||||
} from '../../../shared/image-workspace';
|
||||
|
||||
type FieldOption = { value: string; label: string };
|
||||
type ScalarFieldKind = 'text' | 'textarea' | 'select' | 'number' | 'boolean' | 'list' | 'asset';
|
||||
|
||||
type ScalarFieldConfig = {
|
||||
path: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
kind?: ScalarFieldKind;
|
||||
options?: FieldOption[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
|
||||
type ItemFieldConfig = {
|
||||
key: string;
|
||||
label: string;
|
||||
kind?: ScalarFieldKind;
|
||||
placeholder?: string;
|
||||
options?: FieldOption[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
|
||||
type CollectionFieldConfig = {
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
itemLabel: string;
|
||||
idPrefix: string;
|
||||
fields: ItemFieldConfig[];
|
||||
createItem: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FormSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
fields: ScalarFieldConfig[];
|
||||
collections?: CollectionFieldConfig[];
|
||||
medium?: 'image' | 'video';
|
||||
};
|
||||
|
||||
const MEDIA_OPTIONS: FieldOption[] = [
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
];
|
||||
|
||||
const CREATION_INTENT_OPTIONS: FieldOption[] = [
|
||||
{ value: 'create', label: '从零创作' },
|
||||
{ value: 'edit', label: '编辑已有素材' },
|
||||
{ value: 'vary', label: '生成变体' },
|
||||
{ value: 'composite', label: '合成多个素材' },
|
||||
{ value: 'animate', label: '把画面做成视频' },
|
||||
{ value: 'extend', label: '扩展已有内容' },
|
||||
];
|
||||
|
||||
const ORIENTATION_OPTIONS: FieldOption[] = [
|
||||
{ value: 'square', label: '方形' },
|
||||
{ value: 'portrait', label: '竖版' },
|
||||
{ value: 'landscape', label: '横版' },
|
||||
];
|
||||
|
||||
const IMAGE_OPERATION_OPTIONS: FieldOption[] = [
|
||||
{ value: 'new_generation', label: '全新生成' },
|
||||
{ value: 'source_edit', label: '基于原图编辑' },
|
||||
{ value: 'regional_edit', label: '局部编辑' },
|
||||
{ value: 'variation', label: '生成变体' },
|
||||
{ value: 'composition', label: '素材合成' },
|
||||
];
|
||||
|
||||
const REFERENCE_ROLE_OPTIONS: FieldOption[] = [
|
||||
{ value: 'subject_identity', label: '主体身份' },
|
||||
{ value: 'product_structure', label: '产品结构' },
|
||||
{ value: 'style', label: '视觉风格' },
|
||||
{ value: 'composition', label: '构图' },
|
||||
{ value: 'palette', label: '配色' },
|
||||
{ value: 'texture', label: '材质纹理' },
|
||||
{ value: 'first_frame', label: '首帧' },
|
||||
{ value: 'last_frame', label: '尾帧' },
|
||||
{ value: 'edit_source', label: '编辑源图' },
|
||||
{ value: 'mask', label: '遮罩' },
|
||||
{ value: 'logo', label: '品牌标志' },
|
||||
{ value: 'inspiration', label: '灵感参考' },
|
||||
];
|
||||
|
||||
function createItemId(prefix: string): string {
|
||||
const value = globalThis.crypto?.randomUUID?.()
|
||||
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}-${value}`.slice(0, 64);
|
||||
}
|
||||
|
||||
const SUBJECTS: CollectionFieldConfig = {
|
||||
path: 'content.subjects',
|
||||
label: '主体',
|
||||
description: '逐个说明人物、产品或物体,以及需要保持的特征和关系。',
|
||||
itemLabel: '主体',
|
||||
idPrefix: 'subject',
|
||||
fields: [
|
||||
{ key: 'description', label: '主体描述', kind: 'textarea', placeholder: '例如:一只磨砂银色的便携咖啡机' },
|
||||
{ key: 'attributes', label: '关键特征', kind: 'list', placeholder: '每行一个特征' },
|
||||
{ key: 'action', label: '动作', placeholder: '例如:正在萃取咖啡' },
|
||||
{ key: 'spatial_relationship', label: '空间关系', placeholder: '例如:位于画面中央,前景略偏右' },
|
||||
],
|
||||
createItem: () => ({
|
||||
id: createItemId('subject'),
|
||||
description: '',
|
||||
attributes: [],
|
||||
action: null,
|
||||
spatial_relationship: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const EXACT_CONTENT: CollectionFieldConfig = {
|
||||
path: 'content.exact_content',
|
||||
label: '精确文字与标识',
|
||||
description: '记录必须按原文出现的标题、卖点、品牌名或标识。',
|
||||
itemLabel: '文字',
|
||||
idPrefix: 'copy',
|
||||
fields: [
|
||||
{ key: 'text', label: '原文', kind: 'textarea', placeholder: '输入必须准确呈现的文字' },
|
||||
{ key: 'role', label: '用途', placeholder: '例如:主标题、角标、品牌名' },
|
||||
{ key: 'language', label: '语言', placeholder: '例如:简体中文' },
|
||||
{ key: 'hierarchy', label: '层级', placeholder: '例如:一级标题' },
|
||||
{ key: 'exact', label: '必须逐字准确', kind: 'boolean' },
|
||||
],
|
||||
createItem: () => ({
|
||||
id: createItemId('copy'),
|
||||
text: '',
|
||||
role: null,
|
||||
language: null,
|
||||
hierarchy: null,
|
||||
exact: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const LAYOUT_REGIONS: CollectionFieldConfig = {
|
||||
path: 'layout.regions',
|
||||
label: '画面区域',
|
||||
description: '为主体、文字、标志等元素划分明确区域与优先级。',
|
||||
itemLabel: '区域',
|
||||
idPrefix: 'region',
|
||||
fields: [
|
||||
{ key: 'role', label: '区域用途', placeholder: '例如:主视觉、标题区' },
|
||||
{ key: 'placement', label: '位置与范围', kind: 'textarea', placeholder: '例如:画面左侧三分之一,保留顶部安全距离' },
|
||||
{ key: 'priority', label: '优先级', kind: 'number', min: 0, max: 100 },
|
||||
],
|
||||
createItem: () => ({ id: createItemId('region'), role: '', placement: '', priority: 0 }),
|
||||
};
|
||||
|
||||
const REFERENCES: CollectionFieldConfig = {
|
||||
path: 'references',
|
||||
label: '参考素材',
|
||||
description: '明确每张素材承担什么角色,以及哪些内容应保留、调整或避免照搬。',
|
||||
itemLabel: '参考',
|
||||
idPrefix: 'reference',
|
||||
fields: [
|
||||
{ key: 'asset_id', label: '素材', kind: 'asset' },
|
||||
{ key: 'role', label: '参考角色', kind: 'select', options: REFERENCE_ROLE_OPTIONS },
|
||||
{ key: 'preserve', label: '必须保留', kind: 'list', placeholder: '每行一项' },
|
||||
{ key: 'adapt', label: '可以调整', kind: 'list', placeholder: '每行一项' },
|
||||
{ key: 'do_not_copy', label: '不要照搬', kind: 'list', placeholder: '每行一项' },
|
||||
{ key: 'reviewed_observations', label: '已确认观察', kind: 'list', placeholder: '每行一项客观观察' },
|
||||
],
|
||||
createItem: () => ({
|
||||
id: createItemId('reference'),
|
||||
asset_id: '',
|
||||
asset_revision: null,
|
||||
role: 'inspiration',
|
||||
preserve: [],
|
||||
adapt: [],
|
||||
do_not_copy: [],
|
||||
reviewed_observations: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const VIDEO_SHOTS: CollectionFieldConfig = {
|
||||
path: 'video.shots',
|
||||
label: '分镜',
|
||||
description: '把叙事拆成镜头,明确时间、景别、运镜和主体运动。',
|
||||
itemLabel: '镜头',
|
||||
idPrefix: 'shot',
|
||||
fields: [
|
||||
{ key: 'story_beat', label: '叙事节点', kind: 'textarea', placeholder: '这一镜发生什么' },
|
||||
{ key: 'start_milliseconds', label: '开始时间(毫秒)', kind: 'number', min: 0, max: 120000 },
|
||||
{ key: 'end_milliseconds', label: '结束时间(毫秒)', kind: 'number', min: 1, max: 120000 },
|
||||
{ key: 'framing', label: '景别', placeholder: '例如:中近景、产品特写' },
|
||||
{ key: 'camera', label: '运镜', placeholder: '例如:缓慢推进,保持水平' },
|
||||
{ key: 'subject_motion', label: '主体运动', placeholder: '主体如何运动' },
|
||||
{ key: 'environment_motion', label: '环境运动', placeholder: '背景、光影或粒子如何变化' },
|
||||
{ key: 'focus_behavior', label: '焦点变化', placeholder: '例如:焦点从前景转向产品标志' },
|
||||
{ key: 'transition', label: '转场', placeholder: '例如:动作匹配剪辑' },
|
||||
],
|
||||
createItem: () => ({
|
||||
id: createItemId('shot'),
|
||||
start_milliseconds: null,
|
||||
end_milliseconds: null,
|
||||
story_beat: '',
|
||||
framing: null,
|
||||
camera: null,
|
||||
subject_motion: null,
|
||||
environment_motion: null,
|
||||
focus_behavior: null,
|
||||
transition: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const ACCEPTANCE_CRITERIA: CollectionFieldConfig = {
|
||||
path: 'constraints.acceptance_criteria',
|
||||
label: '验收标准',
|
||||
description: '写成可判断是否完成的条件,生成后据此检查结果。',
|
||||
itemLabel: '标准',
|
||||
idPrefix: 'criterion',
|
||||
fields: [
|
||||
{ key: 'description', label: '判断条件', kind: 'textarea', placeholder: '例如:产品标志完整清晰,且不被主体遮挡' },
|
||||
{ key: 'required', label: '必须满足', kind: 'boolean' },
|
||||
],
|
||||
createItem: () => ({ id: createItemId('criterion'), description: '', required: true }),
|
||||
};
|
||||
|
||||
const SECTIONS: FormSection[] = [
|
||||
{
|
||||
id: 'intent',
|
||||
title: '目标与用途',
|
||||
description: '先确定做什么、给谁看,以及成功的判断标准。',
|
||||
fields: [
|
||||
{ path: 'intent.media', label: '输出媒介', kind: 'select', options: MEDIA_OPTIONS },
|
||||
{ path: 'intent.creation_intent', label: '创作方式', kind: 'select', options: CREATION_INTENT_OPTIONS },
|
||||
{ path: 'intent.purpose', label: '使用目的', placeholder: '例如:新品发布主视觉' },
|
||||
{ path: 'intent.channel', label: '投放渠道', placeholder: '例如:小红书首图、官网横幅' },
|
||||
{ path: 'intent.audience', label: '目标受众', kind: 'textarea', placeholder: '描述人群、场景与认知背景' },
|
||||
{ path: 'intent.intended_response', label: '期望反应', kind: 'textarea', placeholder: '希望观众看后产生什么反应' },
|
||||
{ path: 'intent.communication_goal', label: '沟通目标', kind: 'textarea', placeholder: '这一设计最重要的信息是什么' },
|
||||
{ path: 'intent.success_definition', label: '成功定义', kind: 'textarea', placeholder: '什么样的结果才算设计成功' },
|
||||
{ path: 'intent.related_asset_id', label: '关联资产', kind: 'asset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: '内容与叙事',
|
||||
description: '描述主体、行为、环境,以及必须准确出现的内容。',
|
||||
fields: [
|
||||
{ path: 'content.concept', label: '核心概念', kind: 'textarea', placeholder: '用一两句话说明画面的核心想法' },
|
||||
{ path: 'content.narrative', label: '叙事', kind: 'textarea', placeholder: '观众从画面中读到的故事或因果' },
|
||||
{ path: 'content.action', label: '主要动作', kind: 'textarea', placeholder: '主体正在做什么' },
|
||||
{ path: 'content.environment', label: '环境', kind: 'textarea', placeholder: '空间、时间、天气与背景元素' },
|
||||
{ path: 'content.emotional_tone', label: '情绪基调', placeholder: '例如:克制、轻盈、有期待感' },
|
||||
],
|
||||
collections: [SUBJECTS, EXACT_CONTENT],
|
||||
},
|
||||
{
|
||||
id: 'visual',
|
||||
title: '视觉语言',
|
||||
description: '把风格拆成构图、镜头、光线、色彩、材质和完成度。',
|
||||
fields: [
|
||||
{ path: 'visual.art_direction', label: '艺术方向', kind: 'textarea', placeholder: '描述整体视觉语言,避免只写风格名' },
|
||||
{ path: 'visual.cultural_cues', label: '文化语境', kind: 'textarea', placeholder: '需要体现或避开的文化线索' },
|
||||
{ path: 'visual.composition', label: '构图', kind: 'textarea', placeholder: '主体比例、留白、视觉动线和层级' },
|
||||
{ path: 'visual.viewpoint', label: '视点与镜头', placeholder: '例如:略低机位,50mm 视角' },
|
||||
{ path: 'visual.lighting', label: '光线', kind: 'textarea', placeholder: '光源方向、软硬、对比和氛围' },
|
||||
{ path: 'visual.palette', label: '配色', kind: 'textarea', placeholder: '主色、辅色、明度与饱和度关系' },
|
||||
{ path: 'visual.materials', label: '材质', kind: 'textarea', placeholder: '主体与环境的材质表现' },
|
||||
{ path: 'visual.finish', label: '成片质感', kind: 'textarea', placeholder: '例如:商业摄影级,细节克制不过锐' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
title: '版式与文字',
|
||||
description: '约束主体、文案、标志、安全区与裁切后的可用性。',
|
||||
fields: [
|
||||
{ path: 'layout.subject_placement', label: '主体位置', kind: 'textarea' },
|
||||
{ path: 'layout.text_placement', label: '文字位置', kind: 'textarea' },
|
||||
{ path: 'layout.logo_placement', label: '标志位置', kind: 'textarea' },
|
||||
{ path: 'layout.safe_areas', label: '安全区', kind: 'textarea' },
|
||||
{ path: 'layout.crop_behavior', label: '裁切行为', kind: 'textarea' },
|
||||
{ path: 'layout.typography', label: '字体与排版', kind: 'textarea' },
|
||||
{ path: 'layout.exact_rendering', label: '精确呈现要求', kind: 'textarea' },
|
||||
],
|
||||
collections: [LAYOUT_REGIONS],
|
||||
},
|
||||
{
|
||||
id: 'references',
|
||||
title: '参考素材',
|
||||
description: '给素材分配明确角色,避免含糊的“参考一下”。',
|
||||
fields: [],
|
||||
collections: [REFERENCES],
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
title: '图片制作',
|
||||
description: '图片专用的编辑范围、保留项、背景与透明度要求。',
|
||||
medium: 'image',
|
||||
fields: [
|
||||
{ path: 'image.operation_intent', label: '图片操作', kind: 'select', options: IMAGE_OPERATION_OPTIONS },
|
||||
{ path: 'image.source_asset_id', label: '源图', kind: 'asset' },
|
||||
{ path: 'image.mask_asset_id', label: '遮罩图', kind: 'asset' },
|
||||
{ path: 'image.preserve_instructions', label: '必须保留', kind: 'list', placeholder: '每行一项' },
|
||||
{ path: 'image.change_instructions', label: '需要改变', kind: 'list', placeholder: '每行一项' },
|
||||
{ path: 'image.background_behavior', label: '背景处理', kind: 'textarea' },
|
||||
{ path: 'image.transparency_required', label: '需要透明背景', kind: 'boolean' },
|
||||
{ path: 'image.edge_crop_behavior', label: '边缘与裁切', kind: 'textarea' },
|
||||
{ path: 'image.variant_relationship', label: '变体关系', kind: 'textarea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
title: '视频与分镜',
|
||||
description: '视频专用的时长、节奏、镜头、运动与连续性。',
|
||||
medium: 'video',
|
||||
fields: [
|
||||
{ path: 'video.total_duration_seconds', label: '总时长(秒)', kind: 'number', min: 1, max: 120 },
|
||||
{ path: 'video.pacing', label: '整体节奏', kind: 'textarea' },
|
||||
{ path: 'video.first_frame_asset_id', label: '首帧素材', kind: 'asset' },
|
||||
{ path: 'video.last_frame_asset_id', label: '尾帧素材', kind: 'asset' },
|
||||
{ path: 'video.rhythm', label: '节拍与段落', kind: 'textarea' },
|
||||
{ path: 'video.speed_behavior', label: '速度变化', kind: 'textarea' },
|
||||
{ path: 'video.stillness', label: '静止策略', kind: 'textarea' },
|
||||
{ path: 'video.loop_behavior', label: '循环方式', kind: 'textarea' },
|
||||
{ path: 'video.end_state', label: '结束状态', kind: 'textarea' },
|
||||
{ path: 'video.continuity', label: '连续性', kind: 'textarea' },
|
||||
{ path: 'video.audio_intent', label: '声音意图', kind: 'textarea' },
|
||||
],
|
||||
collections: [VIDEO_SHOTS],
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
title: '交付规格',
|
||||
description: '画幅、方向、质量、格式和变体数量会进入服务端编译与报价。',
|
||||
fields: [
|
||||
{ path: 'output.aspect_ratio', label: '画幅比例', placeholder: '例如:3:4、16:9、1:1' },
|
||||
{ path: 'output.orientation', label: '画面方向', kind: 'select', options: ORIENTATION_OPTIONS },
|
||||
{ path: 'output.quality_intent', label: '质量目标', kind: 'textarea', placeholder: '例如:适合电商主图,产品边缘清晰' },
|
||||
{ path: 'output.delivery_format', label: '交付格式', placeholder: '例如:PNG、MP4' },
|
||||
{ path: 'output.variant_count', label: '变体数量', kind: 'number', min: 1, max: 16 },
|
||||
{ path: 'output.variant_purposes', label: '各变体用途', kind: 'list', placeholder: '每行一种用途' },
|
||||
{ path: 'output.crop_requirements', label: '裁切要求', kind: 'textarea' },
|
||||
{ path: 'output.export_requirements', label: '导出要求', kind: 'textarea' },
|
||||
{ path: 'output.safe_areas', label: '交付安全区', kind: 'textarea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'constraints',
|
||||
title: '约束与验收',
|
||||
description: '把必须做到、必须保留、必须避免和可以妥协的部分分开。',
|
||||
fields: [
|
||||
{ path: 'constraints.must_include', label: '必须包含', kind: 'list', placeholder: '每行一项' },
|
||||
{ path: 'constraints.must_preserve', label: '必须保留', kind: 'list', placeholder: '每行一项' },
|
||||
{ path: 'constraints.must_avoid', label: '必须避免', kind: 'list', placeholder: '每行一项' },
|
||||
{ path: 'constraints.brand_constraints', label: '品牌约束', kind: 'list', placeholder: '每行一项' },
|
||||
{ path: 'constraints.flexible_preferences', label: '弹性偏好', kind: 'list', placeholder: '可以调整但最好满足的偏好' },
|
||||
],
|
||||
collections: [ACCEPTANCE_CRITERIA],
|
||||
},
|
||||
];
|
||||
|
||||
function readPath(values: DesignSpecificationValues, path: string): unknown {
|
||||
let current: unknown = values;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) return null;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== null && value !== undefined && value !== ''
|
||||
&& (!Array.isArray(value) || value.length > 0);
|
||||
}
|
||||
|
||||
function decisionLabel(decision: DesignFieldDecision | undefined): string {
|
||||
if (!decision || decision.resolution === 'open') return '待补充';
|
||||
if (decision.resolution === 'proposed') return 'Agent 建议';
|
||||
if (decision.resolution === 'delegated') return '交给 Agent';
|
||||
if (decision.resolution === 'not_applicable') return '不适用';
|
||||
return decision.provenance === 'user' || decision.provenance === 'extracted_user'
|
||||
? '已确认'
|
||||
: '已解析';
|
||||
}
|
||||
|
||||
function decisionVariant(
|
||||
decision: DesignFieldDecision | undefined,
|
||||
): 'outline' | 'secondary' | 'warning' | 'success' {
|
||||
if (decision?.resolution === 'proposed' || decision?.resolution === 'delegated') return 'warning';
|
||||
if (decision?.resolution === 'resolved') return 'success';
|
||||
if (decision?.resolution === 'not_applicable') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function scalarToDraft(value: unknown, kind: ScalarFieldKind): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (kind === 'list') return Array.isArray(value) ? value.join('\n') : '';
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? String(value) : '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function draftToValue(value: string, field: ScalarFieldConfig | ItemFieldConfig): unknown {
|
||||
const kind = field.kind ?? 'text';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (kind === 'number') return Number(trimmed);
|
||||
if (kind === 'boolean') return trimmed === 'true';
|
||||
if (kind === 'list') return value.split('\n').map((item) => item.trim()).filter(Boolean);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function FieldDecisionActions({
|
||||
path,
|
||||
value,
|
||||
decision,
|
||||
disabled,
|
||||
}: {
|
||||
path: string;
|
||||
value: unknown;
|
||||
decision: DesignFieldDecision | undefined;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations);
|
||||
const run = (operations: DesignUserFieldOperation[]) => {
|
||||
void applyFieldOperations(operations).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '字段操作失败');
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{decision?.resolution === 'proposed' && hasValue(value) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-brand hover:bg-brand/[0.08] disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={() => run([{ kind: 'set', path, value }])}
|
||||
>
|
||||
采用
|
||||
</button>
|
||||
)}
|
||||
{hasValue(value) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-1 text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-50"
|
||||
aria-label={decision?.locked ? `解锁 ${path}` : `锁定 ${path}`}
|
||||
disabled={disabled}
|
||||
onClick={() => run([{ kind: 'set_lock', path, locked: !decision?.locked }])}
|
||||
>
|
||||
{decision?.locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScalarField({
|
||||
config,
|
||||
workspace,
|
||||
assets,
|
||||
}: {
|
||||
config: ScalarFieldConfig;
|
||||
workspace: DesignWorkspace;
|
||||
assets: DesignAsset[];
|
||||
}) {
|
||||
const fieldDrafts = useImageWorkspaceStore((state) => state.fieldDrafts);
|
||||
const setFieldDraft = useImageWorkspaceStore((state) => state.setFieldDraft);
|
||||
const discardFieldDraft = useImageWorkspaceStore((state) => state.discardFieldDraft);
|
||||
const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations);
|
||||
const pending = Object.values(useImageWorkspaceStore((state) => state.pendingOperations))
|
||||
.some((operation) => operation.status === 'submitting');
|
||||
const canonical = readPath(workspace.form.specification.values, config.path);
|
||||
const decision = workspace.form.specification.field_decisions[config.path];
|
||||
const dirty = Object.hasOwn(fieldDrafts, config.path);
|
||||
const kind = config.kind ?? 'text';
|
||||
const draft = dirty
|
||||
? String(fieldDrafts[config.path] ?? '')
|
||||
: scalarToDraft(canonical, kind);
|
||||
const disabled = Boolean(decision?.locked) || pending;
|
||||
|
||||
const save = () => {
|
||||
const value = draftToValue(draft, config);
|
||||
const operation: DesignUserFieldOperation = value === null
|
||||
? { kind: 'clear', path: config.path }
|
||||
: { kind: 'set', path: config.path, value };
|
||||
void applyFieldOperations([operation], [config.path]).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '字段保存失败');
|
||||
});
|
||||
};
|
||||
|
||||
const inputClass = cn(decision?.resolution === 'proposed' && 'border-amber-300 bg-amber-50/45');
|
||||
const input = kind === 'textarea' || kind === 'list'
|
||||
? (
|
||||
<Textarea
|
||||
aria-label={config.label}
|
||||
value={draft}
|
||||
rows={kind === 'list' ? 4 : 3}
|
||||
placeholder={config.placeholder}
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
onChange={(event) => setFieldDraft(config.path, event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
: kind === 'select' || kind === 'boolean' || kind === 'asset'
|
||||
? (
|
||||
<Select
|
||||
aria-label={config.label}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
onChange={(event) => setFieldDraft(config.path, event.currentTarget.value)}
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{kind === 'boolean' && (
|
||||
<>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</>
|
||||
)}
|
||||
{kind === 'select' && config.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
{kind === 'asset' && assets.map((asset) => (
|
||||
<option key={asset.assetId} value={asset.assetId}>
|
||||
{asset.role === 'uploaded' ? '上传素材' : '生成作品'} · {asset.assetId.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
: (
|
||||
<Input
|
||||
aria-label={config.label}
|
||||
type={kind === 'number' ? 'number' : 'text'}
|
||||
value={draft}
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
placeholder={config.placeholder}
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
onChange={(event) => setFieldDraft(config.path, event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && dirty && !disabled) save();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', kind === 'textarea' || kind === 'list' ? 'md:col-span-2' : '')}>
|
||||
<div className="flex min-h-6 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<label className="truncate text-sm font-medium text-foreground">{config.label}</label>
|
||||
<Badge variant={decisionVariant(decision)} className="shrink-0 px-2 py-0 text-[10px]">
|
||||
{decisionLabel(decision)}
|
||||
</Badge>
|
||||
</div>
|
||||
<FieldDecisionActions
|
||||
path={config.path}
|
||||
value={canonical}
|
||||
decision={decision}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
{config.description && <p className="text-xs leading-5 text-muted-foreground">{config.description}</p>}
|
||||
{input}
|
||||
{dirty && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => discardFieldDraft(config.path)}>
|
||||
撤销
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={disabled} onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeItemFieldValue(value: string, field: ItemFieldConfig): unknown {
|
||||
const parsed = draftToValue(value, field);
|
||||
if (parsed !== null) return parsed;
|
||||
if (field.kind === 'list') return [];
|
||||
if (field.kind === 'boolean') return false;
|
||||
if (field.kind === 'number') return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function CollectionField({
|
||||
config,
|
||||
workspace,
|
||||
assets,
|
||||
}: {
|
||||
config: CollectionFieldConfig;
|
||||
workspace: DesignWorkspace;
|
||||
assets: DesignAsset[];
|
||||
}) {
|
||||
const fieldDrafts = useImageWorkspaceStore((state) => state.fieldDrafts);
|
||||
const setFieldDraft = useImageWorkspaceStore((state) => state.setFieldDraft);
|
||||
const discardFieldDraft = useImageWorkspaceStore((state) => state.discardFieldDraft);
|
||||
const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations);
|
||||
const pending = Object.values(useImageWorkspaceStore((state) => state.pendingOperations))
|
||||
.some((operation) => operation.status === 'submitting');
|
||||
const canonicalValue = readPath(workspace.form.specification.values, config.path);
|
||||
const canonicalItems = Array.isArray(canonicalValue)
|
||||
? canonicalValue as Record<string, unknown>[]
|
||||
: [];
|
||||
const dirty = Object.hasOwn(fieldDrafts, config.path);
|
||||
const items = dirty && Array.isArray(fieldDrafts[config.path])
|
||||
? fieldDrafts[config.path] as Record<string, unknown>[]
|
||||
: canonicalItems;
|
||||
const decision = workspace.form.specification.field_decisions[config.path];
|
||||
const disabled = Boolean(decision?.locked) || pending;
|
||||
|
||||
const updateItems = (next: Record<string, unknown>[]) => setFieldDraft(config.path, next);
|
||||
const updateItem = (index: number, key: string, value: unknown) => {
|
||||
updateItems(items.map((item, itemIndex) => (
|
||||
itemIndex === index ? { ...item, [key]: value } : item
|
||||
)));
|
||||
};
|
||||
const save = () => {
|
||||
const operation: DesignUserFieldOperation = items.length === 0
|
||||
? { kind: 'clear', path: config.path }
|
||||
: { kind: 'set', path: config.path, value: items };
|
||||
void applyFieldOperations([operation], [config.path]).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '列表保存失败');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 md:col-span-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground">{config.label}</h4>
|
||||
<Badge variant={decisionVariant(decision)} className="px-2 py-0 text-[10px]">
|
||||
{decisionLabel(decision)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<FieldDecisionActions
|
||||
path={config.path}
|
||||
value={canonicalItems}
|
||||
decision={decision}
|
||||
disabled={pending}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => updateItems([...items, config.createItem()])}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
添加{config.itemLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="w-full rounded-xl border border-dashed border-border px-4 py-5 text-sm text-muted-foreground transition-colors hover:border-brand/35 hover:bg-brand/[0.03] disabled:opacity-50"
|
||||
onClick={() => updateItems([config.createItem()])}
|
||||
>
|
||||
暂无{config.itemLabel},点击添加
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<div key={String(item.id ?? `${config.idPrefix}-${index}`)} className="rounded-xl bg-surface-subtle/70 p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{config.itemLabel} {index + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除${config.itemLabel} ${index + 1}`}
|
||||
disabled={disabled}
|
||||
className="rounded-lg p-1.5 text-muted-foreground hover:bg-background hover:text-destructive disabled:opacity-50"
|
||||
onClick={() => updateItems(items.filter((_, itemIndex) => itemIndex !== index))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{config.fields.map((field) => {
|
||||
const kind = field.kind ?? 'text';
|
||||
const current = scalarToDraft(item[field.key], kind);
|
||||
const wide = kind === 'textarea' || kind === 'list';
|
||||
return (
|
||||
<div key={field.key} className={cn('space-y-1.5', wide && 'md:col-span-2')}>
|
||||
<label className="text-xs font-medium text-foreground">{field.label}</label>
|
||||
{wide ? (
|
||||
<Textarea
|
||||
aria-label={`${config.itemLabel} ${index + 1} ${field.label}`}
|
||||
value={current}
|
||||
rows={kind === 'list' ? 3 : 2}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateItem(
|
||||
index,
|
||||
field.key,
|
||||
normalizeItemFieldValue(event.currentTarget.value, field),
|
||||
)}
|
||||
/>
|
||||
) : kind === 'select' || kind === 'boolean' || kind === 'asset' ? (
|
||||
<Select
|
||||
aria-label={`${config.itemLabel} ${index + 1} ${field.label}`}
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateItem(
|
||||
index,
|
||||
field.key,
|
||||
normalizeItemFieldValue(event.currentTarget.value, field),
|
||||
)}
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{kind === 'boolean' && (
|
||||
<>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</>
|
||||
)}
|
||||
{kind === 'select' && field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
{kind === 'asset' && assets.map((asset) => (
|
||||
<option key={asset.assetId} value={asset.assetId}>
|
||||
{asset.role === 'uploaded' ? '上传素材' : '生成作品'} · {asset.assetId.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
aria-label={`${config.itemLabel} ${index + 1} ${field.label}`}
|
||||
type={kind === 'number' ? 'number' : 'text'}
|
||||
value={current}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateItem(
|
||||
index,
|
||||
field.key,
|
||||
normalizeItemFieldValue(event.currentTarget.value, field),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => discardFieldDraft(config.path)}>
|
||||
撤销整组修改
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={disabled} onClick={save}>
|
||||
保存{config.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countSectionDecisions(workspace: DesignWorkspace, section: FormSection): [number, number] {
|
||||
const paths = [
|
||||
...section.fields.map((field) => field.path),
|
||||
...(section.collections ?? []).map((field) => field.path),
|
||||
];
|
||||
const resolved = paths.filter((path) => {
|
||||
const resolution = workspace.form.specification.field_decisions[path]?.resolution;
|
||||
return resolution === 'resolved' || resolution === 'not_applicable';
|
||||
}).length;
|
||||
return [resolved, paths.length];
|
||||
}
|
||||
|
||||
export function DesignFormPane({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const media = workspace.form.specification.values.intent.media;
|
||||
const visibleSections = useMemo(
|
||||
() => SECTIONS.filter((section) => !section.medium || !media || section.medium === media),
|
||||
[media],
|
||||
);
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||
intent: true,
|
||||
content: true,
|
||||
visual: true,
|
||||
output: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-8">
|
||||
{visibleSections.map((section) => {
|
||||
const open = openSections[section.id] === true;
|
||||
const [resolved, total] = countSectionDecisions(workspace, section);
|
||||
return (
|
||||
<section key={section.id} className="rounded-2xl border border-border/70 bg-background">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
|
||||
onClick={() => setOpenSections((current) => ({ ...current, [section.id]: !open }))}
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-foreground">{section.title}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
|
||||
{section.description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{resolved}/{total} 已确认
|
||||
</span>
|
||||
</button>
|
||||
<DisclosureContent open={open}>
|
||||
<div className="border-t border-border/60 px-4 py-4">
|
||||
<div className="grid gap-x-4 gap-y-5 md:grid-cols-2">
|
||||
{section.fields.map((field) => (
|
||||
<ScalarField
|
||||
key={field.path}
|
||||
config={field}
|
||||
workspace={workspace}
|
||||
assets={workspace.assets}
|
||||
/>
|
||||
))}
|
||||
{section.collections?.map((field) => (
|
||||
<CollectionField
|
||||
key={field.path}
|
||||
config={field}
|
||||
workspace={workspace}
|
||||
assets={workspace.assets}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DisclosureContent>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignFormSummary({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const decisions = Object.values(workspace.form.specification.field_decisions);
|
||||
const resolved = decisions.filter(
|
||||
(decision) => decision.resolution === 'resolved' || decision.resolution === 'not_applicable',
|
||||
).length;
|
||||
const proposals = decisions.filter((decision) => decision.resolution === 'proposed').length;
|
||||
const locks = decisions.filter((decision) => decision.locked).length;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check className="h-3.5 w-3.5 text-brand" />
|
||||
{resolved} 项已确认
|
||||
</span>
|
||||
{proposals > 0 && <span>{proposals} 项待采纳</span>}
|
||||
{locks > 0 && <span>{locks} 项已锁定</span>}
|
||||
<span>规格版本 {workspace.form.specificationRevision}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
src/pages/ImageCanvas/DesignProductionPanel.tsx
Normal file
487
src/pages/ImageCanvas/DesignProductionPanel.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Clock3,
|
||||
Download,
|
||||
Film,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
saveImageWorkspaceAsset,
|
||||
uploadImageWorkspaceAsset,
|
||||
} from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignGenerationQuote,
|
||||
DesignGenerationTask,
|
||||
DesignWorkspace,
|
||||
} from '../../../shared/image-workspace';
|
||||
|
||||
const TASK_STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
|
||||
queued: '排队中',
|
||||
running: '生成中',
|
||||
succeeded: '已完成',
|
||||
failed: '未完成',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
const TASK_STAGE_LABELS: Record<DesignGenerationTask['progress']['stage'], string> = {
|
||||
queued: '等待开始',
|
||||
preparing: '准备素材',
|
||||
submitting: '提交生成',
|
||||
generating: '生成内容',
|
||||
composing: '合成结果',
|
||||
validating: '检查结果',
|
||||
finalizing: '保存资产',
|
||||
completed: '生成完成',
|
||||
failed: '生成失败',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
const EXECUTION_HEALTH_LABELS: Record<DesignGenerationTask['executionHealth'], string> = {
|
||||
normal: '正常',
|
||||
recovering: '自动恢复中',
|
||||
operator_required: '平台处理中',
|
||||
};
|
||||
|
||||
function quoteTitle(quote: DesignGenerationQuote): string {
|
||||
return quote.medium === 'video' ? '视频生成方案' : '图片生成方案';
|
||||
}
|
||||
|
||||
function formatExpiry(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return value;
|
||||
return new Date(timestamp).toLocaleString([], {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function AssetPreview({ asset }: { asset: DesignAsset }) {
|
||||
const [resolution, setResolution] = useState<{
|
||||
contentPath: string;
|
||||
source: string | null;
|
||||
failed: boolean;
|
||||
}>(() => ({ contentPath: asset.contentPath, source: null, failed: false }));
|
||||
const currentResolution = resolution.contentPath === asset.contentPath
|
||||
? resolution
|
||||
: { contentPath: asset.contentPath, source: null, failed: false };
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void resolveImageWorkspaceAssetUrl(asset.contentPath)
|
||||
.then((url) => {
|
||||
if (active) setResolution({ contentPath: asset.contentPath, source: url, failed: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setResolution({ contentPath: asset.contentPath, source: null, failed: true });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [asset.contentPath]);
|
||||
|
||||
if (!currentResolution.source || currentResolution.failed) {
|
||||
return (
|
||||
<div className="flex aspect-square items-center justify-center bg-surface-subtle text-muted-foreground">
|
||||
{asset.mediaType === 'video'
|
||||
? <Film className="h-6 w-6" />
|
||||
: <ImageIcon className="h-6 w-6" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (asset.mediaType === 'video') {
|
||||
return (
|
||||
<video
|
||||
src={currentResolution.source}
|
||||
className="aspect-square w-full bg-surface-subtle object-cover"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={currentResolution.source}
|
||||
alt={asset.role === 'uploaded' ? '上传的设计参考素材' : 'AI 生成的设计结果'}
|
||||
className="aspect-square w-full bg-surface-subtle object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignQuotePanel({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
|
||||
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
|
||||
const quoteBlockers = useImageWorkspaceStore((state) => state.quoteBlockers);
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const offeredQuotes = workspace.form.activeQuotes.filter((quote) => quote.status === 'offered');
|
||||
const quote = offeredQuotes[0] ?? null;
|
||||
const requesting = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
|
||||
);
|
||||
const confirming = Object.values(pendingOperations).some(
|
||||
(operation) => operation.status === 'submitting'
|
||||
&& operation.command.kind === 'confirm_generation',
|
||||
);
|
||||
|
||||
if (!quote) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-brand/20 bg-brand/[0.035] p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<WandSparkles className="h-4 w-4 text-brand" />
|
||||
<h3 className="text-sm font-semibold text-foreground">生成方案与报价</h3>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
服务端会冻结当前设计版本,编译专业提示词、输出计划与最高扣费。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0 rounded-xl"
|
||||
disabled={requesting}
|
||||
onClick={() => {
|
||||
void requestQuote().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '生成方案编译失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
{requesting
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <WandSparkles className="mr-2 h-4 w-4" />}
|
||||
编译生成方案
|
||||
</Button>
|
||||
</div>
|
||||
{quoteBlockers.length > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/[0.04] p-3">
|
||||
<p className="text-xs font-medium text-destructive">还需要补充以下内容</p>
|
||||
<ul className="mt-2 space-y-1.5 text-xs leading-5 text-foreground">
|
||||
{quoteBlockers.map((blocker) => (
|
||||
<li key={`${blocker.code}-${blocker.path ?? ''}`}>{blocker.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{quoteTitle(quote)}</h3>
|
||||
<Badge variant="success">报价已冻结</Badge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
对应规格版本 {quote.specificationRevision},确认后不会再读取当前表单的新修改。
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<p className="text-2xl font-semibold tabular-nums text-foreground">
|
||||
{quote.quotedDesignPoints}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">设计点</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 rounded-xl bg-surface-subtle/75 p-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">画幅</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{quote.outputSummary.aspectRatio}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">数量</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{quote.outputSummary.outputCount} 个输出</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">生成方式</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">
|
||||
{quote.outputSummary.strategy === 'direct' ? '直接生成' : '多步骤生成'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-muted-foreground">有效期</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{formatExpiry(quote.expiresAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quote.warnings.length > 0 && (
|
||||
<div className="mt-3 space-y-1.5 rounded-xl border border-amber-200 bg-amber-50/60 p-3 text-xs leading-5 text-amber-900">
|
||||
{quote.warnings.map((warning) => (
|
||||
<p key={`${warning.code}-${warning.path ?? ''}`} className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{warning.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
确认即按此不可变报价创建任务,不会在客户端拼接或改写提示词。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0 rounded-xl"
|
||||
disabled={confirming}
|
||||
onClick={() => {
|
||||
void confirmGeneration(quote.quoteId).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '确认生成失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
{confirming
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <Check className="mr-2 h-4 w-4" />}
|
||||
确认生成
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignPendingOperationsPanel() {
|
||||
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
|
||||
const retryOperation = useImageWorkspaceStore((state) => state.retryOperation);
|
||||
const unknown = Object.values(pendingOperations).filter((operation) => operation.status === 'unknown');
|
||||
if (unknown.length === 0) return null;
|
||||
return (
|
||||
<section className="rounded-2xl border border-amber-200 bg-amber-50/55 p-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-amber-950">有操作结果尚未确认</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-amber-900/80">
|
||||
重试会使用原操作标识,服务端会返回原结果,不会创建重复设计或重复任务。
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{unknown.map((operation) => (
|
||||
<div key={operation.id} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-foreground">{operation.label}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{operation.error}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
void retryOperation(operation.id).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '操作重试失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
安全重试
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignTaskPanel({ tasks }: { tasks: DesignGenerationTask[] }) {
|
||||
if (tasks.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">生成任务</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">任务持续在云端执行,关闭页面不会取消。</p>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{tasks.map((task) => {
|
||||
const active = task.status === 'queued' || task.status === 'running';
|
||||
return (
|
||||
<article key={task.taskId} className="rounded-2xl border border-border/70 bg-background p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.medium === 'video'
|
||||
? <Film className="h-4 w-4 text-brand" />
|
||||
: <ImageIcon className="h-4 w-4 text-brand" />}
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{task.medium === 'video' ? '视频任务' : '图片任务'}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
规格版本 {task.specificationRevision} · {task.outputSummary.aspectRatio}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={task.status === 'succeeded'
|
||||
? 'success'
|
||||
: task.status === 'failed'
|
||||
? 'destructive'
|
||||
: active
|
||||
? 'warning'
|
||||
: 'secondary'}
|
||||
>
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl bg-surface-subtle/75 px-3 py-2.5">
|
||||
<span className="text-xs text-muted-foreground">{TASK_STAGE_LABELS[task.progress.stage]}</span>
|
||||
<span className="text-xs tabular-nums text-foreground">
|
||||
{task.progress.completedRequiredSteps}/{task.progress.totalRequiredSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">执行状态</p>
|
||||
<p className="mt-1 font-medium text-foreground">
|
||||
{EXECUTION_HEALTH_LABELS[task.executionHealth]}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">结果资产</p>
|
||||
<p className="mt-1 font-medium tabular-nums text-foreground">{task.resultAssetIds.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.issues.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded-xl bg-destructive/[0.04] p-3 text-xs leading-5 text-destructive">
|
||||
{task.issues.map((issue) => <p key={issue.code}>{issue.message}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignAssetsPanel({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const assets = useMemo(
|
||||
() => [...workspace.assets].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)),
|
||||
[workspace.assets],
|
||||
);
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file);
|
||||
await refreshWorkspace();
|
||||
toast.success('参考素材已上传');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '素材上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">项目资产</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">上传素材后,可在表单中指定参考角色、源图或视频首帧。</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
: <Upload className="mr-1.5 h-3.5 w-3.5" />}
|
||||
上传图片
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void upload(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{assets.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground transition-colors hover:border-brand/35 hover:bg-brand/[0.03]"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传第一张参考图
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{assets.map((asset) => (
|
||||
<article key={asset.assetId} className="group/asset overflow-hidden rounded-2xl border border-border/70 bg-background">
|
||||
<AssetPreview asset={asset} />
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{asset.role === 'uploaded' ? '上传素材' : '生成作品'}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{asset.width} × {asset.height}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="保存资产到电脑"
|
||||
className={cn(
|
||||
'rounded-lg p-1.5 text-muted-foreground transition-colors',
|
||||
'hover:bg-surface-subtle hover:text-foreground',
|
||||
)}
|
||||
onClick={() => {
|
||||
void saveImageWorkspaceAsset(asset).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : '资产保存失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignProductionPanel({ workspace }: { workspace: DesignWorkspace }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DesignPendingOperationsPanel />
|
||||
<DesignQuotePanel workspace={workspace} />
|
||||
<DesignTaskPanel tasks={workspace.tasks} />
|
||||
<DesignAssetsPanel workspace={workspace} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user