feat: 完成工作流开始节点开场表单

- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析

- 统一 Admin/UserCenter 开场表单渲染与文件集合输入

- 补充开始表单校验、引用迁移和前端工具测试
This commit is contained in:
2026-04-19 13:57:57 +08:00
parent 8546d927bc
commit 9feb889637
26 changed files with 3391 additions and 172 deletions

View File

@@ -71,6 +71,47 @@
const onRunTest = options.onRunTest;
const { updateEdgeData } = useUpdateEdgeData();
const pendingParentRepairs = new Set<string>();
function scheduleOrphanParentRepair(nodeId: string) {
if (!nodeId || pendingParentRepairs.has(nodeId)) {
return;
}
pendingParentRepairs.add(nodeId);
queueMicrotask(() => {
pendingParentRepairs.delete(nodeId);
const currentNode = getNode(nodeId);
const parentId = asString(currentNode?.parentId).trim();
if (!currentNode || !parentId || getNode(parentId)) {
return;
}
svelteFlow.updateNode(nodeId, {
parentId: undefined
});
});
}
function getResolvedParentId(node: null | Node | undefined) {
const parentId = asString(node?.parentId).trim();
if (!parentId) {
return undefined;
}
if (getNode(parentId)) {
return parentId;
}
if (node?.id) {
scheduleOrphanParentRepair(node.id);
}
return undefined;
}
function repairOrphanParentNodes() {
store.getNodes().forEach((node) => {
if (asString(node.parentId).trim() && !getResolvedParentId(node)) {
scheduleOrphanParentRepair(node.id);
}
});
}
function getEventClientPosition(event: any) {
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
@@ -353,24 +394,27 @@
const isValidConnection = (conn: any) => {
const sourceNode = getNode(conn.source)!;
const targetNode = getNode(conn.target)!;
const sourceParentId = getResolvedParentId(sourceNode);
const targetParentId = getResolvedParentId(targetNode);
// 阻止循环节点连接到父级节点 或者 父级节点连接到子级节点
if (conn.sourceHandle === 'loop_handle' || sourceNode.parentId) {
if (conn.sourceHandle === 'loop_handle' || sourceParentId) {
const edges = svelteFlow.getEdges();
for (let edge of edges) {
if (edge.target === conn.target) {
const edgeSourceNode = getNode(edge.source) as Node;
if (conn.sourceHandle === 'loop_handle' && edgeSourceNode.parentId !== sourceNode.id) {
const edgeSourceParentId = getResolvedParentId(edgeSourceNode);
if (conn.sourceHandle === 'loop_handle' && edgeSourceParentId !== sourceNode.id) {
return false;
}
if (sourceNode.parentId && edgeSourceNode.parentId !== sourceNode.parentId) {
if (sourceParentId && edgeSourceParentId !== sourceParentId) {
return false;
}
}
}
}
if (!sourceNode.parentId && targetNode.parentId && targetNode.parentId !== sourceNode.id) {
if (!sourceParentId && targetParentId && targetParentId !== sourceNode.id) {
return false;
}
@@ -446,12 +490,14 @@
closeNodePicker();
const toNode = state.toNode as Node;
if (toNode.parentId) {
const targetParentId = getResolvedParentId(toNode);
if (targetParentId) {
return;
}
const fromNode = state.fromNode as Node;
const fromHande = state.fromHandle as any;
const sourceParentId = getResolvedParentId(fromNode);
const newNode = {
position: { ...toNode.position }
@@ -459,8 +505,8 @@
if (fromHande.id === 'loop_handle') {
newNode.parentId = fromNode.id;
} else if (fromNode.parentId) {
newNode.parentId = fromNode.parentId;
} else if (sourceParentId) {
newNode.parentId = sourceParentId;
}
if (newNode.parentId) {
@@ -514,10 +560,11 @@
showEdgePanel = false;
}
const targetNode = getNode(edge.target) as Node;
if (targetNode && targetNode.parentId) {
const targetParentId = getResolvedParentId(targetNode as Node);
if (targetNode && targetParentId) {
const nodeEdges = getEdgesByTarget(edge.target);
// const loopNode = getNode(targetNode.parentId) as Node;
const { x, y } = getNodeRelativePosition(targetNode.parentId);
const { x, y } = getNodeRelativePosition(targetParentId);
if (nodeEdges.length === 0) {
svelteFlow.updateNode(targetNode.id, {
parentId: undefined,
@@ -543,7 +590,7 @@
for (let i = 0; i < nodeEdges.length; i++) {
const edge = nodeEdges[i];
const sourceNode = getNode(edge.source) as Node;
if (sourceNode.parentId || sourceNode.type === 'loopNode') {
if (getResolvedParentId(sourceNode) || sourceNode.type === 'loopNode') {
hasSameParent = true;
break;
}
@@ -680,6 +727,7 @@
onMount(() => {
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
repairOrphanParentNodes();
if (!readonly) {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handleGlobalPaste);

View File

@@ -4,7 +4,15 @@
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {contentTypes, startFormTypes} from '#consts';
import type {Parameter} from '#types';
import {store} from '#store/stores.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {
renameStartFieldReferencesInNodes,
removeStartFormField,
START_NODE_TYPE,
SYSTEM_START_PARAM_NAME,
updateStartFormField,
} from '../../utils/workflowNodeFields';
const { parameter, index }: {
parameter: Parameter,
@@ -20,10 +28,108 @@
...(node?.current?.data?.parameters as Array<Parameter>)[index]
};
});
let isSystemStartParam = $derived.by(() => {
return param.systemReserved === true && param.name === SYSTEM_START_PARAM_NAME;
});
let isStartNodeInputParam = $derived.by(() => {
return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
});
let availableFormTypes = $derived.by(() => {
if (isSystemStartParam) {
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
}
return startFormTypes;
});
let displayFormTypeValue = $derived.by(() => {
if (isStartNodeInputParam && param.contentType === 'file') {
return ['file'];
}
return param.formType ? [param.formType] : [];
});
let displayParamName = $derived.by(() => {
if (isSystemStartParam) {
return '用户问题';
}
return param.name;
});
const { updateNodeData } = useSvelteFlow();
const toStartFormFieldType = (value: string) => {
if (value === 'input') {
return 'text';
}
return value;
};
const trimString = (value: unknown) => {
return value == null ? '' : String(value).trim();
};
const applyStartFieldPatch = (fieldKey: string, patch: Record<string, any>) => {
const currentFieldId = trimString(param.id);
store.updateNodes((nodes) => {
const edges = store.getEdges();
let nextFieldKey = fieldKey;
const nextNodes = nodes.map((currentNode) => {
if (currentNode.id !== currentNodeId) {
return currentNode;
}
const nextData = updateStartFormField(currentNode.data as Record<string, any>, fieldKey, patch);
const nextSchema = Array.isArray(nextData.startFormSchema) ? nextData.startFormSchema : [];
const matchedField = nextSchema.find((field) => {
return (currentFieldId && trimString(field?.id) === currentFieldId)
|| trimString(field?.key) === fieldKey;
});
nextFieldKey = trimString(matchedField?.key) || fieldKey;
return {
...currentNode,
data: {
...((currentNode.data || {}) as Record<string, any>),
...nextData
}
};
});
return renameStartFieldReferencesInNodes(
nextNodes,
edges,
currentNodeId,
fieldKey,
nextFieldKey
);
});
};
const updateParameter = (key: string, value: any) => {
if (isStartNodeInputParam) {
const fieldKey = param.name || '';
if (!fieldKey) {
return;
}
let patch: Record<string, any> = {};
if (key === 'name') {
patch = { key: value };
} else if (key === 'required') {
patch = { required: Boolean(value) };
} else if (key === 'formType') {
patch = { type: toStartFormFieldType(value) };
} else if (key === 'formLabel') {
patch = { label: value };
} else if (key === 'formDescription') {
patch = { description: value };
} else if (key === 'formPlaceholder') {
patch = { placeholder: value };
} else if (key === 'enums') {
patch = { options: Array.isArray(value) ? value : [] };
} else if (key === 'contentType') {
patch = { type: value === 'file' ? 'file' : 'text' };
}
if (Object.keys(patch).length === 0) {
return;
}
applyStartFieldPatch(fieldKey, patch);
return;
}
updateNodeData(currentNodeId, (node) => {
let parameters = node.data.parameters as Array<Parameter>;
(parameters[index] as any)[key] = value;
@@ -51,6 +157,9 @@
const updateFormType = (item: any) => {
const newValue = item.value;
updateParameter('formType', newValue);
if (isSystemStartParam) {
updateParameter('contentType', 'text');
}
};
const updateContentType = (item: any) => {
@@ -61,6 +170,13 @@
let triggerObject: any;
const handleDelete = () => {
if (isStartNodeInputParam) {
updateNodeData(currentNodeId, (node) => {
return removeStartFormField(node.data as Record<string, any>, param.name);
});
triggerObject?.hide();
return;
}
updateNodeData(currentNodeId, (node) => {
let parameters = node.data.parameters as Array<Parameter>;
parameters.splice(index, 1);
@@ -75,7 +191,7 @@
<div class="input-item">
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
<Input style="width: 100%;" value={displayParamName} placeholder="请输入参数名称"
disabled={param.nameDisabled === true}
oninput={updateName} />
</div>
@@ -94,22 +210,21 @@
<div class="input-more-setting">
{#if param.systemReserved}
<div class="input-more-item">
系统入口参数,当前不可编辑
系统入口参数,名称和必填规则固定,可调整展示标题、说明、占位符和输入方式
</div>
{/if}
<div class="input-more-item">
数据内容:
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
value={param.contentType ? [param.contentType] : []}
disabled={param.systemReserved === true}
disabled={param.systemReserved === true || isStartNodeInputParam}
onSelect={updateContentType}
/>
</div>
<div class="input-more-item">
输入方式:
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
value={param.formType ? [param.formType] : []}
disabled={param.systemReserved === true}
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
value={displayFormTypeValue}
onSelect={updateFormType}
/>
</div>
@@ -127,21 +242,21 @@
数据标题:
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formLabel', event)
}} disabled={param.systemReserved === true} value={param.formLabel} />
}} value={param.formLabel} />
</div>
<div class="input-more-item">
数据描述:
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formDescription', event)
}} disabled={param.systemReserved === true} value={param.formDescription} />
}} value={param.formDescription} />
</div>
<div class="input-more-item">
占位符:
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formPlaceholder', event)
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
}} value={param.formPlaceholder} />
</div>

View File

@@ -9,6 +9,7 @@
} from '@xyflow/svelte';
import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base';
import {type Snippet} from 'svelte';
import {onDestroy, onMount} from 'svelte';
import {useDeleteNode} from '../utils/useDeleteNode.svelte';
import {useCopyNode} from '../utils/useCopyNode.svelte';
import {getOptions} from '../utils/NodeUtils';
@@ -73,6 +74,38 @@
options.onNodeExecute?.(getNode(id)!);
};
let currentNodeId = getCurrentNodeId();
let wrapperElement: HTMLDivElement | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeFrame = 0;
const scheduleUpdateNodeInternals = () => {
if (!id) {
return;
}
if (resizeFrame) {
cancelAnimationFrame(resizeFrame);
}
resizeFrame = requestAnimationFrame(() => {
updateNodeInternals(id);
});
};
onMount(() => {
scheduleUpdateNodeInternals();
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
resizeObserver = new ResizeObserver(() => {
scheduleUpdateNodeInternals();
});
resizeObserver.observe(wrapperElement);
}
});
onDestroy(() => {
if (resizeFrame) {
cancelAnimationFrame(resizeFrame);
}
resizeObserver?.disconnect();
});
</script>
@@ -250,7 +283,7 @@
</NodeToolbar>
{/if}
<div class="tf-node-wrapper {wrapperClass}">
<div class="tf-node-wrapper {wrapperClass}" bind:this={wrapperElement}>
<div class="tf-node-wrapper-body">
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
updateNodeData(id, {expand: actionKeys?.includes('key')})

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {Heading} from '../base';
import {Heading, Input, Textarea} from '../base';
import {Button} from '../base/index.js';
import {type NodeProps} from '@xyflow/svelte';
import DefinedParameterList from '../core/DefinedParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import {useSvelteFlow} from '@xyflow/svelte';
import {
ensureStartNodeParameters,
hasSystemStartParameter,
appendStartFormField,
isSystemStartParameter,
normalizeStartNodeData,
normalizeStartFormMeta,
} from '../../utils/workflowNodeFields';
const { data, ...rest }: {
@@ -19,25 +19,39 @@
} = $props();
const currentNodeId = getCurrentNodeId();
const { addParameter } = useAddParameter();
const { updateNodeData } = useSvelteFlow();
$effect(() => {
const currentParameters = (data.parameters as Array<any>) || [];
if (!hasSystemStartParameter(currentParameters)) {
return;
}
const parameters = ensureStartNodeParameters(currentParameters);
if (JSON.stringify(currentParameters) !== JSON.stringify(parameters)) {
updateNodeData(currentNodeId, {
parameters
});
const normalizedData = normalizeStartNodeData((data || {}) as Record<string, any>, {
allowLegacyParametersOnly: true
});
const nextData = {
...(data || {}),
...normalizedData,
};
if (JSON.stringify(data || {}) !== JSON.stringify(nextData)) {
updateNodeData(currentNodeId, nextData);
}
});
const updateStartFormMeta = (key: 'title' | 'description' | 'submitText', value: string) => {
updateNodeData(currentNodeId, (node) => {
const normalizedMeta = normalizeStartFormMeta(node.data?.startFormMeta as Record<string, any>);
return {
startFormMeta: {
...normalizedMeta,
[key]: value,
},
};
});
};
let currentParameters = $derived.by(() => {
return ((data.parameters as Array<any>) || []);
});
let startFormMeta = $derived.by(() => {
return normalizeStartFormMeta((data.startFormMeta as Record<string, any>) || {});
});
let systemParameters = $derived.by(() => {
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
});
@@ -55,11 +69,48 @@
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
</svg>
{/snippet}
<div class="param-section">
<div class="heading">
<Heading level={3}>开场表单</Heading>
</div>
<div class="section-description">配置开始问答的标题、说明和提交按钮文案。</div>
<div class="meta-form">
<div class="meta-form-item">
<div class="meta-label">标题</div>
<Input
style="width: 100%;"
value={startFormMeta.title}
placeholder="请输入表单标题"
oninput={(event) => updateStartFormMeta('title', (event.target as HTMLInputElement)?.value || '')}
/>
</div>
<div class="meta-form-item">
<div class="meta-label">说明</div>
<Textarea
rows={2}
style="width: 100%;"
value={startFormMeta.description}
placeholder="请输入表单说明"
oninput={(event) => updateStartFormMeta('description', (event.target as HTMLTextAreaElement)?.value || '')}
/>
</div>
<div class="meta-form-item">
<div class="meta-label">提交按钮</div>
<Input
style="width: 100%;"
value={startFormMeta.submitText}
placeholder="请输入按钮文案"
oninput={(event) => updateStartFormMeta('submitText', (event.target as HTMLInputElement)?.value || '')}
/>
</div>
</div>
</div>
<div class="param-section">
<div class="heading">
<Heading level={3}>系统入口</Heading>
</div>
<div class="section-description">固定入口参数,作为工作流默认输入来源</div>
<div class="section-description">固定主问题字段,名称与必填规则固定,可调整展示方式与提示文案</div>
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
</div>
@@ -67,14 +118,20 @@
<div class="heading">
<Heading level={3}>自定义参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", formType: "input", contentType: "text"});
updateNodeData(currentNodeId, (node) => {
return appendStartFormField(node.data as Record<string, any>, {
label: '新字段',
type: 'text',
placeholder: '请输入内容',
});
});
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
</Button>
</div>
<div class="section-description">这里添加额外输入参数,不影响默认入口参数</div>
<div class="section-description">这里添加额外收集字段,字段顺序按列表顺序保存,首版不提供拖拽排序</div>
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
</div>
</NodeWrapper>
@@ -93,6 +150,23 @@
border-top: 1px solid var(--tf-border-color);
}
.meta-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.meta-form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.meta-label {
font-size: 12px;
color: var(--tf-text-secondary);
}
.section-description {
margin-bottom: 10px;
font-size: 12px;