diff --git a/src/renderer/src/panels/Properties.tsx b/src/renderer/src/panels/Properties.tsx index 0542a4a..0e5bae4 100644 --- a/src/renderer/src/panels/Properties.tsx +++ b/src/renderer/src/panels/Properties.tsx @@ -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(() => { + if (!flow || !node) return []; + const incoming = new Map(); + for (const edge of flow.edges) { + incoming.set(edge.target, [...(incoming.get(edge.target) || []), edge.source]); + } + const ancestors = new Set(); + 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)} /> + @@ -515,13 +563,16 @@ function FieldControl({ ) : field.widget === "secret" ? ( - onChange(field.key, event.target.value)} - /> +
+ onChange(field.key, event.target.value)} + /> + +
) : field.widget === "number" ? ( onChange(field.key, event.target.value)} /> ) : ( - onChange(field.key, event.target.value)} - /> +
+ onChange(field.key, event.target.value)} + /> + +
)} {field.description && {field.description}} @@ -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 ( +
+ + {open && ( +
+ {variables.map((item) => ( + + ))} +
+ )} +
+ ); +} + function NumberField({ label, value, diff --git a/src/renderer/src/store/editorStore.ts b/src/renderer/src/store/editorStore.ts index a11692d..4b34c84 100644 --- a/src/renderer/src/store/editorStore.ts +++ b/src/renderer/src/store/editorStore.ts @@ -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((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((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((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((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; + } + } }); }, diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 6c5df90..f774a6e 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -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; +} diff --git a/src/renderer/src/validation/flowValidation.test.ts b/src/renderer/src/validation/flowValidation.test.ts index 00876f0..a33d0cf 100644 --- a/src/renderer/src/validation/flowValidation.test.ts +++ b/src/renderer/src/validation/flowValidation.test.ts @@ -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" })); + }); }); diff --git a/src/renderer/src/validation/flowValidation.ts b/src/renderer/src/validation/flowValidation.ts index cf22491..ab5d142 100644 --- a/src/renderer/src/validation/flowValidation.ts +++ b/src/renderer/src/validation/flowValidation.ts @@ -55,6 +55,37 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida } } + const definedVars = new Set([ + ...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));