feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一
## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
192
src/modules/home/hooks/useModelUI.ts
Normal file
192
src/modules/home/hooks/useModelUI.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// ============================================================
|
||||
// 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<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 的 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<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 依赖 sref,sref 为空时它们应被禁用。
|
||||
*/
|
||||
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,
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user