工作流绘制链路加固:校验增强、悬垂引用清理与保存连续性修复

- validateFlowConstraints 增加自环/model 必选模型/孤立节点校验,onConnect 拦截自环连线
- 删除节点后主动清理其余节点指向被删节点的 valueSource,避免保存脏 DSL
- 保存成功后保留工作流 id 支持连续保存,新建回填 id,避免重复副本
- attachFormFieldValueSources 不再覆盖已有上游引用,保持引用/表单展示互斥
- form 自定义字段引入不可变 field,重命名 label 不影响下游引用

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-20 01:04:58 +08:00
co-authored by Claude
parent c1658380a7
commit 3cddff6df2
3 changed files with 123 additions and 8 deletions
@@ -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 }];
}
}
}
}
+107 -6
View File
@@ -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 {
// 错误已由全局拦截器处理