工作流绘制链路加固:校验增强、悬垂引用清理与保存连续性修复
- validateFlowConstraints 增加自环/model 必选模型/孤立节点校验,onConnect 拦截自环连线 - 删除节点后主动清理其余节点指向被删节点的 valueSource,避免保存脏 DSL - 保存成功后保留工作流 id 支持连续保存,新建回填 id,避免重复副本 - attachFormFieldValueSources 不再覆盖已有上游引用,保持引用/表单展示互斥 - form 自定义字段引入不可变 field,重命名 label 不影响下游引用 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
|
||||
export interface FormField {
|
||||
// 不可变字段标识(保存/下游引用的 key);label 仅为展示名,重命名不影响已建立的引用。
|
||||
// 旧数据缺省时退化用 label,保存一次后 field 固化不再随 label 变化
|
||||
field?: string;
|
||||
label: string;
|
||||
type: string; // input | number | textarea | switch | uploadMultiple
|
||||
value: any; // 默认值(uploadMultiple 时存文件 URL 数组或空)
|
||||
@@ -67,8 +70,12 @@ const sync = () => {
|
||||
emit('update:modelValue', clone(fields.value));
|
||||
};
|
||||
|
||||
// 字段唯一标识生成:时间戳 + 递增序号,避免同节点内字段 field 冲突
|
||||
let fieldSeq = 0;
|
||||
const genFieldKey = () => `f_${Date.now().toString(36)}_${++fieldSeq}`;
|
||||
|
||||
const addField = () => {
|
||||
fields.value.push({ label: '', type: 'input', value: '', required: false });
|
||||
fields.value.push({ field: genFieldKey(), label: '', type: 'input', value: '', required: false });
|
||||
sync();
|
||||
};
|
||||
|
||||
|
||||
@@ -373,7 +373,14 @@ export function attachFormFieldValueSources(params: any, fields: ExposedField[]
|
||||
if (!f || typeof f.path !== 'string') continue;
|
||||
const cur = resolvePath(params, f.path);
|
||||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||
cur.valueSource = [{ nodeId: startNodeId, field: f.path }];
|
||||
// 已有指向上游节点的真实引用时保持互斥:不覆盖为开始节点标记,避免引用丢失
|
||||
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 }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,47 @@ const selectedSkillData = ref<any>(null);
|
||||
const showWorkflowSelector = ref(false);
|
||||
const selectedWorkflowData = ref<any>(null);
|
||||
|
||||
// 过滤对象(叶子 def / formConfig 条目等)上指向已删除节点的 valueSource 引用;返回是否有变化。
|
||||
// 兼容单对象与数组两种 valueSource 形态;并递归 modelRequestParams 深树(对象 attrs / 数组模板 enumValues / value 实例)。
|
||||
const filterValueSourceRefs = (obj: any, deletedNodeId: string): boolean => {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
let changed = false;
|
||||
const vs = obj.valueSource;
|
||||
if (vs && typeof vs === 'object') {
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const keep = arr.filter((r: any) => r && r.nodeId !== deletedNodeId);
|
||||
if (keep.length !== arr.length) {
|
||||
if (keep.length === 0) delete obj.valueSource;
|
||||
else obj.valueSource = keep;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (obj.attrs && typeof obj.attrs === 'object' && !Array.isArray(obj.attrs)) {
|
||||
if (filterValueSourceRefs(obj.attrs, deletedNodeId)) changed = true;
|
||||
}
|
||||
if (Array.isArray(obj.enumValues)) {
|
||||
for (const tpl of obj.enumValues) {
|
||||
if (filterValueSourceRefs(tpl, deletedNodeId)) changed = true;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(obj.value)) {
|
||||
for (const v of obj.value) {
|
||||
if (filterValueSourceRefs(v, deletedNodeId)) changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
// 清理数组中每项指向已删除节点的引用(formConfig / modelRequestParamsPath / subFlowConfig.fields)
|
||||
const filterValueSourceInArray = (arr: any[] | null | undefined, deletedNodeId: string): boolean => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
let changed = false;
|
||||
for (const item of arr) {
|
||||
if (item && typeof item === 'object' && filterValueSourceRefs(item, deletedNodeId)) changed = true;
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
const deleteSelectedNode = async () => {
|
||||
if (!selectedNode.value?.data) return;
|
||||
|
||||
@@ -295,8 +336,30 @@ const deleteSelectedNode = async () => {
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
removeNodes([selectedNode.value.id]);
|
||||
const deletedNodeId = selectedNode.value.id;
|
||||
removeNodes([deletedNodeId]);
|
||||
selectedNode.value = null;
|
||||
|
||||
// 清理其它节点指向被删节点的引用,避免保存脏 DSL(懒清理 watch 只在重新渲染时生效,这里主动清)
|
||||
for (const node of nodes.value) {
|
||||
if (node.id === deletedNodeId || !node.data) continue;
|
||||
const data = node.data;
|
||||
let changed = false;
|
||||
if (filterValueSourceInArray(data.formConfig, deletedNodeId)) changed = true;
|
||||
const mc = data.modelConfig;
|
||||
if (mc) {
|
||||
if (filterValueSourceInArray(mc.modelRequestParamsPath, deletedNodeId)) changed = true;
|
||||
if (filterValueSourceRefs(mc.modelRequestParams, deletedNodeId)) changed = true;
|
||||
}
|
||||
if (data.subFlowConfig && filterValueSourceInArray(data.subFlowConfig.fields, deletedNodeId)) changed = true;
|
||||
if (changed) {
|
||||
// 同步 VueFlow 内部状态 + 触发节点重新渲染
|
||||
updateNode(node.id, node);
|
||||
const idx = nodes.value.findIndex((n) => n.id === node.id);
|
||||
if (idx >= 0) nodes.value[idx] = { ...node, data: { ...data } };
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('节点已删除');
|
||||
} catch {
|
||||
// 用户取消删除
|
||||
@@ -309,6 +372,11 @@ const onConnect = (connection: Connection) => {
|
||||
|
||||
if (!source?.data || !target?.data) return;
|
||||
|
||||
if (connection.source === connection.target) {
|
||||
ElMessage.warning('不能将节点连接到自身');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.data.nodeCode === '__start__') {
|
||||
ElMessage.warning('开始节点不能被连接');
|
||||
return;
|
||||
@@ -916,6 +984,32 @@ const validateFlowConstraints = () => {
|
||||
return { ok: false, message: '工作流必须包含开始节点' };
|
||||
}
|
||||
|
||||
// 自环校验(onConnect 已拦截,此处兜底历史/加载数据)
|
||||
for (const edge of currentEdges) {
|
||||
if (edge.source === edge.target) {
|
||||
return { ok: false, message: '存在连接节点自身的连线,请先删除该连线' };
|
||||
}
|
||||
}
|
||||
|
||||
// model 节点必须已选模型,否则后端执行期才报错
|
||||
const unconfiguredModel = currentNodes.find(
|
||||
(n) => n.data?.nodeCode === 'model' && !(n.data?.modelConfig && n.data.modelConfig.modelId)
|
||||
);
|
||||
if (unconfiguredModel) {
|
||||
const label = unconfiguredModel.data?.label || unconfiguredModel.id;
|
||||
return { ok: false, message: `节点「${label}」尚未选择模型,请先配置` };
|
||||
}
|
||||
|
||||
// 孤立节点(无任何连线、非开始节点):不参与执行,视为绘制遗漏
|
||||
const isolatedNode = currentNodes.find((n) => {
|
||||
if (isStartNode(n)) return false;
|
||||
return !currentEdges.some((e) => e.source === n.id || e.target === n.id);
|
||||
});
|
||||
if (isolatedNode) {
|
||||
const label = isolatedNode.data?.label || isolatedNode.id;
|
||||
return { ok: false, message: `存在未连接的节点「${label}」,请先连接或删除` };
|
||||
}
|
||||
|
||||
// 检查边的约束
|
||||
for (const edge of currentEdges) {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
@@ -1163,7 +1257,8 @@ const buildOutputConfig = (node: Node<NodeData>, startNodeId = '', outputIndex?:
|
||||
const fieldName = f.field || f.label || '';
|
||||
return {
|
||||
type: f.type || 'input',
|
||||
field: f.label || f.field || '',
|
||||
// field 为不可变标识(下游引用 key),label 仅为展示名;field 优先,避免重命名 label 使引用失效
|
||||
field: f.field || f.label || '',
|
||||
label: f.label || f.field || '',
|
||||
value: f.value ?? '',
|
||||
required: Boolean(f.required),
|
||||
@@ -1538,17 +1633,23 @@ const confirmSaveWorkflow = async () => {
|
||||
});
|
||||
ElMessage.success('工作流更新成功');
|
||||
} else {
|
||||
await saveWorkflow({
|
||||
const res = await saveWorkflow({
|
||||
flowName: saveForm.value.flowName,
|
||||
description: saveForm.value.description,
|
||||
flowContent: workflowDsl,
|
||||
});
|
||||
ElMessage.success('工作流保存成功');
|
||||
// 新建成功:回填后端返回的 id,使后续「再点保存」走更新而非新建重复副本
|
||||
const newData = (res as any)?.data;
|
||||
const newId =
|
||||
typeof newData === 'string'
|
||||
? newData
|
||||
: newData?.id ?? newData?.flowId ?? newData?.data?.id ?? newData?.data?.flowId ?? '';
|
||||
if (newId) currentEditingWorkflowId.value = String(newId);
|
||||
}
|
||||
saveDialogVisible.value = false;
|
||||
saveForm.value.flowName = '';
|
||||
saveForm.value.description = '';
|
||||
currentEditingWorkflowId.value = null;
|
||||
// 保存成功后保留 saveForm(名称/描述)与 currentEditingWorkflowId,便于连续编辑再保存;
|
||||
// 新建/编辑入口(createNewWorkflow / editWorkflow)会重置这两者
|
||||
await fetchWorkflowList();
|
||||
} catch {
|
||||
// 错误已由全局拦截器处理
|
||||
|
||||
Reference in New Issue
Block a user