feat: M28 增加工作流汇聚可视化配置

This commit is contained in:
2026-08-31 15:55:05 +08:00
parent 4386b2a1a6
commit 6daf805cd0
6 changed files with 785 additions and 13 deletions

View File

@@ -8,6 +8,7 @@
key: string; key: string;
icon?: string | Snippet; icon?: string | Snippet;
title: string | Snippet; title: string | Snippet;
badge?: string | Snippet;
titleHelp?: string; titleHelp?: string;
description?: string | Snippet; description?: string | Snippet;
content: string | Snippet; content: string | Snippet;
@@ -50,6 +51,11 @@
{/if} {/if}
<Render target={item.title} /> <Render target={item.title} />
{#if item.badge}
<span class="tf-collapse-item-title-badge">
<Render target={item.badge} />
</span>
{/if}
{#if item.titleHelp} {#if item.titleHelp}
<span <span
class="tf-collapse-item-title-help" class="tf-collapse-item-title-help"
@@ -106,6 +112,22 @@
position: relative; position: relative;
} }
.tf-collapse-item-title-badge {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
margin-left: 8px;
font-size: 10px;
font-weight: 500;
line-height: 1.2;
color: var(--tf-primary-color);
white-space: nowrap;
background: var(--tf-primary-soft-bg);
border: 1px solid var(--tf-primary-soft-border);
border-radius: 999px;
}
.tf-collapse-item-title-help:hover { .tf-collapse-item-title-help:hover {
border-color: var(--tf-border-color-strong); border-color: var(--tf-border-color-strong);
color: var(--tf-text-primary); color: var(--tf-text-primary);

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import {
JOIN_MODE_ALL,
JOIN_MODE_ANY,
type JoinMode,
} from '../utils/joinMode';
const {
value,
name,
allDisabledReason = '',
invalidMode = false,
onChange,
}: {
value: JoinMode | null;
name: string;
allDisabledReason?: string;
invalidMode?: boolean;
onChange?: (value: JoinMode) => void;
} = $props();
const descriptionId = $derived(`${name}-description`);
const warningId = $derived(`${name}-warning`);
</script>
<fieldset class="join-mode-setting" aria-describedby={descriptionId}>
<legend>执行时机</legend>
<p id={descriptionId} class="join-mode-description">
多条入边时,选择节点何时开始执行
</p>
<div class="join-mode-options">
<label class:selected={value === JOIN_MODE_ANY} class="join-mode-option">
<input
type="radio"
{name}
value={JOIN_MODE_ANY}
checked={value === JOIN_MODE_ANY}
aria-describedby={invalidMode || allDisabledReason ? warningId : undefined}
onchange={() => onChange?.(JOIN_MODE_ANY)}
/>
<span class="join-mode-copy">
<strong>任一上游完成</strong>
<span>任一入边到达,即开始执行</span>
</span>
</label>
<label
class:selected={value === JOIN_MODE_ALL}
class:disabled={Boolean(allDisabledReason)}
class="join-mode-option"
>
<input
type="radio"
{name}
value={JOIN_MODE_ALL}
checked={value === JOIN_MODE_ALL}
disabled={Boolean(allDisabledReason)}
aria-describedby={allDisabledReason ? warningId : undefined}
onchange={() => onChange?.(JOIN_MODE_ALL)}
/>
<span class="join-mode-copy">
<strong>全部上游完成</strong>
<span>等待所有必需入边到达后执行</span>
</span>
</label>
</div>
{#if invalidMode || allDisabledReason}
<div
id={warningId}
class:error={invalidMode || value === JOIN_MODE_ALL}
class="join-mode-warning"
role={invalidMode || value === JOIN_MODE_ALL ? 'alert' : 'status'}
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 1 21h22L12 2Zm0 5.5 6.5 11h-13L12 7.5ZM11 10v4h2v-4h-2Zm0 5.5v2h2v-2h-2Z" />
</svg>
<span>
{invalidMode
? '当前执行时机配置无效,请重新选择。'
: allDisabledReason}
</span>
</div>
{/if}
</fieldset>
<style lang="less">
.join-mode-setting {
min-width: 0;
padding: 12px 0 0;
margin: 2px 0 0;
border: 0;
border-top: 1px solid var(--tf-border-color-soft);
}
legend {
float: left;
width: 100%;
padding: 0;
margin: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: var(--tf-text-primary);
}
.join-mode-description {
clear: both;
padding: 3px 0 0;
margin: 0 0 8px;
font-size: 11px;
line-height: 1.45;
color: var(--tf-text-muted);
}
.join-mode-options {
overflow: hidden;
background: var(--tf-bg-surface-alt);
border: 1px solid var(--tf-border-color);
border-radius: 10px;
}
.join-mode-option {
display: flex;
gap: 8px;
align-items: center;
min-height: 52px;
padding: 8px 10px;
box-sizing: border-box;
cursor: pointer;
background: transparent;
transition: background 140ms ease, box-shadow 140ms ease;
& + & {
border-top: 1px solid var(--tf-border-color-soft);
}
&:hover {
background: var(--tf-bg-hover);
}
&:active {
background: var(--tf-bg-active);
}
&:focus-within {
position: relative;
z-index: 1;
box-shadow: inset 0 0 0 1px var(--tf-primary-color), var(--tf-focus-shadow);
}
&.selected {
background: var(--tf-primary-ghost-bg);
strong {
color: var(--tf-primary-color);
}
}
&.disabled {
color: var(--tf-text-disabled);
cursor: not-allowed;
background: var(--tf-bg-input-disabled);
&:active,
&:hover {
background: var(--tf-bg-input-disabled);
}
}
}
input[type='radio'] {
flex: 0 0 auto;
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--tf-primary-color);
}
.join-mode-copy {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 12px;
font-weight: 600;
line-height: 1.35;
color: var(--tf-text-primary);
}
span {
font-size: 11px;
line-height: 1.45;
color: var(--tf-text-secondary);
}
}
.join-mode-option.disabled .join-mode-copy strong,
.join-mode-option.disabled .join-mode-copy span {
color: var(--tf-text-disabled);
}
.join-mode-warning {
display: flex;
gap: 6px;
align-items: flex-start;
padding: 7px 8px;
margin-top: 8px;
font-size: 11px;
line-height: 1.45;
color: var(--tf-warning-soft-text);
background: var(--tf-warning-soft-bg);
border: 0;
border-radius: 8px;
svg {
flex: 0 0 auto;
width: 14px;
height: 14px;
margin-top: 1px;
color: var(--tf-warning-icon-color);
}
&.error {
color: var(--tf-danger-soft-text);
background: var(--tf-danger-soft-bg);
svg {
color: var(--tf-danger-icon-color);
}
}
}
</style>

View File

@@ -0,0 +1,82 @@
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
describe('NodeJoinModeSetting', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('uses native radios and reports a selected mode', async () => {
const onChange = vi.fn();
const host = document.createElement('div');
host.className = 'tf-theme-light';
document.body.appendChild(host);
const app = mount(NodeJoinModeSetting, {
target: host,
props: {
name: 'join-node-a',
value: 'any',
onChange,
},
});
flushSync();
const radios = host.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios).toHaveLength(2);
expect(radios[0].checked).toBe(true);
radios[1].focus();
expect(document.activeElement).toBe(radios[1]);
radios[1].click();
expect(onChange).toHaveBeenCalledWith('all');
await unmount(app);
});
it('keeps an unsafe existing all selected while allowing any as recovery', async () => {
const onChange = vi.fn();
const host = document.createElement('div');
host.className = 'tf-theme-dark';
document.body.appendChild(host);
const app = mount(NodeJoinModeSetting, {
target: host,
props: {
name: 'join-node-b',
value: 'all',
allDisabledReason: '存在条件路径,可能永久等待。',
onChange,
},
});
flushSync();
const radios = host.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios[1].checked).toBe(true);
expect(radios[1].disabled).toBe(true);
expect(host.querySelector('[role="alert"]')?.textContent).toContain('永久等待');
radios[0].click();
expect(onChange).toHaveBeenCalledWith('any');
await unmount(app);
});
it('shows an invalid configuration error without selecting a fallback', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const app = mount(NodeJoinModeSetting, {
target: host,
props: {
name: 'join-node-invalid',
value: null,
invalidMode: true,
},
});
flushSync();
const radios = host.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(Array.from(radios).every((radio) => !radio.checked)).toBe(true);
expect(host.querySelector('[role="alert"]')?.textContent).toContain('配置无效');
await unmount(app);
});
});

