894 lines
37 KiB
TypeScript
894 lines
37 KiB
TypeScript
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>
|
||
);
|
||
}
|