Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo
This commit is contained in:
commit
ac20d59283
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user