227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
// ===== 首页执行工作流管理 DSL 的解析与写回工具 =====
|
||
// 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。
|
||
// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .enumValues[i]
|
||
|
||
export interface HomeFormField {
|
||
path: string;
|
||
label: string;
|
||
type: string;
|
||
fieldType: string;
|
||
required: boolean;
|
||
value?: any;
|
||
default?: any;
|
||
options?: any[];
|
||
fieldConstraint?: any;
|
||
// 引用上游节点输出({ nodeId, field });有则首页只读展示引用,值由上游节点提供
|
||
valueSource?: any;
|
||
// 来源节点 id(未配置 valueSource 时默认引用开始节点,用于引用展示)
|
||
nodeId?: string;
|
||
// 上传字段的文件名(单文件为字符串,多文件为数组),执行时随 value 一并写回传给后端
|
||
fileName?: string | string[];
|
||
// 旧兼容:creation 时代 http body 中 showInForm 的子字段
|
||
__isHttpBodyChild?: boolean;
|
||
bodyKey?: string;
|
||
}
|
||
|
||
export function deepClone<T>(val: T): T {
|
||
return JSON.parse(JSON.stringify(val ?? null)) as T;
|
||
}
|
||
|
||
// 开始节点运行字段(runFormFields)→ MainContent 控件 type
|
||
function mapRunFieldType(f: any): string {
|
||
const ft = String(f?.fieldType || '');
|
||
const t = String(f?.type || 'string');
|
||
if (ft === 'number' || t === 'number') return 'number';
|
||
if (ft === 'boolean' || t === 'boolean' || ft === 'switch') return 'switch';
|
||
if (ft === 'select' && Array.isArray(f?.options)) return 'select';
|
||
if (ft === 'textarea') return 'textarea';
|
||
if (ft === 'uploadMultiple' || t === 'uploadMultiple' || f?.multiple) return 'uploadMultiple';
|
||
if (ft === 'upload' || ft === 'file' || ft === 'fileUpload' || t === 'upload' || t === 'file') return 'upload';
|
||
return 'input';
|
||
}
|
||
|
||
// 选项归一化:兼容 {label,value} / {key,value} / value.options 嵌套三种来源
|
||
function normalizeOptions(def: any): any[] | undefined {
|
||
let raw: any[] | undefined;
|
||
if (Array.isArray(def.options) && def.options.length > 0) raw = def.options;
|
||
else if (def.value && typeof def.value === 'object' && !Array.isArray(def.value) && Array.isArray(def.value.options))
|
||
raw = def.value.options;
|
||
else if (Array.isArray(def.enumValues) && def.enumValues.length > 0) raw = def.enumValues;
|
||
if (!raw) return undefined;
|
||
return raw.map((o: any) => {
|
||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||
const val = o.value !== undefined ? o.value : o.key;
|
||
const label = o.label !== undefined ? o.label : o.key;
|
||
return { label: label === undefined ? String(val) : label, value: val };
|
||
}
|
||
return { label: String(o), value: o };
|
||
});
|
||
}
|
||
|
||
// 开始节点(__start__):读取 outputConfig 中保存的 runFormFields(工作流管理汇总的
|
||
// model 勾选字段 + form 自定义字段),转首页扁平表单字段。条目已有
|
||
// field/label/type/fieldType/required/value/defaultValue/fieldConstraint/valueSource/multiple,
|
||
// 直接归一化映射,无需再走 model/form 各自结构。
|
||
function collectStartNodeFields(node: any): HomeFormField[] {
|
||
const out = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
|
||
return out
|
||
.filter((o: any) => o && typeof o === 'object' && (o.field !== undefined || o.path !== undefined))
|
||
.map((o: any) => {
|
||
const field = o.field !== undefined ? o.field : o.path;
|
||
return {
|
||
path: field,
|
||
label: o.label || String(field),
|
||
type: mapRunFieldType(o),
|
||
fieldType: o.fieldType || o.type || 'string',
|
||
required: Boolean(o.required),
|
||
value: o.value !== undefined ? o.value : o.defaultValue,
|
||
default: o.defaultValue,
|
||
options: normalizeOptions(o),
|
||
fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
|
||
valueSource: o.valueSource && typeof o.valueSource === 'object' ? o.valueSource : undefined,
|
||
nodeId: o.nodeId,
|
||
fileName: o.fileName,
|
||
};
|
||
});
|
||
}
|
||
|
||
// 主入口:DSL 节点 → 首页扁平表单字段。开始节点为唯一表单源
|
||
// (runFormFields 汇总了各 model 节点勾选字段 + form 节点自定义字段),其余节点无运行时字段。
|
||
export function collectHomeFormFields(node: any): HomeFormField[] {
|
||
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') return [];
|
||
return collectStartNodeFields(node);
|
||
}
|
||
|
||
// 上传类型空值:未选文件(空字符串 / 空数组 / null)
|
||
const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0);
|
||
|
||
// 首页表单值写回开始节点(唯一表单源)outputConfig 对应条目 value:
|
||
// 1. 引用其他节点输出(valueSource)的字段只读展示,值由上游节点运行时提供,
|
||
// 清空保存时的值快照(避免后端误用过期值)
|
||
// 2. 上传类型字段未选文件 → 从开始节点运行字段移除,不向后端提交空字段
|
||
export function applyHomeFormValues(nodes: any[], formValues: Record<string, any>, formFileNames?: Record<string, any>): void {
|
||
if (!Array.isArray(nodes)) return;
|
||
const startNode = nodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__');
|
||
if (!startNode || !Array.isArray(startNode.outputConfig)) return;
|
||
for (let i = startNode.outputConfig.length - 1; i >= 0; i--) {
|
||
const f = startNode.outputConfig[i];
|
||
if (!f || typeof f !== 'object') continue;
|
||
if (f.valueSource && typeof f.valueSource === 'object') {
|
||
// 引用其他节点输出的字段:值由上游节点运行时提供,清空保存时的快照,避免后端误用过期值
|
||
if ('value' in f) delete f.value;
|
||
continue;
|
||
}
|
||
const field = f.field !== undefined ? f.field : f.path;
|
||
if (field === undefined || field === '') continue;
|
||
const key = `${startNode.id || startNode.nodeCode}|${field}`;
|
||
const val = formValues[key];
|
||
// 仅跳过未初始化的 key;null(如数字清空)也要写回
|
||
if (val === undefined) continue;
|
||
// 上传类型字段未选文件 → 不提交,从开始节点运行字段移除
|
||
const ft = String(f.fieldType || f.type || '');
|
||
const isUpload = ft === 'upload' || ft === 'uploadMultiple' || ft === 'fileUpload' || ft === 'file';
|
||
if (isUpload && isEmptyUploadValue(val)) {
|
||
startNode.outputConfig.splice(i, 1);
|
||
continue;
|
||
}
|
||
f.value = val;
|
||
// 上传字段附带文件名:与 value 类型对齐(单文件字符串/多文件数组),随开始节点一并传给后端
|
||
if (formFileNames && formFileNames[key] !== undefined) {
|
||
f.fileName = formFileNames[key];
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模板完整性校验:返回缺模型等缺失项,供补全弹窗提示
|
||
export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeName: string; reason: string }[] {
|
||
const nodes = Array.isArray(flowContent?.nodes) ? flowContent.nodes : [];
|
||
const missing: { nodeId: string; nodeName: string; reason: string }[] = [];
|
||
for (const n of nodes) {
|
||
if (String(n?.nodeCode || '').toLowerCase() === 'model' && !n?.modelConfig?.modelId) {
|
||
missing.push({ nodeId: n?.id || '', nodeName: n?.name || n?.nodeCode || '模型节点', reason: '未选择模型' });
|
||
}
|
||
}
|
||
return missing;
|
||
}
|
||
|
||
// ===== 执行产出提取 =====
|
||
// 会话内展示工作流执行产出(文件卡片):来自 getExecutionDetail 返回的 WorkflowItem,
|
||
// 合并 fileUrls / outputParams 各 value / resultUrl,去重后归一为 { url, name, type, backendId }。
|
||
// type 用于预览模式判定(image/video/audio/text/file),backendId 为执行记录 id(删除产出用)。
|
||
|
||
export interface WorkflowOutput {
|
||
url: string;
|
||
name: string;
|
||
type: string;
|
||
backendId: string;
|
||
}
|
||
|
||
// 按文件扩展名推断预览类型
|
||
function guessFileType(url: string): string {
|
||
const clean = String(url).split('?')[0] || '';
|
||
const ext = (clean.split('.').pop() || '').toLowerCase();
|
||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image';
|
||
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
|
||
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
|
||
if (['txt', 'md', 'json', 'log', 'csv'].includes(ext)) return 'text';
|
||
return 'file';
|
||
}
|
||
|
||
export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowOutput[] {
|
||
if (!data) return [];
|
||
const seen = new Set<string>();
|
||
const outputs: WorkflowOutput[] = [];
|
||
const push = (rawUrl: any) => {
|
||
if (typeof rawUrl !== 'string') return;
|
||
const url = rawUrl.trim();
|
||
if (!url || seen.has(url)) return;
|
||
seen.add(url);
|
||
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
|
||
outputs.push({
|
||
url,
|
||
name,
|
||
type: guessFileType(url),
|
||
backendId: backendId || data.id || '',
|
||
});
|
||
};
|
||
if (Array.isArray(data.fileUrls)) data.fileUrls.forEach(push);
|
||
if (Array.isArray(data.outputParams)) {
|
||
data.outputParams.forEach((o: any) => {
|
||
if (!o || typeof o !== 'object') return;
|
||
Object.values(o).forEach((v) => push(v));
|
||
});
|
||
}
|
||
if (data.resultUrl) push(data.resultUrl);
|
||
return outputs;
|
||
}
|
||
|
||
// 从事件携带的文件 URL 数组(flow_complete 的 data.resultFileUrls)直接构建产出列表:
|
||
// 不依赖执行详情的异步查询,type 同样按扩展名推断,去重后归一为 { url, name, type, backendId }。
|
||
// 用于完成事件即时渲染产出卡片,删除时 backendId 为空则跳过删除接口(安全降级)。
|
||
export function buildOutputsFromUrls(urls: any, backendId?: string): WorkflowOutput[] {
|
||
// 兼容两种入参:字符串(逗号拼接多 URL)或数组(元素本身可能是逗号拼接串);
|
||
// 合法 URL 不含裸逗号,统一按逗号拆分后逐条归一化,避免把整串误当单个产出
|
||
const arr: string[] = typeof urls === 'string' ? [urls] : Array.isArray(urls) ? urls : [];
|
||
const seen = new Set<string>();
|
||
const outputs: WorkflowOutput[] = [];
|
||
for (const u of arr) {
|
||
if (typeof u !== 'string') continue;
|
||
const parts = u
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
for (const url of parts) {
|
||
if (seen.has(url)) continue;
|
||
seen.add(url);
|
||
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
|
||
outputs.push({
|
||
url,
|
||
name,
|
||
type: guessFileType(url),
|
||
backendId: backendId || '',
|
||
});
|
||
}
|
||
}
|
||
return outputs;
|
||
}
|