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; }; 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)[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 (
{decision?.resolution === 'proposed' && hasValue(value) && ( )} {hasValue(value) && ( )}
); } 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' ? (