模板字段约束实际挂在 constraint 键(min/max/uploadRules 等),原读取 fieldConstraint 键导致模型勾选字段的约束丢失。新增 toFieldConstraint 归一化(min→minValue、max→maxValue、uploadRules/accept→fileTypes/maxFileSize/maxFileCount),并将格式分隔符统一为英文逗号,修复 wan 模型音频上传被误拦。 Co-Authored-By: Claude <noreply@anthropic.com>
510 lines
24 KiB
TypeScript
510 lines
24 KiB
TypeScript
// 模型参数表单共用工具(workflow 独立实现,不依赖内容创作)
|
||
|
||
// 与项目一致的深拷贝方式
|
||
export function deepClone<T>(val: T): T {
|
||
return JSON.parse(JSON.stringify(val ?? null)) as T;
|
||
}
|
||
|
||
// 按类型生成叶子字段空值
|
||
export function defaultLeafValue(def: any): any {
|
||
const t = def?.type || 'string';
|
||
const ft = def?.fieldType || '';
|
||
if (t === 'boolean' || ft === 'switch') return false;
|
||
if (t === 'number' || ft === 'number') return 0;
|
||
return '';
|
||
}
|
||
|
||
// 归一化数组元素:元素为 { type:'object', attrs } 包装时,递归预填 attrs 子字段
|
||
export function normalizeArrayItem(item: any): any {
|
||
if (item && typeof item === 'object') {
|
||
if (item.type === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
|
||
Object.values(item.attrs).forEach(normalizeFieldDef);
|
||
}
|
||
}
|
||
return item;
|
||
}
|
||
|
||
// 归一化字段定义:用 defaultValue 预填 value;递归处理 object/array 子树
|
||
export function normalizeFieldDef(def: any): any {
|
||
if (!def || typeof def !== 'object') return def;
|
||
const t = def.type;
|
||
if (t === 'object') {
|
||
const attrs = def.attrs;
|
||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||
Object.values(attrs).forEach(normalizeFieldDef);
|
||
}
|
||
if (def.value === undefined || def.value === null) {
|
||
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : {};
|
||
}
|
||
} else if (t === 'array') {
|
||
if (Array.isArray(def.value)) {
|
||
def.value.forEach(normalizeArrayItem);
|
||
} else if (Array.isArray(def.defaultValue)) {
|
||
def.value = def.defaultValue.map((it: any) => normalizeArrayItem(deepClone(it)));
|
||
} else {
|
||
def.value = [];
|
||
}
|
||
} else {
|
||
if (def.value === undefined || def.value === null) {
|
||
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : defaultLeafValue(def);
|
||
}
|
||
}
|
||
return def;
|
||
}
|
||
|
||
// 归一化整个 requestBodyMapping 参数模板对象
|
||
export function normalizeModelParams(params: any): Record<string, any> {
|
||
if (!params || typeof params !== 'object') return {};
|
||
Object.values(params).forEach(normalizeFieldDef);
|
||
return params;
|
||
}
|
||
|
||
// 剔除 isForm === false(只读)字段:不在节点表单中显示,且不随工作流保存。
|
||
// 级联规则:容器若内部无可编辑子级则无意义,一并剔除
|
||
// 1. 字段自身 isForm === false → 整体剔除
|
||
// 2. object:剔除子字段后 attrs 为空(无可编辑子字段)→ object 一并剔除
|
||
// 3. array:过滤「子字段全部只读」的元素模板;无可编辑模板 → array 一并剔除
|
||
// 4. array 已添加元素内部被清空 → 一并移除
|
||
export function stripReadonlyFields(params: any): any {
|
||
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
|
||
for (const key of Object.keys(params)) {
|
||
const def = params[key];
|
||
if (!def || typeof def !== 'object') continue;
|
||
// 自身标记为只读 → 整体删除
|
||
if (def.isForm === false) {
|
||
delete params[key];
|
||
continue;
|
||
}
|
||
if (def.type === 'object') {
|
||
const attrs = def.attrs;
|
||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||
stripReadonlyFields(attrs);
|
||
// 子字段全部只读 → object 无意义,一并剔除
|
||
if (Object.keys(attrs).length === 0) {
|
||
delete params[key];
|
||
}
|
||
}
|
||
} else if (def.type === 'array') {
|
||
let hasEditable = false;
|
||
// 单一元素模板(attrs 为 object)
|
||
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) {
|
||
stripReadonlyFields(def.attrs);
|
||
if (Object.keys(def.attrs).length > 0) hasEditable = true;
|
||
}
|
||
// 多元素模板:优先 enumValues(与 ModelField.arrayTemplates 一致),其次 attrs 数组;过滤子字段全部只读的空模板
|
||
const isEnumValues = Array.isArray(def.enumValues) && def.enumValues.length > 0;
|
||
const isAttrsArray = Array.isArray(def.attrs) && def.attrs.length > 0;
|
||
const templates = isEnumValues ? def.enumValues : isAttrsArray ? def.attrs : [];
|
||
const keptTemplates = templates.filter((t: any) => {
|
||
if (t && typeof t === 'object' && t.attrs && typeof t.attrs === 'object' && !Array.isArray(t.attrs)) {
|
||
stripReadonlyFields(t.attrs);
|
||
return Object.keys(t.attrs).length > 0;
|
||
}
|
||
return true;
|
||
});
|
||
// 有模板被剔除(子字段全只读)→ 数组索引被压缩;给保留的模板补记原始位置 __originIndex,
|
||
// 供 buildModelRequestParamsPath 生成后端契约的 enumValues 原始索引(幂等:已带则不覆盖)
|
||
if (keptTemplates.length !== templates.length) {
|
||
templates.forEach((t: any, originIdx: number) => {
|
||
if (t && typeof t === 'object' && keptTemplates.includes(t) && t.__originIndex === undefined) {
|
||
t.__originIndex = originIdx;
|
||
}
|
||
});
|
||
}
|
||
if (isEnumValues) def.enumValues = keptTemplates;
|
||
if (isAttrsArray) def.attrs = keptTemplates;
|
||
if (keptTemplates.length > 0) hasEditable = true;
|
||
// 已添加元素内部清理后为空的 → 一并移除
|
||
if (Array.isArray(def.value)) {
|
||
def.value.forEach((it: any) => {
|
||
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
|
||
stripReadonlyFields(it.attrs);
|
||
}
|
||
});
|
||
def.value = def.value.filter((it: any) => {
|
||
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
|
||
return Object.keys(it.attrs).length > 0;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
// 无可编辑内容 → array 无意义,一并剔除
|
||
if (!hasEditable) {
|
||
delete params[key];
|
||
}
|
||
}
|
||
}
|
||
return params;
|
||
}
|
||
|
||
// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
|
||
// (enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
|
||
// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
|
||
// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
|
||
export function removeArrayValueInstances(params: any): any {
|
||
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
|
||
// 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
|
||
if (typeof params.type === 'string') {
|
||
if (params.type === 'array') {
|
||
delete params.value;
|
||
if (params.attrs && typeof params.attrs === 'object') {
|
||
if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
|
||
else removeArrayValueInstances(params.attrs);
|
||
}
|
||
if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
|
||
} else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
|
||
removeArrayValueInstances(params.attrs);
|
||
}
|
||
return params;
|
||
}
|
||
// 字段定义容器 { key: fieldDef }:逐个字段递归
|
||
for (const key of Object.keys(params)) {
|
||
removeArrayValueInstances(params[key]);
|
||
}
|
||
return params;
|
||
}
|
||
|
||
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
|
||
|
||
// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
|
||
// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
|
||
// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
|
||
function arrayTemplatesOf(def: any): any[] {
|
||
if (!def || typeof def !== 'object') return [];
|
||
if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
|
||
if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
|
||
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
|
||
return [];
|
||
}
|
||
|
||
// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content")
|
||
export interface ExposedField {
|
||
path: string;
|
||
label: string;
|
||
fieldType: string;
|
||
type: string;
|
||
required: boolean;
|
||
options?: any[];
|
||
value?: any;
|
||
defaultValue?: any; // 默认值:首页初始化/回显用
|
||
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
|
||
valueSource?: any; // 引用上游节点输出([{ nodeId, field }] 数组,支持多选);有则首页只读展示
|
||
multiple?: boolean; // 多文件上传标记
|
||
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
|
||
}
|
||
|
||
// 将模型模板字段级约束(constraint,JsonEditor 风格:min/max/uploadRules/accept/maxSize/maxCount 等)
|
||
// 归一化为首页运行表单消费的 fieldConstraint(minValue/maxValue/fileTypes/maxFileSize/maxFileCount)。
|
||
// 模板字段约束实际挂在 constraint 键下(如 video_url.url 的 uploadRules、duration 的 min/numberType),
|
||
// 与 collectExposedFields 原读取的 fieldConstraint 键不一致,需在此转换后随 modelFormFields 透传首页。
|
||
function toFieldConstraint(constraint: any): any {
|
||
if (!constraint || typeof constraint !== 'object') return undefined;
|
||
const out: Record<string, any> = {};
|
||
// 数字 min/max → 首页 el-input-number 的 minValue/maxValue
|
||
if (constraint.min !== undefined && constraint.min !== null) out.minValue = constraint.min;
|
||
if (constraint.max !== undefined && constraint.max !== null) out.maxValue = constraint.max;
|
||
// 上传约束:uploadRules 数组取第一条;兼容顶层 accept/maxSize/maxCount 单值形式
|
||
const rules = Array.isArray(constraint.uploadRules) ? constraint.uploadRules.filter((r: any) => r && typeof r === 'object') : [];
|
||
const rule = rules[0] || null;
|
||
const fileTypes = rule?.format ?? constraint.accept;
|
||
if (fileTypes !== undefined && fileTypes !== null && fileTypes !== '') {
|
||
// 模板 format 可能用中文顿号/空白分隔(如 wan 模型 "wav、mp3"),
|
||
// 统一转英文逗号,保证首页 split(',') 与扩展名匹配正确
|
||
out.fileTypes = String(fileTypes)
|
||
.replace(/[、\s]+/g, ',')
|
||
.replace(/,+/g, ',')
|
||
.replace(/^,|,$/g, '');
|
||
}
|
||
const maxSize = rule?.maxSize ?? constraint.maxSize;
|
||
if (maxSize !== undefined && maxSize !== null) out.maxFileSize = maxSize;
|
||
const maxCount = rule?.maxCount ?? constraint.maxCount;
|
||
if (maxCount !== undefined && maxCount !== null) out.maxFileCount = maxCount;
|
||
return Object.keys(out).length > 0 ? out : undefined;
|
||
}
|
||
|
||
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
|
||
// 路径文法(与 restoreRuntimeShow 共用):
|
||
// object 子字段 → 父路径 + ".attrs." + 子key
|
||
// array 模板元素 → 父路径 + ".enumValues[i]"
|
||
// 原始值数组元素(非 object 包装)不可勾选,跳过
|
||
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
||
if (!params || typeof params !== 'object') return [];
|
||
const result: ExposedField[] = [];
|
||
for (const key of Object.keys(params)) {
|
||
const def = params[key];
|
||
if (!def || typeof def !== 'object') continue;
|
||
const path = prefix ? `${prefix}.${key}` : key;
|
||
const t = def.type;
|
||
if (t === 'object') {
|
||
const attrs = def.attrs;
|
||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||
result.push(...collectExposedFields(attrs, `${path}.attrs`));
|
||
}
|
||
} else if (t === 'array') {
|
||
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
|
||
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
|
||
const templates = arrayTemplatesOf(def);
|
||
templates.forEach((tpl: any, i: number) => {
|
||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||
// 索引与 collectAllLeafFields 一致:优先用 strip 时记录的原始模板索引 __originIndex,
|
||
// 保证 modelFormFields 与 modelRequestParamsPath 的 path 索引对齐后端模型完整定义
|
||
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
|
||
result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
|
||
}
|
||
});
|
||
} else if (def.runtimeShow === true) {
|
||
// 叶子且已勾选
|
||
result.push({
|
||
path,
|
||
label: def.label || key,
|
||
fieldType: def.fieldType || 'string',
|
||
type: t || 'string',
|
||
required: !!def.required,
|
||
options: Array.isArray(def.options) ? def.options : undefined,
|
||
value: def.value,
|
||
defaultValue: def.defaultValue,
|
||
// 优先透传已有的 fieldConstraint(首页格式);模板字段约束在 constraint 键时归一化补全
|
||
fieldConstraint:
|
||
(def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined) ??
|
||
toFieldConstraint(def.constraint),
|
||
valueSource: (() => {
|
||
const vs = def.valueSource;
|
||
// 契约统一数组;兼容旧 DSL 单对象 { nodeId, field }
|
||
return Array.isArray(vs) ? vs : vs && typeof vs === 'object' ? [vs] : undefined;
|
||
})(),
|
||
multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
|
||
});
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ===== 模型请求参数扁平清单(modelRequestParamsPath)=====
|
||
// 后端契约:保存时与 modelRequestParams 平级新增 modelRequestParamsPath,
|
||
// 为嵌套模型参数树的全部叶子字段扁平清单(path 文法与 collectExposedFields 一致),
|
||
// 后端按 path 重组请求参数。value 保留当前值;valueSource 规则(统一数组 [{ nodeId, field }]):
|
||
// - 已有引用配置(用户在设计器配置「引用上游」)→ 原样保留数组
|
||
// - 无引用但勾选「表单展示」(runtimeShow=true)→ 补 [{ nodeId: 开始节点id, field: path }]
|
||
// - 其余 → 不带 valueSource
|
||
export interface ModelRequestParamsPathItem {
|
||
path: string;
|
||
// 叶子字段中文名(回显/展示用;无 label 时兜底 path 最后一段字段名)
|
||
label: string;
|
||
type: string;
|
||
required: boolean;
|
||
value: any;
|
||
valueSource?: any;
|
||
}
|
||
|
||
// 收集嵌套树的全部叶子字段(含数组模板元素,不过滤 runtimeShow),返回 { path, def }
|
||
function collectAllLeafFields(params: any, prefix = ''): { path: string; def: any }[] {
|
||
if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
|
||
const result: { path: string; def: any }[] = [];
|
||
for (const key of Object.keys(params)) {
|
||
const def = params[key];
|
||
if (!def || typeof def !== 'object') continue;
|
||
const path = prefix ? `${prefix}.${key}` : key;
|
||
const t = def.type;
|
||
if (t === 'object') {
|
||
const attrs = def.attrs;
|
||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||
result.push(...collectAllLeafFields(attrs, `${path}.attrs`));
|
||
}
|
||
} else if (t === 'array') {
|
||
// 数组字段遍历模板(enumValues 优先,其次 attrs 数组),path 用 enumValues[i] 定位模板,
|
||
// 与 ModelField 渲染 / collectExposedFields 文法一致(值/勾选均落在模板上,不依赖 value 实例)。
|
||
// 模板带 __originIndex(strip 时记录的原始位置)时用原始索引,保证与后端模型完整定义的 enumValues 编号一致
|
||
const templates = arrayTemplatesOf(def);
|
||
templates.forEach((tpl: any, i: number) => {
|
||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
|
||
result.push(...collectAllLeafFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
|
||
}
|
||
});
|
||
} else {
|
||
result.push({ path, def });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// 生成 modelRequestParamsPath 扁平清单
|
||
// 所有叶子字段(含对象/数组内部)都支持「引用上游」,valueSource 原样保留;
|
||
// 与编辑器「引用上级输出与表单展示同级」语义一致
|
||
export function buildModelRequestParamsPath(
|
||
params: any,
|
||
startNodeId = '',
|
||
// 节点输出索引(id → 节点名 + 字段 label),用于补全 valueSource 的引用来源 label
|
||
outputIndex?: Map<string, { name: string; fields: Map<string, string> }>
|
||
): ModelRequestParamsPathItem[] {
|
||
const fields = collectAllLeafFields(params);
|
||
// 引用来源 label:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
|
||
const resolveRefLabel = (nodeId: string, field: string): string => {
|
||
const info = outputIndex?.get(nodeId);
|
||
if (!info) return field;
|
||
return info.fields.get(field) || field;
|
||
};
|
||
return fields.map(({ path, def }) => {
|
||
let valueSource: any;
|
||
if (Array.isArray(def.valueSource)) {
|
||
// 引用上游:前端编辑态 [{ nodeId, field }] 数组,逐项补 label
|
||
valueSource = def.valueSource.map((r: any) =>
|
||
r && r.nodeId && r.field
|
||
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
|
||
: r
|
||
);
|
||
} else if (def.valueSource && typeof def.valueSource === 'object') {
|
||
// 兼容旧 DSL 单对象 { nodeId, field }
|
||
const r = def.valueSource as any;
|
||
valueSource = [
|
||
r && r.nodeId && r.field
|
||
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
|
||
: r,
|
||
];
|
||
} else if (def.runtimeShow === true && startNodeId) {
|
||
// 勾选「表单展示」:补后端契约数组(指向开始节点,label 取开始节点字段名)
|
||
valueSource = [{ nodeId: startNodeId, field: path, label: resolveRefLabel(startNodeId, path) }];
|
||
}
|
||
return {
|
||
path,
|
||
label: def.label || path.split('.').pop() || '',
|
||
type: def.type || 'string',
|
||
required: Boolean(def.required),
|
||
value: def.value ?? '',
|
||
...(valueSource ? { valueSource } : {}),
|
||
};
|
||
});
|
||
}
|
||
|
||
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
|
||
// startNodeId:勾选「表单展示」的字段保存时带 valueSource 指向开始节点(后端契约);
|
||
// 回显时该 valueSource 无编辑器引用语义,清除避免 ModelField 误判引用/误删
|
||
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
|
||
for (const f of fields) {
|
||
if (!f || typeof f.path !== 'string') continue;
|
||
const cur = resolvePath(params, f.path);
|
||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||
cur.runtimeShow = true;
|
||
if (startNodeId && cur.valueSource && typeof cur.valueSource === 'object') {
|
||
// 移除指向开始节点(表单展示标记)的引用;兼容旧 DSL 单对象(统一数组化),全部移除后删除整个 valueSource
|
||
const arr = Array.isArray(cur.valueSource) ? cur.valueSource : [cur.valueSource];
|
||
const keep = arr.filter((r: any) => !(r && r.nodeId === startNodeId));
|
||
if (keep.length === 0) delete cur.valueSource;
|
||
else cur.valueSource = keep;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 勾选「表单展示」的字段:保存前按后端契约补 valueSource={nodeId:开始节点id, field:path},
|
||
// 标记字段值来自主工作流开始节点(首页表单)。startNodeId 为空(无开始节点)时跳过。
|
||
export function attachFormFieldValueSources(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0 || !startNodeId) return;
|
||
for (const f of fields) {
|
||
if (!f || typeof f.path !== 'string') continue;
|
||
const cur = resolvePath(params, f.path);
|
||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||
// 已有指向上游节点的真实引用时保持互斥:不覆盖为开始节点标记,避免引用丢失
|
||
const vs = cur.valueSource;
|
||
const hasRealRef = Array.isArray(vs)
|
||
? vs.some((r: any) => r && r.nodeId && r.nodeId !== startNodeId)
|
||
: !!vs && typeof vs === 'object' && vs.nodeId && vs.nodeId !== startNodeId;
|
||
if (!hasRealRef) {
|
||
cur.valueSource = [{ nodeId: startNodeId, field: f.path }];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按路径走回 params 树;解析失败(schema 变更导致字段缺失)返回 undefined
|
||
function resolvePath(params: any, path: string): any {
|
||
if (!path) return undefined;
|
||
const segments = path.split('.');
|
||
let cur = params;
|
||
for (const seg of segments) {
|
||
if (cur === undefined || cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
|
||
if (seg === 'attrs') {
|
||
cur = cur.attrs;
|
||
if (!cur || typeof cur !== 'object') return undefined;
|
||
} else {
|
||
const mv = seg.match(/^value\[(\d+)\]$/);
|
||
if (mv) {
|
||
const idx = Number(mv[1]);
|
||
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
|
||
cur = cur.value[idx];
|
||
continue;
|
||
}
|
||
const me = seg.match(/^enumValues\[(\d+)\]$/);
|
||
if (me) {
|
||
const idx = Number(me[1]);
|
||
const templates = arrayTemplatesOf(cur);
|
||
// 路径索引可能为 strip 压缩前记录的原始索引(__originIndex),优先按原始索引匹配;
|
||
// 未带 __originIndex 时退化为按数组位置匹配(未压缩场景)
|
||
const byOrigin = templates.find((t: any) => t && typeof t.__originIndex === 'number' && t.__originIndex === idx);
|
||
if (byOrigin !== undefined) cur = byOrigin;
|
||
else if (idx < templates.length) cur = templates[idx];
|
||
else return undefined;
|
||
continue;
|
||
}
|
||
cur = cur[seg];
|
||
}
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
// 深度比较(JSON 序列化方式),供 index.vue 的"仅变更才写"守卫使用
|
||
export function isEqual(a: any, b: any): boolean {
|
||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||
}
|
||
|
||
// ===== 模型返回参数中文 label(引用展示用)=====
|
||
// 模型 responseMapping(响应映射:路径→中文)支持两种结构:普通对象 {路径: 中文} 与
|
||
// { type, attrs/value, label } 包裹格式。独立实现平铺(不依赖 settings/modelConfigV2),
|
||
// 供保存 modelResponseBodyMapping 时把中文描述填入 value,引用上级节点输出展示中文而非英文 key。
|
||
function flattenResponseLabelMap(mapping: any, basePath = '', out: Record<string, string>): void {
|
||
if (!mapping || typeof mapping !== 'object') return;
|
||
// { type, attrs/value } 包裹格式:叶子节点带 label
|
||
if (typeof mapping.type === 'string' && ['string', 'number', 'boolean', 'null', 'object', 'array'].includes(mapping.type)) {
|
||
const jtype = mapping.type;
|
||
const dataKey = jtype === 'object' || jtype === 'array' ? 'attrs' : 'value';
|
||
if (jtype === 'object' && dataKey in mapping && mapping[dataKey] && typeof mapping[dataKey] === 'object' && !Array.isArray(mapping[dataKey])) {
|
||
for (const [key, val] of Object.entries(mapping[dataKey])) {
|
||
flattenResponseLabelMap(val, basePath ? `${basePath}.attrs.${key}` : key, out);
|
||
}
|
||
} else if (jtype === 'array' && dataKey in mapping && Array.isArray(mapping[dataKey])) {
|
||
mapping[dataKey].forEach((item: any, idx: number) => {
|
||
flattenResponseLabelMap(item, `${basePath}.attrs[${idx}]`, out);
|
||
});
|
||
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
|
||
if (mapping.label) out[basePath] = String(mapping.label);
|
||
}
|
||
return;
|
||
}
|
||
// 普通对象(非包裹格式)
|
||
if (!Array.isArray(mapping)) {
|
||
for (const [key, val] of Object.entries(mapping)) {
|
||
const childPath = basePath ? `${basePath}.${key}` : key;
|
||
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||
flattenResponseLabelMap(val, childPath, out);
|
||
} else if (typeof val === 'string' && val) {
|
||
out[childPath] = val;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 用模型 responseMapping 的中文描述填充 modelResponseBodyMapping 的 value:
|
||
// { key: '' } → { key: '中文' }。结构不变(对象 key→string),无对应中文时保留原 value(兜底 key)。
|
||
export function enrichResponseBodyMapping(body: any, mapping: any): any {
|
||
if (!body || typeof body !== 'object' || Array.isArray(body)) return body ?? null;
|
||
const labelMap: Record<string, string> = {};
|
||
flattenResponseLabelMap(mapping, '', labelMap);
|
||
const out: Record<string, string> = {};
|
||
for (const k of Object.keys(body)) {
|
||
const raw = typeof body[k] === 'string' ? body[k] : '';
|
||
out[k] = labelMap[k] || raw || k;
|
||
}
|
||
return out;
|
||
}
|