feat: 变量引用自动化(默认输出变量/变量选择器/连线预填/校验提示)

- A 新增组件时自动填充输出变量(取 catalog suggestedName,重名自动加序号;
  复制节点同样去重)
- B 属性面板字段旁新增变量选择器:列出沿连线可达的上游输出变量与流程变量,
  点选插入 ${变量};替换场景限空值或恰为已定义变量名的裸名
- C 连线时若源节点有输出变量,自动预填目标节点首个空的对象型字段
  (workbook/objExcel/objBrowser 等)
- 运行前校验新增:引用未定义变量(warning)、对象字段误用裸变量名(warning)
- 新增 4 个校验回归测试
This commit is contained in:
yu 2026-10-28 11:33:46 +08:00
parent aaa0f2c3c7
commit cb6d2ac365
5 changed files with 276 additions and 15 deletions

View File

@ -20,6 +20,11 @@ import { containerSlots } from "../canvas/ContainerNode";
type Tab = "props" | "vars" | "run";
interface VariableRef {
name: string;
source: string;
}
interface PropertiesProps {
catalog: Catalog;
onToast: (message: string, kind?: "ok" | "error") => void;
@ -69,6 +74,35 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
() => (node ? flow?.nodes.filter((item) => item.parentId === node.id) || [] : []),
[flow, node]
);
const variableRefs = useMemo<VariableRef[]>(() => {
if (!flow || !node) return [];
const incoming = new Map<string, string[]>();
for (const edge of flow.edges) {
incoming.set(edge.target, [...(incoming.get(edge.target) || []), edge.source]);
}
const ancestors = new Set<string>();
const queue = [node.id];
while (queue.length) {
const current = queue.shift()!;
for (const source of incoming.get(current) || []) {
if (ancestors.has(source)) continue;
ancestors.add(source);
queue.push(source);
}
}
const refs: VariableRef[] = [];
for (const item of flow.nodes) {
if (!ancestors.has(item.id)) continue;
const name = item.options.outputVar;
if (!name) continue;
const owner = catalog.components.find((entry) => entry.id === item.componentId);
refs.push({ name, source: item.label || owner?.name || item.componentId });
}
for (const variable of flow.variables) {
refs.push({ name: variable.name, source: "流程变量" });
}
return refs;
}, [flow, node, catalog]);
if (!flow) return null;
@ -117,6 +151,7 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
component={component}
parent={parent}
childNodes={childNodes}
variableRefs={variableRefs}
onLabel={(label) => updateNode(node.id, { label })}
onChange={(key, value) => updateValue(node.id, key, value)}
onChangeOption={(id, key, value) =>
@ -177,6 +212,7 @@ function PropertyTab({
component,
parent,
childNodes,
variableRefs,
onLabel,
onChange,
onChangeOption,
@ -187,6 +223,7 @@ function PropertyTab({
component: Catalog["components"][number];
parent: FlowNode | null;
childNodes: FlowNode[];
variableRefs: VariableRef[];
onLabel: (label: string) => void;
onChange: (key: string, value: unknown) => void;
onChangeOption: (id: string, key: keyof FlowNode["options"], value: unknown) => void;
@ -274,6 +311,7 @@ function PropertyTab({
key={field.key}
field={field}
node={node}
variables={variableRefs}
onChange={onChange}
onToast={onToast}
/>
@ -294,6 +332,7 @@ function PropertyTab({
key={field.key}
field={field}
node={node}
variables={variableRefs}
onChange={onChange}
onToast={onToast}
/>
@ -392,17 +431,25 @@ function PropertyTab({
function FieldControl({
field,
node,
variables,
onChange,
onToast
}: {
field: CatalogField;
node: FlowNode;
variables: VariableRef[];
onChange: (key: string, value: unknown) => void;
onToast: (message: string, kind?: "ok" | "error") => void;
}) {
const raw = node.values[field.key] ?? field.defaultValue;
const [picking, setPicking] = useState(false);
const insertVariable = (expression: string) => {
const current = textOf(raw).trim();
const known = variables.some((item) => item.name === current);
onChange(field.key, current === "" || known ? expression : `${textOf(raw)}${expression}`);
};
const pickPath = async (directories: boolean) => {
try {
const selected = await window.rpa.dialog.open({
@ -498,6 +545,7 @@ function FieldControl({
placeholder={field.placeholder || (field.widget === "folder" ? "选择目录" : "选择文件")}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览">
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
</button>
@ -515,13 +563,16 @@ function FieldControl({
</button>
</div>
) : field.widget === "secret" ? (
<input
className="text-input"
type="password"
value={textOf(raw)}
placeholder={field.placeholder || "密钥引用"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<div className="field-row">
<input
className="text-input"
type="password"
value={textOf(raw)}
placeholder={field.placeholder || "密钥引用"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.widget === "number" ? (
<input
className="text-input"
@ -531,12 +582,15 @@ function FieldControl({
onChange={(event) => onChange(field.key, event.target.value)}
/>
) : (
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
)}
{field.description && <span className="field-hint">{field.description}</span>}
@ -547,6 +601,48 @@ function FieldControl({
);
}
function VarMenu({
variables,
onPick
}: {
variables: VariableRef[];
onPick: (expression: string) => void;
}) {
const [open, setOpen] = useState(false);
if (!variables.length) return null;
return (
<div className="var-menu-wrap">
<button
type="button"
className="mini-btn"
title="插入上游变量引用"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<Variable className="icon" style={{ width: 13, height: 13 }} />
</button>
{open && (
<div className="var-menu" role="menu">
{variables.map((item) => (
<button
type="button"
key={item.name}
className="var-menu-item"
onClick={() => {
onPick(`\${${item.name}}`);
setOpen(false);
}}
>
<code>{item.name}</code>
<span>{item.source}</span>
</button>
))}
</div>
)}
</div>
);
}
function NumberField({
label,
value,

View File

@ -38,6 +38,22 @@ function defaultOptions(): FlowNodeOptions {
};
}
const OBJECT_FIELD_KEY = /(workbook|objexcel|objbrowser|^obj[_.]?|browser_obj)/i;
function objectFieldKeys(component: CatalogComponent): string[] {
return component.fields
.filter((field) => field.widget !== "hidden" && (field.type === "object" || OBJECT_FIELD_KEY.test(field.key)))
.map((field) => field.key);
}
function uniqueOutputVar(base: string, nodes: FlowNode[]): string {
const taken = new Set(nodes.map((item) => item.options.outputVar).filter(Boolean) as string[]);
if (!taken.has(base)) return base;
let index = 2;
while (taken.has(`${base}${index}`)) index += 1;
return `${base}${index}`;
}
interface EditorState {
flow: FlowDefinition | null;
catalog: Catalog | null;
@ -107,7 +123,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
componentId,
position,
values: defaultValues(component),
options: defaultOptions(),
options: {
...defaultOptions(),
outputVar: component.output?.suggestedName
? uniqueOutputVar(component.output.suggestedName, state.flow.nodes)
: undefined
},
enabled: true
};
commit((draft) => {
@ -139,7 +160,12 @@ export const useEditorStore = create<EditorState>((set, get) => {
slot,
position: { x: 0, y: siblings.length * 52 },
values: defaultValues(component),
options: defaultOptions(),
options: {
...defaultOptions(),
outputVar: component.output?.suggestedName
? uniqueOutputVar(component.output.suggestedName, state.flow.nodes)
: undefined
},
enabled: true
};
commit((draft) => {
@ -220,6 +246,7 @@ export const useEditorStore = create<EditorState>((set, get) => {
copy.id = nextNodeId();
copy.position = { x: source.position.x + 36, y: source.position.y + 36 };
copy.label = source.label ? `${source.label} 副本` : undefined;
if (copy.options.outputVar) copy.options.outputVar = uniqueOutputVar(copy.options.outputVar, [...state.flow.nodes, copy]);
commit((draft) => {
draft.nodes.push(copy);
});
@ -240,6 +267,18 @@ export const useEditorStore = create<EditorState>((set, get) => {
};
commit((draft) => {
draft.edges.push(edge);
const sourceNode = draft.nodes.find((item) => item.id === source);
const targetNode = draft.nodes.find((item) => item.id === target);
const sourceVar = sourceNode?.options?.outputVar;
const targetComponent = state.catalog?.components.find((item) => item.id === targetNode?.componentId);
if (!sourceNode || !targetNode || !sourceVar || !targetComponent) return;
for (const key of objectFieldKeys(targetComponent)) {
const current = targetNode.values[key];
if (current === undefined || current === null || String(current).trim() === "") {
targetNode.values = { ...targetNode.values, [key]: `\${${sourceVar}}` };
break;
}
}
});
},

View File

@ -3234,3 +3234,63 @@ textarea:focus-visible {
grid-template-columns: minmax(170px, 1.3fr) 82px minmax(90px, 0.8fr) 96px 96px;
}
}
/* 变量引用选择器(VarMenu) */
.var-menu-wrap {
position: relative;
display: flex;
align-items: center;
}
.var-menu-wrap .mini-btn {
color: var(--accent, #2563eb);
}
.var-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 40;
min-width: 210px;
max-height: 220px;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--surface, #fff);
box-shadow: 0 10px 28px rgba(23, 32, 51, 0.16);
display: flex;
flex-direction: column;
gap: 2px;
}
.var-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
text-align: left;
}
.var-menu-item:hover {
background: var(--soft, #f1f5f9);
}
.var-menu-item code {
font-size: 11px;
font-weight: 600;
color: var(--ink, #172033);
}
.var-menu-item span {
font-size: 10px;
color: var(--ink-2, #5b6474);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -52,4 +52,39 @@ describe("flow validation", () => {
definition.edges = [];
expect(validateFlow(definition, conditionalCatalog).filter((issue) => issue.code === "required")).toEqual([]);
});
it("warns on references to undefined variables", () => {
const definition = flow();
const action = definition.nodes.find((node) => node.id === "action")!;
action.values = { value: "${nope}" };
action.options = { outputVar: "result" };
expect(validateFlow(definition, catalog)).toContainEqual(expect.objectContaining({ code: "unknown_variable", nodeId: "action" }));
});
it("accepts references to defined node outputs and flow variables", () => {
const definition = flow();
definition.variables = [{ name: "orderNo", type: "string", defaultValue: "", scope: "flow" }];
const action = definition.nodes.find((node) => node.id === "action")!;
action.values = { value: "${result} ${orderNo}" };
action.options = { outputVar: "result" };
expect(validateFlow(definition, catalog).filter((issue) => issue.code === "unknown_variable")).toEqual([]);
});
it("warns when a bare output variable name is used in an object field", () => {
const objectCatalog = structuredClone(catalog);
objectCatalog.components.push({
id: "Excel.Write", name: "写入", kind: "vendor", group: "office", implemented: true, slots: [],
fields: [
{ key: "workbook", label: "工作簿对象", required: false, widget: "text", type: "object", defaultValue: "" },
{ key: "value", label: "内容", required: true, widget: "text", defaultValue: "" }
]
} as never);
const definition = flow();
definition.nodes = [
{ id: "new", componentId: "Trigger.Manual", position: { x: 0, y: 0 }, values: {}, options: { outputVar: "objExcelWorkBook" } },
{ id: "write", componentId: "Excel.Write", position: { x: 0, y: 100 }, values: { workbook: "objExcelWorkBook", value: "1" }, options: {} }
];
definition.edges = [{ id: "edge2", source: "new", target: "write" }];
expect(validateFlow(definition, objectCatalog)).toContainEqual(expect.objectContaining({ code: "bare_variable", nodeId: "write" }));
});
});

View File

@ -55,6 +55,37 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida
}
}
const definedVars = new Set<string>([
...flow.variables.map((item) => item.name),
...flow.nodes.map((node) => node.options?.outputVar).filter(Boolean) as string[]
]);
const variableRef = /\$\{\s*([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s*(?:\.[A-Za-z_]\w*)?\s*\}/g;
for (const node of flow.nodes) {
const component = componentMap.get(node.componentId);
if (!component) continue;
const nodeName = node.label || component.name;
for (const [key, value] of Object.entries(node.values)) {
if (typeof value !== "string") continue;
for (const match of value.matchAll(variableRef)) {
if (!definedVars.has(match[1])) {
issues.push({ severity: "warning", code: "unknown_variable", nodeId: node.id, message: `「${nodeName}」引用了未定义变量:${match[1]}` });
}
}
const field = component.fields.find((item) => item.key === key);
if (!field) continue;
const bare = value.trim();
const isObjectField = field.type === "object" || /^(workbook|objexcel|objbrowser|obj)/i.test(key);
if (isObjectField && /^[A-Za-z_]\w*$/.test(bare) && definedVars.has(bare)) {
issues.push({
severity: "warning",
code: "bare_variable",
nodeId: node.id,
message: `「${nodeName}」的 ${field.label} 应写作 \${${bare}};当前写法会被当作文件路径`
});
}
}
}
if (flow.edges.length && topLevel.length) {
const topIds = new Set(topLevel.map((node) => node.id));
const validEdges = flow.edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target));