feat(canvas): cut over design agent to living form v2

This commit is contained in:
2026-08-30 17:00:31 +08:00
parent f245603923
commit b0b5a602b5
35 changed files with 5729 additions and 13986 deletions

View 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>
);
}

View 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>
);
}

View 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