Files
ele-HeiXiu/src/pages/home/hooks/useModelUI.ts
YoungestSongMo e4c9ae8fc6 feat: 三栏比例缩放 + SeedanceContent视频上传 + 本地存储基建 (0.0.22)
三栏布局
- ResizeObserver 等比例分配替代固定默认值恢复
- 修复拖拽+窗口调整竞态卡死(needsRecalcRef 延迟补算)
- containerRef.current 动态读取 DOM 替代闭包 el

Seedance2Content
- 从占位文本重构为内容编排控件(文本+图片/视频/音频上传)
- 根据 spec.image_files/video_files/audio_files 动态渲染上传区域
- 值 JSON 序列化 → 提交时转 ContentItem[](text/image_url/video_url/audio_url)

图片上传兼容视频文件
- buildAccept 根据 file_filter 区分 image/video 前缀
- createMediaBeforeUpload 动态放行 video/* MIME
- 公共模块 media-upload-utils.ts

本地存储基建
- sql.js (WASM SQLite) + safeStorage 加密持久化
- 8个IPC文件操作通道 + resolveSafe 路径穿越防护
- <userData>/heixiu-data/ 沙箱 + JWT用户隔离
- 7表DDL + 游标分页 + LRU媒体淘汰

其他
- 修复 errorMessageRef 渲染期写入警告
- 媒体预览回退直接URL(<img>无CORS限制)+ output_type类型补充
- CSP wasm-unsafe-eval + https: CDN媒体源
2026-06-18 19:22:28 +08:00

193 lines
6.7 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.
// ============================================================
// 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 '@/services/modules/models';
import { WIDGET_MAP, FALLBACK_WIDGET } from '../controls';
import type { WidgetProps } from '../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<WidgetProps>;
/** 显示标签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 的 valuePropNamecheckbox 用 '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<string, unknown>;
}
// ---------- 文件类型 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 依赖 srefsref 为空时它们应被禁用。
*/
function buildDependencyMap(
constraintsConfig: Record<string, unknown>,
): Map<string, string[]> {
const depMap = new Map<string, string[]>();
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<string, unknown> = {},
): UIFieldConfig[] {
return useMemo(() => {
if (!paramSchema || paramSchema.length === 0) return [];
const depMap = buildDependencyMap(constraintsConfig);
return paramSchema.map((item, index) => {
const spec = (item.spec || {}) as Record<string, unknown>;
const ui = (item.ui || {}) as Record<string, unknown>;
const widget = (ui.widget as string) || 'lineEdit';
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
const widgetConfig: Record<string, unknown> = {
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,
// seedance2Contentspec 中的媒体文件数量 + 上传供应商
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;
}