Files
makelore/src/pages/ImageCanvas/DesignFormPane.tsx

894 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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