View File

@@ -15,6 +15,13 @@
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import type {TinyflowNodeData} from '#types'; import type {TinyflowNodeData} from '#types';
import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver'; import {useTinyflowNodeSizeObserver} from '../utils/nodeSizeObserver';
import {useTinyflowStore} from '#store/stores.svelte';
import NodeJoinModeSetting from './NodeJoinModeSetting.svelte';
import {
analyzeJoinMode,
getJoinModeBadge,
type JoinMode,
} from '../utils/joinMode';
const { const {
data, data,
@@ -52,13 +59,22 @@
const activeKeys = $derived.by(() => data.expand ? ['key'] : []); const activeKeys = $derived.by(() => data.expand ? ['key'] : []);
const { updateNodeData, getNode } = useSvelteFlow(); const { updateNodeData, getNode } = useSvelteFlow();
const store = useTinyflowStore();
const updateNodeInternals = useUpdateNodeInternals(); const updateNodeInternals = useUpdateNodeInternals();
const joinModeAnalysis = $derived.by(() => analyzeJoinMode(
store.getNodes(),
store.getEdges(),
id,
));
const joinModeBadge = $derived(getJoinModeBadge(data.joinMode));
const items = $derived.by(() => { const items = $derived.by(() => {
return [{ return [{
key: 'key', key: 'key',
icon, icon,
title: data.title as string, title: data.title as string,
badge: joinModeBadge,
titleHelp, titleHelp,
description: data.description as string, description: data.description as string,
content: children content: children
@@ -87,6 +103,17 @@
const MIN_LOOP_COUNT = 1; const MIN_LOOP_COUNT = 1;
const MAX_LOOP_COUNT = 300; const MAX_LOOP_COUNT = 300;
let loopCountHint = $state(''); let loopCountHint = $state('');
let advancedConditionOpen = $state(false);
const updateJoinMode = (joinMode: JoinMode) => {
updateNodeData(id, {joinMode});
};
$effect(() => {
if (String(data.condition || '').trim()) {
advancedConditionOpen = true;
}
});
const normalizeLoopCount = (value: unknown) => { const normalizeLoopCount = (value: unknown) => {
const parsed = Number(value); const parsed = Number(value);
@@ -210,16 +237,35 @@
}} value={data.description} /> }} value={data.description} />
</div> </div>
{#if joinModeAnalysis.incomingCount > 1}
<NodeJoinModeSetting
name={`join-mode-${id}`}
value={joinModeAnalysis.mode}
invalidMode={joinModeAnalysis.invalidMode}
allDisabledReason={joinModeAnalysis.allAllowed
? ''
: joinModeAnalysis.allDisabledReason}
onChange={updateJoinMode}
/>
{/if}
{#if allowSettingOfCondition} {#if allowSettingOfCondition}
<div class="input-item"> <details class="advanced-condition" bind:open={advancedConditionOpen}>
执行条件: <summary>高级执行条件</summary>
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{ <div class="advanced-condition-content">
const value = (event.target as any).value; <Textarea
updateNodeData(currentNodeId,{ rows={3}
condition: value style="width: 100%;"
}) placeholder="可选:输入 JavaScript 条件表达式"
}} value={data.condition} /> onchange={(event)=>{
const value = (event.target as HTMLTextAreaElement).value;
updateNodeData(currentNodeId, {condition: value})
}}
value={data.condition}
/>
<span>汇聚条件满足后才会计算此表达式。</span>
</div> </div>
</details>
{/if} {/if}
<label class="input-item-inline"> <label class="input-item-inline">
@@ -362,13 +408,18 @@
.settings { .settings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
padding: 10px; padding: 12px;
background: var(--tf-bg-surface); background: var(--tf-bg-surface);
border: 1px solid var(--tf-border-color-strong); border: 1px solid var(--tf-border-color-strong);
border-radius: 5px; box-sizing: border-box;
width: 200px; width: 304px;
max-height: min(560px, 72vh);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 12px;
box-shadow: var(--tf-shadow-medium); box-shadow: var(--tf-shadow-medium);
scrollbar-color: var(--tf-scrollbar-thumb) transparent;
.input-item { .input-item {
display: flex; display: flex;
@@ -390,6 +441,66 @@
line-height: 1.4; line-height: 1.4;
} }
.advanced-condition {
color: var(--tf-text-secondary);
border-top: 1px solid var(--tf-border-color-soft);
summary {
display: flex;
gap: 6px;
align-items: center;
padding: 10px 0 0;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: var(--tf-text-primary);
cursor: pointer;
list-style: none;
outline: none;
&::-webkit-details-marker {
display: none;
}
&::before {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-size: 16px;
font-weight: 400;
line-height: 1;
color: var(--tf-text-muted);
content: '';
transition: transform 140ms ease;
transform-origin: center;
}
}
summary:focus-visible {
border-radius: 5px;
box-shadow: var(--tf-focus-shadow);
}
&[open] > summary::before {
transform: rotate(90deg);
}
&-content {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
span {
font-size: 11px;
line-height: 1.4;
color: var(--tf-text-muted);
}
}
}
input[type='checkbox'] { input[type='checkbox'] {
width: 14px; width: 14px;
height: 14px; height: 14px;

View File

@@ -0,0 +1,157 @@
import type { Edge, Node } from '@xyflow/svelte';
import { describe, expect, it } from 'vitest';
import {
analyzeJoinMode,
getJoinModeBadge,
parseJoinMode,
} from './joinMode';
const node = (
id: string,
type = 'codeNode',
data: Record<string, unknown> = {},
parentId?: string,
) => ({ id, type, data, parentId, position: { x: 0, y: 0 } }) as Node;
const edge = (
id: string,
source: string,
target: string,
condition = '',
) => ({
id,
source,
target,
data: condition ? { condition } : {},
}) as Edge;
describe('join mode graph analysis', () => {
it('defaults to any and only exposes a multi-inbound setting', () => {
const nodes = [node('start', 'startNode'), node('join')];
expect(analyzeJoinMode(nodes, [], 'join')).toMatchObject({
incomingCount: 0,
mode: 'any',
allAllowed: true,
});
expect(analyzeJoinMode(
nodes,
[edge('e1', 'start', 'join')],
'join',
).incomingCount).toBe(1);
});
it('allows all when every parallel source is guaranteed', () => {
const nodes = [
node('start', 'startNode'),
node('a'),
node('b'),
node('join', 'codeNode', { joinMode: 'all' }),
];
const edges = [
edge('start-a', 'start', 'a'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
];
expect(analyzeJoinMode(nodes, edges, 'join')).toMatchObject({
incomingCount: 2,
mode: 'all',
invalidMode: false,
allAllowed: true,
});
});
it('blocks conditional and custom-conditioned upstream paths', () => {
const conditionalNodes = [
node('start', 'startNode'),
node('a'),
node('b'),
node('join'),
];
const conditionalEdges = [
edge('start-a', 'start', 'a', 'matched === true'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
];
const conditional = analyzeJoinMode(
conditionalNodes,
conditionalEdges,
'join',
);
expect(conditional.allAllowed).toBe(false);
expect(conditional.allDisabledReason).toContain('无法证明必达');
const directConditional = analyzeJoinMode(
conditionalNodes,
[
edge('start-a', 'start', 'a'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join', 'matched === true'),
edge('b-join', 'b', 'join'),
],
'join',
);
expect(directConditional.allAllowed).toBe(false);
expect(directConditional.allDisabledReason).toContain('直接入边');
const customCondition = analyzeJoinMode(
[
node('start', 'startNode'),
node('a', 'codeNode', { condition: 'score > 0' }),
node('b'),
node('join'),
],
[
edge('start-a', 'start', 'a'),
edge('start-b', 'start', 'b'),
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
],
'join',
);
expect(customCondition.allAllowed).toBe(false);
expect(customCondition.allDisabledReason).toContain('高级执行条件');
});
it('blocks loop children without silently rewriting existing all', () => {
const analysis = analyzeJoinMode(
[
node('loop', 'loopNode'),
node('a', 'codeNode', {}, 'loop'),
node('b', 'codeNode', {}, 'loop'),
node('join', 'codeNode', { joinMode: 'all' }, 'loop'),
],
[
edge('a-join', 'a', 'join'),
edge('b-join', 'b', 'join'),
],
'join',
);
expect(analysis.mode).toBe('all');
expect(analysis.allAllowed).toBe(false);
expect(analysis.allDisabledReason).toContain('显式循环子图');
});
it('keeps invalid raw values visible and exposes the all badge', () => {
expect(parseJoinMode('unexpected')).toBeNull();
expect(parseJoinMode('')).toBeNull();
expect(parseJoinMode(null)).toBeNull();
expect(parseJoinMode(undefined)).toBe('any');
expect(getJoinModeBadge('ALL')).toBe('等待全部');
expect(getJoinModeBadge('any')).toBe('');
const analysis = analyzeJoinMode(
[node('join', 'codeNode', { joinMode: 'unexpected' })],
[],
'join',
);
expect(analysis.invalidMode).toBe(true);
expect(analysis.mode).toBeNull();
});
});

View File

@@ -0,0 +1,163 @@
import type { Edge, Node } from '@xyflow/svelte';
export const JOIN_MODE_ANY = 'any';
export const JOIN_MODE_ALL = 'all';
export type JoinMode = typeof JOIN_MODE_ANY | typeof JOIN_MODE_ALL;
export type JoinModeAnalysis = {
incomingCount: number;
mode: JoinMode | null;
invalidMode: boolean;
allAllowed: boolean;
allDisabledReason: string;
};
const text = (value: unknown) => (value == null ? '' : String(value).trim());
export function parseJoinMode(value: unknown): JoinMode | null {
if (value === undefined) {
return JOIN_MODE_ANY;
}
const normalized = text(value).toLowerCase();
if (normalized === JOIN_MODE_ANY) {
return JOIN_MODE_ANY;
}
if (normalized === JOIN_MODE_ALL) {
return JOIN_MODE_ALL;
}
return null;
}
export function getJoinModeBadge(value: unknown) {
return parseJoinMode(value) === JOIN_MODE_ALL ? '等待全部' : '';
}
export function analyzeJoinMode(
nodes: Node[],
edges: Edge[],
nodeId: string,
): JoinModeAnalysis {
const targetNode = nodes.find((node) => node.id === nodeId);
const directInward = edges.filter((edge) => edge.target === nodeId);
const hasJoinMode = targetNode?.data
? Object.prototype.hasOwnProperty.call(targetNode.data, 'joinMode')
: false;
const mode = parseJoinMode(targetNode?.data?.joinMode);
const invalidMode = hasJoinMode && mode === null;
if (!targetNode) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: false,
allDisabledReason: '节点不存在,请刷新画布后重试。',
};
}
if (text(targetNode.parentId)) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: false,
allDisabledReason: '显式循环子图暂不支持等待全部上游,请使用“任一上游完成”。',
};
}
if (directInward.length <= 1) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: true,
allDisabledReason: '',
};
}
const guaranteedNodes = findGuaranteedNodes(nodes, edges);
const allAllowed = directInward.every(
(edge) => !hasEdgeCondition(edge) && guaranteedNodes.has(edge.source),
);
if (allAllowed) {
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: true,
allDisabledReason: '',
};
}
let reason = '存在条件、互斥或无法证明必达的上游路径,可能永久等待。请调整连线或使用“任一上游完成”。';
if (directInward.some(hasEdgeCondition)) {
reason = '存在带条件的直接入边,部分入边可能不会到达。请调整连线或使用“任一上游完成”。';
} else if (directInward.some((edge) => {
const source = nodes.find((node) => node.id === edge.source);
return hasAdvancedCondition(source);
})) {
reason = '上游节点包含高级执行条件,无法保证每条入边都会到达。请调整条件或使用“任一上游完成”。';
}
return {
incomingCount: directInward.length,
mode,
invalidMode,
allAllowed: false,
allDisabledReason: reason,
};
}
function findGuaranteedNodes(nodes: Node[], edges: Edge[]) {
const guaranteed = new Set(
nodes
.filter((node) => !text(node.parentId) && node.type === 'startNode')
.map((node) => node.id),
);
const inwardByTarget = new Map<string, Edge[]>();
edges.forEach((edge) => {
const inward = inwardByTarget.get(edge.target) || [];
inward.push(edge);
inwardByTarget.set(edge.target, inward);
});
let changed = true;
while (changed) {
changed = false;
nodes.forEach((node) => {
if (
text(node.parentId)
|| guaranteed.has(node.id)
|| hasAdvancedCondition(node)
) {
return;
}
const mode = parseJoinMode(node.data?.joinMode);
if (!mode) {
return;
}
const inward = inwardByTarget.get(node.id) || [];
const isGuaranteed = mode === JOIN_MODE_ALL
? inward.length > 0 && inward.every(
(edge) => !hasEdgeCondition(edge) && guaranteed.has(edge.source),
)
: inward.some(
(edge) => !hasEdgeCondition(edge) && guaranteed.has(edge.source),
);
if (isGuaranteed) {
guaranteed.add(node.id);
changed = true;
}
});
}
return guaranteed;
}
function hasAdvancedCondition(node: Node | undefined) {
return Boolean(text(node?.data?.condition));
}
function hasEdgeCondition(edge: Edge) {
return Boolean(text(edge.data?.condition));
}