// ============================================================ // useModelUI — param_schema → UIFieldConfig[] 转换 Hook // // 职责: // - 遍历模型的 param_schema,解析 spec/ui 字段 // - 查 WIDGET_MAP 获取对应的 React 组件 // - 解析 constraints_config.requires 计算字段间依赖 // - 组装出可直接渲染的 UIFieldConfig 数组 // // ModelInputForm 只需遍历 fields 渲染 Form.Item 即可, // 不需要关心 param_schema 的解析逻辑。 // ============================================================ import { useMemo } from 'react'; import type { ComponentType } from 'react'; import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/left/controls'; import type { WidgetProps } from '../views/left/controls'; // ---------- 输出类型 ---------- /** * 单个字段的 UI 配置。 * * ModelInputForm 遍历此数组,每一项渲染一个 Form.Item。 */ export interface UIFieldConfig { /** 渲染顺序(param_schema 中的索引,保证稳定) */ index: number; /** 表单字段名(map_to),对应 Form.Item name */ name: string; /** API 声明的 widget 名称(如 "seedance2Content"),供提交时做特殊序列化 */ widgetName: string; /** 要渲染的 React 组件 */ component: ComponentType; /** 显示标签(ui.label → Form.Item label) */ label: string; /** 是否必填(spec.required → Form.Item rules) */ must: boolean; /** 默认值(spec.default → Form initialValues) */ defaultValue?: unknown; /** 提示文本(ui.tips) */ tips?: string; /** Form.Item 的 valuePropName(checkbox 用 'checked',其他用默认 'value') */ valuePropName?: string; /** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList) */ isFileWidget: boolean; /** * 条件依赖:当这些字段的值为空时,本字段应被禁用。 * * 来源于 constraints_config.requires 的逆向推导: * { if: "sref", then: ["sw", "sv"] } * → sw.dependsOn = ["sref"], sv.dependsOn = ["sref"] */ dependsOn?: string[]; /** 传给 widget 组件的配置(placeholder/options/min/max 等) */ widgetConfig: Record; } // ---------- 文件类型 widget 集合 ---------- const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList', 'videoInput']); // ---------- 约束解析 ---------- interface ConstraintRule { if: string; then: string[]; } /** * 从 constraints_config.requires 构建反向依赖表。 * * 输入: * [{ if: "sref", then: ["sw", "sv"] }] * * 输出: * { sw: ["sref"], sv: ["sref"] } * * 含义:sw 和 sv 依赖 sref,sref 为空时它们应被禁用。 */ function buildDependencyMap( constraintsConfig: Record, ): Map { const depMap = new Map(); const requires = constraintsConfig.requires as ConstraintRule[] | undefined; if (!requires || !Array.isArray(requires)) return depMap; for (const rule of requires) { if (!rule.if || !rule.then || !Array.isArray(rule.then)) continue; for (const target of rule.then) { const existing = depMap.get(target) || []; existing.push(rule.if); depMap.set(target, existing); } } return depMap; } // ---------- Hook ---------- /** * 根据模型的 param_schema 和 constraints_config 构建 UI 字段配置数组。 * * @param paramSchema — 来自 selectedModel.param_schema * @param constraintsConfig — 来自 selectedModel.constraints_config * @returns UIFieldConfig[] — 渲染就绪的字段配置列表 */ export function useModelUI( paramSchema: ModelsListResponseBody['param_schema'], constraintsConfig: Record = {}, ): UIFieldConfig[] { return useMemo(() => { if (!paramSchema || paramSchema.length === 0) return []; const depMap = buildDependencyMap(constraintsConfig); return paramSchema.map((item, index) => { const spec = (item.spec || {}) as Record; const ui = (item.ui || {}) as Record; const widget = (ui.widget as string) || 'lineEdit'; const component = WIDGET_MAP[widget] || FALLBACK_WIDGET; const widgetConfig: Record = { placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`, required: spec.required, enumValues: spec.enum, minValue: spec.min_value, maxValue: spec.max_value, step: spec.single_step, minLength: spec.min_length, maxLength: spec.max_length, // imageInput / imageList / audioList fileFilter: spec.file_filter, maxFileSizeMb: spec.max_file_size_mb, minFiles: spec.min_files, maxFiles: spec.max_files, enableSwitch: ui.enable_switch, // seedance2Content:spec 中的媒体文件数量 + 上传供应商 imageFiles: (spec.image_files as number) ?? 0, videoFiles: (spec.video_files as number) ?? 0, audioFiles: (spec.audio_files as number) ?? 0, uploadProvider: spec.upload_provider as string | undefined, }; const isFile = FILE_WIDGETS.has(widget); const isCheckbox = widget === 'checkbox'; const name = item.map_to; const dependsOn = depMap.get(name); return { index, name, widgetName: widget, component, label: (ui.label as string) || name, must: (spec.required as boolean) || false, defaultValue: spec.default, tips: (ui.tips as string) || undefined, valuePropName: isCheckbox ? 'checked' : undefined, isFileWidget: isFile, dependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined, widgetConfig, }; }); }, [paramSchema, constraintsConfig]); } /** 判断 widget 类型是否为文件上传类控件 */ export function isFileWidget(widgetName: string): boolean { return FILE_WIDGETS.has(widgetName); } // ---------- 值判空工具 ---------- /** * 判断表单值是否为"空"(用于依赖禁用判断)。 * * 注意:空数组 [] 不视为空。 * 对于 enable_switch 控件:undefined = 开关关闭, [] = 开关开启但未选文件。 * 配合 constraints_config.requires:开关开启即解锁依赖字段,无需等待文件上传。 */ export function isValueEmpty(value: unknown): boolean { if (value === undefined || value === null) return true; if (typeof value === 'string' && value.trim() === '') return true; if (typeof value === 'boolean' && !value) return true; return false; }