feat: 完善循环节点配置与作用域输出

- 支持次数与数组独立或组合配置并补齐检查规则

- 统一循环体临时变量与下游正式输出候选

- 稳定知识库对象数组字段并补充前后端测试
This commit is contained in:
2026-07-29 18:10:23 +08:00
parent 19c7b60a65
commit 766554bf63
13 changed files with 1737 additions and 62 deletions

View File

@@ -8,6 +8,7 @@
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import type {TinyflowNodeData} from '#types';
import LoopInputEditor from './loop/LoopInputEditor.svelte';
const { data, ...rest }: {
data: TinyflowNodeData,
@@ -17,17 +18,6 @@
const currentNodeId = getCurrentNodeId();
const { addParameter } = useAddParameter();
$effect(() => {
if (!data.loopVars || data.loopVars.length === 0) {
addParameter(currentNodeId, 'loopVars', {
name: 'loopVar',
nameDisabled: true,
deleteDisabled: true
});
}
});
</script>
@@ -46,17 +36,9 @@
{/snippet}
<div class="heading">
<Heading level={3}>循环变量</Heading>
<!-- <Button class="input-btn-more" style="margin-left: auto" onclick={()=>{-->
<!-- addParameter(currentNodeId)-->
<!-- }}>-->
<!-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">-->
<!-- <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>-->
<!-- </svg>-->
<!-- </Button>-->
<Heading level={3}>循环参数</Heading>
</div>
<RefParameterList dataKeyName="loopVars" fixedNumberMin={1} fixedNumberMax={300} />
<div class="loop-limit-hint">单次最多循环 300 次</div>
<LoopInputEditor {data} />
<div class="heading">
<Heading level={3}>输出参数</Heading>
@@ -79,13 +61,6 @@
align-items: center;
}
.loop-limit-hint {
margin-top: 6px;
color: var(--tf-text-secondary);
font-size: 12px;
line-height: 1.4;
}
:global(.loop-handle-wrapper) {
&::after {
//display: none;

View File

@@ -0,0 +1,414 @@
<script lang="ts">
import {
type Node,
useNodesData,
useStore,
useSvelteFlow,
} from '@xyflow/svelte';
import type {
Parameter,
SelectItem,
TinyflowNodeData,
} from '#types';
import { getCurrentNodeId } from '#components/utils/NodeUtils';
import { MixedInput, Select } from '../../base';
import {
buildLoopItemParameter,
resolveLoopInputs,
} from '../../../utils/loopScope';
import { useRefOptions } from '../../utils/useRefOptions.svelte';
import { filterRefOptionsByDataType } from '../../utils/refOptionFilter';
const { data }: { data: TinyflowNodeData } = $props();
const currentNodeId = getCurrentNodeId();
const currentNode = useNodesData(currentNodeId);
const { nodes } = $derived(useStore());
const { updateNodeData } = useSvelteFlow();
const referenceOptions = useRefOptions();
const loopNode = $derived.by(() => {
return (
currentNode.current || {
id: currentNodeId,
type: 'loopNode',
position: { x: 0, y: 0 },
data,
}
) as Node;
});
const loopInputs = $derived.by(() =>
resolveLoopInputs(loopNode, nodes || []),
);
const loopItem = $derived.by(() =>
buildLoopItemParameter(loopNode, nodes || []),
);
const countOptions = $derived.by(() =>
filterRefOptionsByDataType(
referenceOptions.current,
['Number'],
loopInputs.count?.ref || '',
),
);
const itemsOptions = $derived.by(() =>
filterRefOptionsByDataType(
referenceOptions.current,
['Array'],
loopInputs.items?.ref || '',
),
);
const countType = $derived<'fixed' | 'ref'>(
loopInputs.count?.refType === 'ref' ? 'ref' : 'fixed',
);
const countTextValue = $derived(
loopInputs.count?.refType === 'fixed'
? String(loopInputs.count.value || '')
: '',
);
const countRefValue = $derived(
loopInputs.count?.refType === 'ref'
? String(loopInputs.count.ref || '')
: '',
);
const countHint = $derived.by(() => {
const count = loopInputs.count;
if (!count || count.refType !== 'fixed') {
return '';
}
const value = Number(count.value);
return Number.isInteger(value) && value >= 1 && value <= 300
? ''
: '请输入 1300 的整数';
});
const summary = $derived.by(() => {
const count = loopInputs.count;
const items = loopInputs.items;
if (count && items) {
return count.refType === 'fixed' && count.value
? `最多处理数组前 ${count.value} 项`
: '按循环次数限制数组遍历';
}
if (items) {
return '遍历数组全部元素';
}
if (count) {
return count.refType === 'fixed' && count.value
? `循环 ${count.value} 次`
: '按引用次数循环';
}
return '';
});
function updateInputs(
updater: (inputs: {
count?: Parameter;
items?: Parameter;
}) => {
count?: Parameter;
items?: Parameter;
},
) {
updateNodeData(currentNodeId, (node) => {
const current = resolveLoopInputs(node, nodes || []);
const next = updater({
count: current.count,
items: current.items,
});
const loopInputs: Record<string, Parameter> = {};
if (next.count) {
loopInputs.count = next.count;
}
if (next.items) {
loopInputs.items = next.items;
}
return {
loopInputs,
loopVars: [],
};
});
}
function updateCountType(type: 'fixed' | 'ref') {
updateInputs((current) => ({
...current,
count:
type === 'ref'
? {
name: 'count',
refType: 'ref',
ref:
current.count?.refType === 'ref'
? current.count.ref || ''
: '',
dataType: 'Number',
}
: {
name: 'count',
refType: 'fixed',
value:
current.count?.refType === 'fixed'
? current.count.value || ''
: '',
dataType: 'Number',
},
}));
}
function updateCountText(value: string) {
const normalized = value.trim();
updateInputs((current) => ({
...current,
count: normalized
? {
name: 'count',
refType: 'fixed',
value:
/^\d+$/.test(normalized) &&
Number(normalized) > 300
? '300'
: normalized,
dataType: 'Number',
}
: undefined,
}));
}
function updateCountRef(value: string) {
updateInputs((current) => ({
...current,
count: value
? {
name: 'count',
refType: 'ref',
ref: value,
dataType: 'Number',
}
: undefined,
}));
}
function updateItems(item: SelectItem) {
updateInputs((current) => ({
...current,
items: {
name: 'items',
refType: 'ref',
ref: String(item.value),
dataType: item.dataType || 'Array',
},
}));
}
function clearItems(event: MouseEvent) {
event.stopPropagation();
updateInputs((current) => ({
...current,
items: undefined,
}));
}
</script>
<div class="loop-inputs">
<div class="loop-field">
<div class="loop-label">
<span>循环次数</span>
<span class="loop-optional">可选</span>
</div>
<MixedInput
type={countType}
textValue={countTextValue}
refValue={countRefValue}
refOptions={countOptions}
placeholder="输入 1300 或选择变量"
onTypeChange={updateCountType}
onTextChange={updateCountText}
onRefChange={updateCountRef}
/>
{#if countHint}
<div class="loop-error" role="status">{countHint}</div>
{/if}
</div>
<div class="loop-field">
<div class="loop-label">
<span>输入数组</span>
<span class="loop-optional">可选</span>
</div>
<div class="loop-array-input">
<Select
items={itemsOptions}
value={loopInputs.items?.ref ? [loopInputs.items.ref] : []}
placeholder="选择要遍历的数组"
variant="reference"
style="width: 100%"
onSelect={updateItems}
/>
{#if loopInputs.items}
<button
type="button"
class="loop-clear nopan nodrag"
aria-label="清空输入数组"
title="清空输入数组"
onclick={clearItems}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
{#if summary}
<div class="loop-summary">{summary}</div>
{:else}
<div class="loop-error" role="status">
请至少配置循环次数或输入数组
</div>
{/if}
<div class="loop-variables">
<div class="loop-variables-title">循环体变量</div>
<div class="loop-variable-list">
<span class="loop-variable">
<strong>index</strong>
<small>Number</small>
</span>
{#if loopItem}
<span class="loop-variable">
<strong>loopItem</strong>
<small>{loopItem.dataType}</small>
</span>
{/if}
</div>
</div>
</div>
<style lang="less">
.loop-inputs {
display: flex;
flex-direction: column;
gap: 16px;
}
.loop-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.loop-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--tf-text-primary);
font-size: 13px;
font-weight: 500;
}
.loop-optional {
color: var(--tf-text-muted);
font-size: 12px;
font-weight: 400;
}
.loop-array-input {
position: relative;
display: flex;
min-width: 0;
}
.loop-array-input:has(.loop-clear) :global(.tf-select-input) {
padding-right: 48px;
}
.loop-clear {
position: absolute;
top: 50%;
right: 28px;
z-index: 1;
width: 24px;
height: 24px;
padding: 5px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--tf-text-muted);
cursor: pointer;
transform: translateY(-50%);
}
.loop-clear:hover {
background: var(--tf-bg-hover);
color: var(--tf-text-primary);
}
.loop-clear:focus-visible {
outline: none;
box-shadow: var(--tf-focus-shadow);
}
.loop-clear svg {
width: 14px;
height: 14px;
}
.loop-summary {
margin-top: -8px;
color: var(--tf-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.loop-error {
color: var(--tf-danger-soft-text);
font-size: 12px;
line-height: 1.5;
}
.loop-variables {
padding-top: 16px;
border-top: 1px solid var(--tf-border-color-soft);
}
.loop-variables-title {
margin-bottom: 8px;
color: var(--tf-text-secondary);
font-size: 12px;
}
.loop-variable-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.loop-variable {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding: 0 8px;
border: 1px solid var(--tf-border-color-soft);
border-radius: 6px;
background: var(--tf-bg-surface-alt);
color: var(--tf-text-primary);
font-size: 12px;
}
.loop-variable strong {
font-weight: 500;
}
.loop-variable small {
color: var(--tf-text-muted);
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { filterRefOptionsByDataType } from './refOptionFilter';
describe('filterRefOptionsByDataType', () => {
const options = [
{
label: '知识库',
selectable: false,
value: 'knowledge',
children: [
{
label: '文档',
selectable: true,
value: 'knowledge.documents',
dataType: 'Array<Object>',
},
{
label: '数量',
selectable: true,
value: 'knowledge.count',
dataType: 'Number',
},
{
label: '标题',
selectable: true,
value: 'knowledge.title',
dataType: 'String',
},
],
},
];
it('数组输入接受 Array 及 Array 泛型', () => {
const filtered = filterRefOptionsByDataType(options, ['Array']);
expect(filtered[0]?.children.map((item: any) => item.value)).toEqual([
'knowledge.documents',
]);
});
it('次数输入只展示 Number并保留当前旧引用', () => {
const filtered = filterRefOptionsByDataType(
options,
['Number'],
'knowledge.title',
);
expect(filtered[0]?.children.map((item: any) => item.value)).toEqual([
'knowledge.count',
'knowledge.title',
]);
});
});

View File

@@ -0,0 +1,48 @@
function isAcceptedDataType(dataType: string, acceptedDataTypes: string[]) {
const normalized = dataType.trim().toLowerCase();
return acceptedDataTypes.some((acceptedDataType) => {
const accepted = acceptedDataType.trim().toLowerCase();
return accepted === 'array'
? normalized === 'array' || normalized.startsWith('array<')
: normalized === accepted;
});
}
/**
* 按数据类型过滤引用选项,并保留当前已选择的旧引用。
*/
export function filterRefOptionsByDataType(
options: any[],
acceptedDataTypes: string[],
currentRef = '',
): any[] {
if (!acceptedDataTypes.length) {
return options;
}
return options
.map((option) => {
const children = Array.isArray(option.children)
? filterRefOptionsByDataType(
option.children,
acceptedDataTypes,
currentRef,
)
: [];
const selectable =
option.selectable === true &&
(isAcceptedDataType(
String(option.dataType || ''),
acceptedDataTypes,
) ||
String(option.value || '') === currentRef);
if (!selectable && children.length === 0) {
return undefined;
}
return {
...option,
selectable,
children,
};
})
.filter(Boolean);
}

View File

@@ -3,6 +3,11 @@ import type { Parameter } from '#types';
import { getCurrentNodeId, getOptions } from '#components/utils/NodeUtils';
import { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
import { nodeIcons } from '../../consts';
import {
buildLoopReferenceParameters,
buildLoopScopeParameters,
isArrayDataType,
} from '../../utils/loopScope';
const fillRefNodeIds = (
refNodeIds: string[],
@@ -27,7 +32,10 @@ const getChildren = (
) => {
if (!params || params.length === 0) return [];
return params.map((param: any) => {
const isCollection = param.dataType === 'Array' && param.children && param.children.length > 0;
const isCollection =
isArrayDataType(param.dataType) &&
param.children &&
param.children.length > 0;
const childBaseLabel = param.formLabel || param.displayName || param.name;
const normalizedChildLabel = String(childBaseLabel || '').trim();
const pathLabel = !parentPathLabel
@@ -64,6 +72,7 @@ const nodeToOptions = (
node: Node,
nodeIsChildren: boolean,
currentNode: Node,
nodes: Node[],
) => {
const options = getOptions();
const nodeType = node.type || '';
@@ -105,29 +114,27 @@ const nodeToOptions = (
nodeType: nodeType,
children,
};
} else if (nodeType === 'loopNode' && currentNode.parentId) {
} else if (nodeType === 'loopNode') {
const referenceParameters = buildLoopReferenceParameters(
node,
currentNode,
nodes,
);
if (!referenceParameters.length) {
return undefined;
}
return {
label: title,
icon: icon,
value: node.id,
selectable: false,
nodeType: nodeType,
children: [
{
label: 'loopItem',
dataType: 'Any',
value: node.id + '.loopItem',
selectable: true,
nodeType: nodeType,
},
{
label: 'index',
dataType: 'Number',
value: node.id + '.index',
selectable: true,
nodeType: nodeType,
},
],
children: getChildren(
referenceParameters,
node.id,
false,
nodeType,
),
};
} else {
const outputDefs = node.data.outputDefs;
@@ -168,7 +175,12 @@ export const useRefOptions: any = (
for (const node of nodes) {
const nodeIsChildren = node.parentId === currentNode.current.id;
if (nodeIsChildren) {
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
const nodeOptions = nodeToOptions(
node,
nodeIsChildren,
cNode,
nodes,
);
nodeOptions && resultOptions.push(nodeOptions);
}
}
@@ -177,9 +189,17 @@ export const useRefOptions: any = (
fillRefNodeIds(refNodeIds, currentNodeId, edges);
for (const node of nodes) {
if (refNodeIds.includes(node.id)) {
const isScopedLoop =
node.type === 'loopNode' &&
buildLoopScopeParameters(node, cNode, nodes).length > 0;
if (refNodeIds.includes(node.id) || isScopedLoop) {
const nodeIsChildren = node.parentId === currentNode.current.id;
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
const nodeOptions = nodeToOptions(
node,
nodeIsChildren,
cNode,
nodes,
);
nodeOptions && resultOptions.push(nodeOptions);
}
}