feat: 优化工作流参数编辑与告警交互

- 新增 ParamTokenEditor,支持参数选择插入、token 高亮、整段删除与光标避让

- 参数候选改为动态监测,未映射参数可选择并在下拉与输入框顶部告警

- 接入知识库/搜索引擎/LLM/动态代码/HTTP Body 及 SQL、查询数据自定义节点

- 优化 Http 节点布局并补充参数解析工具与单测
This commit is contained in:
2026-02-28 21:37:49 +08:00
parent 59c95a3b06
commit 4ef17da6f4
13 changed files with 1465 additions and 120 deletions

View File

@@ -0,0 +1,748 @@
<script lang="ts">
import { tick } from 'svelte';
import {Button, FloatingTrigger} from '../base';
import type {Parameter} from '#types';
import {
escapeHtml,
flattenParameterCandidates,
findBackspaceTokenRange,
findTokenRangeAtCursor,
insertTextAtCursor,
parseTokenParts,
splitTokenDisplay
} from '../utils/paramToken';
const {
mode = 'textarea',
value = '',
parameters = [],
rows = 3,
placeholder = '',
disabled = false,
class: className = '',
style = '',
...rest
}: {
mode?: 'input' | 'textarea';
value?: string;
parameters?: Parameter[];
rows?: number;
placeholder?: string;
disabled?: boolean;
class?: string;
style?: string;
[key: string]: any;
} = $props();
let inputEl = $state<HTMLInputElement | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let highlightEl = $state<HTMLDivElement | null>(null);
let triggerObject: any;
let isFocused = $state(false);
let isComposing = $state(false);
let localValue = $state((value || '') as string);
$effect(() => {
const nextValue = (value || '') as string;
if (!isFocused && nextValue !== localValue) {
localValue = nextValue;
}
});
const paramCandidates = $derived(flattenParameterCandidates(parameters));
const paramNames = $derived(paramCandidates.map((item) => item.name));
const unresolvedParamSet = $derived.by(() => {
return new Set(paramCandidates.filter((item) => !item.resolved).map((item) => item.name));
});
const hasParams = $derived(paramCandidates.length > 0);
const tokenParts = $derived(parseTokenParts(localValue, paramNames));
const unresolvedTokensInEditor = $derived.by(() => {
const keySet = new Set<string>();
for (const part of tokenParts) {
if (part.type === 'token' && part.valid && unresolvedParamSet.has(part.key)) {
keySet.add(part.key);
}
}
return Array.from(keySet);
});
const undefinedTokensInEditor = $derived.by(() => {
const keySet = new Set<string>();
for (const part of tokenParts) {
if (part.type === 'token' && !part.valid) {
keySet.add(part.key);
}
}
return Array.from(keySet);
});
const showInlineHint = $derived(unresolvedTokensInEditor.length > 0 || undefinedTokensInEditor.length > 0);
const highlightedHtml = $derived.by(() => {
return tokenParts
.map((part) => {
if (part.type === 'text') {
return escapeHtml(part.text);
}
const cssClass = !part.valid
? 'param-token-invalid'
: unresolvedParamSet.has(part.key)
? 'param-token-warning'
: 'param-token-valid';
const display = splitTokenDisplay(part.text, part.key);
return `<span class="param-token-token"><span class="param-token-chip ${cssClass}"><span class="param-token-hidden">${escapeHtml(display.hiddenPrefix)}</span><span class="param-token-chip-text">${escapeHtml(display.visibleText)}</span><span class="param-token-hidden">${escapeHtml(display.hiddenSuffix)}</span></span></span>`;
})
.join('');
});
const getEditorElement = () => {
return mode === 'input' ? inputEl : textareaEl;
};
const emitInput = (event?: Event) => {
if (event) {
rest.oninput?.(event);
return;
}
rest.oninput?.({
target: {
value: localValue
}
});
};
const emitChange = (event?: Event) => {
if (event) {
rest.onchange?.(event);
return;
}
rest.onchange?.({
target: {
value: localValue
}
});
};
const syncScroll = () => {
const el = getEditorElement();
if (!el || !highlightEl) {
return;
}
highlightEl.scrollTop = el.scrollTop;
highlightEl.scrollLeft = el.scrollLeft;
};
const handleInput = (event: Event) => {
localValue = ((event.target as HTMLInputElement | HTMLTextAreaElement).value || '') as string;
syncScroll();
emitInput(event);
};
const handleChange = (event: Event) => {
localValue = ((event.target as HTMLInputElement | HTMLTextAreaElement).value || '') as string;
emitChange(event);
};
const insertParam = async (paramName: string) => {
const editorEl = getEditorElement();
const result = insertTextAtCursor(
localValue,
`{{${paramName}}}`,
editorEl?.selectionStart,
editorEl?.selectionEnd
);
localValue = result.value;
emitInput();
emitChange();
await tick();
const newEditorEl = getEditorElement();
if (newEditorEl) {
newEditorEl.focus();
newEditorEl.setSelectionRange(result.cursor, result.cursor);
}
syncScroll();
triggerObject?.hide?.();
};
const handleFocus = (event: Event) => {
isFocused = true;
rest.onfocus?.(event);
};
const handleBlur = (event: Event) => {
isFocused = false;
rest.onblur?.(event);
};
const handleCompositionStart = (event: Event) => {
isComposing = true;
rest.oncompositionstart?.(event);
};
const handleCompositionEnd = (event: Event) => {
isComposing = false;
rest.oncompositionend?.(event);
};
const setCaretPosition = (position: number) => {
const editorEl = getEditorElement();
if (!editorEl) {
return;
}
editorEl.setSelectionRange(position, position);
};
const normalizeCaretAsync = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
moveCaretOutOfToken();
});
});
};
const moveCaretOutOfToken = (prefer?: 'start' | 'end') => {
const editorEl = getEditorElement();
if (!editorEl) {
return false;
}
const selectionStart = editorEl.selectionStart ?? 0;
const selectionEnd = editorEl.selectionEnd ?? 0;
if (selectionStart !== selectionEnd) {
return false;
}
const tokenRange = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: false,
includeEnd: false
});
if (!tokenRange) {
return false;
}
const target =
prefer === 'start'
? tokenRange.start
: prefer === 'end'
? tokenRange.end
: selectionStart - tokenRange.start <= tokenRange.end - selectionStart
? tokenRange.start
: tokenRange.end;
editorEl.setSelectionRange(target, target);
return true;
};
const handleKeydown = async (event: KeyboardEvent) => {
if (disabled || isComposing) {
rest.onkeydown?.(event);
return;
}
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
if (selectionStart !== selectionEnd) {
rest.onkeydown?.(event);
return;
}
if (event.key === 'ArrowLeft') {
const leftToken = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: false,
includeEnd: true
});
if (leftToken && leftToken.end === selectionStart) {
event.preventDefault();
setCaretPosition(leftToken.start);
rest.onkeydown?.(event);
return;
}
if (moveCaretOutOfToken('start')) {
event.preventDefault();
}
rest.onkeydown?.(event);
return;
}
if (event.key === 'ArrowRight') {
const rightToken = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: true,
includeEnd: false
});
if (rightToken && rightToken.start === selectionStart) {
event.preventDefault();
setCaretPosition(rightToken.end);
rest.onkeydown?.(event);
return;
}
if (moveCaretOutOfToken('end')) {
event.preventDefault();
}
rest.onkeydown?.(event);
return;
}
const tokenRangeInside = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: false,
includeEnd: false
});
if (tokenRangeInside && event.key !== 'Backspace' && event.key !== 'Delete') {
event.preventDefault();
moveCaretOutOfToken();
rest.onkeydown?.(event);
return;
}
if (event.key === 'Delete') {
const deleteRange = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: true,
includeEnd: false
});
if (deleteRange) {
event.preventDefault();
const result = insertTextAtCursor(localValue, '', deleteRange.start, deleteRange.end);
localValue = result.value;
emitInput();
emitChange();
await tick();
const editorEl = getEditorElement();
if (editorEl) {
editorEl.focus();
editorEl.setSelectionRange(deleteRange.start, deleteRange.start);
}
syncScroll();
rest.onkeydown?.(event);
return;
}
}
if (event.key !== 'Backspace') {
rest.onkeydown?.(event);
return;
}
const tokenRange = findBackspaceTokenRange(localValue, selectionStart);
if (!tokenRange) {
rest.onkeydown?.(event);
return;
}
event.preventDefault();
const result = insertTextAtCursor(localValue, '', tokenRange.start, tokenRange.end);
localValue = result.value;
emitInput();
emitChange();
await tick();
const editorEl = getEditorElement();
if (editorEl) {
editorEl.focus();
editorEl.setSelectionRange(tokenRange.start, tokenRange.start);
}
syncScroll();
rest.onkeydown?.(event);
};
const handleSelect = (event: Event) => {
normalizeCaretAsync();
rest.onselect?.(event);
};
const handleMouseUp = (event: MouseEvent) => {
normalizeCaretAsync();
rest.onmouseup?.(event);
};
const handleKeyup = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') {
normalizeCaretAsync();
}
rest.onkeyup?.(event);
};
const handleClick = (event: MouseEvent) => {
normalizeCaretAsync();
rest.onclick?.(event);
};
</script>
<div class="param-token-editor {className}" style={style}>
{#if showInlineHint}
<div class="param-token-inline-hint">
{#if unresolvedTokensInEditor.length > 0}
<div class="hint-item hint-unresolved">
<span class="hint-item-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12S6.486 22 12 22 22 17.514 22 12 17.514 2 12 2zm0 15a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 12 17zm1-4h-2V7h2v6z"></path>
</svg>
</span>
<span>
未映射参数:{unresolvedTokensInEditor.join('、')}
</span>
</div>
{/if}
{#if undefinedTokensInEditor.length > 0}
<div class="hint-item hint-undefined">
<span class="hint-item-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12S6.486 22 12 22 22 17.514 22 12 17.514 2 12 2zm0 15a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 12 17zm1-4h-2V7h2v6z"></path>
</svg>
</span>
<span>
未定义参数:{undefinedTokensInEditor.join('、')}
</span>
</div>
{/if}
</div>
{/if}
<div class="param-token-editor-inner">
<div
class="param-token-editor-highlight {mode === 'input' ? 'single-line' : 'multi-line'}"
bind:this={highlightEl}
aria-hidden="true"
>
{@html highlightedHtml}
</div>
{#if mode === 'input'}
<input
{...rest}
bind:this={inputEl}
type="text"
class="tf-input param-token-input {rest.class}"
placeholder={placeholder}
value={localValue}
disabled={disabled}
spellcheck="false"
onfocus={handleFocus}
onblur={handleBlur}
oninput={handleInput}
onchange={handleChange}
onscroll={syncScroll}
onkeydown={handleKeydown}
onselect={handleSelect}
onmouseup={handleMouseUp}
onclick={handleClick}
onkeyup={handleKeyup}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
/>
{:else}
<textarea
{...rest}
bind:this={textareaEl}
class="tf-textarea param-token-textarea {rest.class}"
placeholder={placeholder}
value={localValue}
rows={rows}
disabled={disabled}
spellcheck="false"
onfocus={handleFocus}
onblur={handleBlur}
oninput={handleInput}
onchange={handleChange}
onscroll={syncScroll}
onkeydown={handleKeydown}
onselect={handleSelect}
onmouseup={handleMouseUp}
onclick={handleClick}
onkeyup={handleKeyup}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
></textarea>
{/if}
<div class="param-token-action">
<FloatingTrigger placement="bottom-end" bind:this={triggerObject}>
<Button
class="param-token-button"
disabled={!hasParams || disabled}
title={hasParams ? '选择参数' : '请先在输入参数中定义参数'}
>
<span>{"{x}"}</span>
</Button>
{#snippet floating()}
<div class="param-token-panel nowheel">
{#if hasParams}
{#each paramCandidates as candidate}
<button
class="param-token-item {candidate.resolved ? '' : 'unresolved'}"
title={candidate.resolved ? candidate.name : `${candidate.name}(未配置引用值)`}
onclick={() => {
insertParam(candidate.name);
}}
>
<span>{candidate.name}</span>
{#if !candidate.resolved}
<span
class="param-token-item-warn"
title="该参数尚未映射引用值,运行时可能为空"
aria-label="未映射参数"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 1 21h22L12 2zm0 5.2 6.4 11.8H5.6L12 7.2zm-1 3.3v4.6h2v-4.6h-2zm0 5.8v1.9h2v-1.9h-2z"></path>
</svg>
</span>
{/if}
</button>
{/each}
{:else}
<div class="param-token-empty">请先在输入参数中定义参数</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
</div>
</div>
</div>
<style lang="less">
.param-token-editor {
width: 100%;
position: relative;
}
.param-token-inline-hint {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
line-height: 1.4;
}
.hint-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
border: 1px solid transparent;
}
.hint-item-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
svg {
width: 14px;
height: 14px;
display: block;
}
}
.hint-item.hint-unresolved {
background: #fff8e6;
border-color: #f6d99a;
color: #8a5a00;
.hint-item-icon {
color: #d97706;
}
}
.hint-item.hint-undefined {
background: #fff1f1;
border-color: #f5c7c4;
color: #b42318;
.hint-item-icon {
color: #d92d20;
}
}
.param-token-editor-inner {
width: 100%;
position: relative;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
}
.param-token-editor-highlight {
position: absolute;
inset: 0;
border: 1px solid transparent;
border-radius: 5px;
padding: 5px 8px;
box-sizing: border-box;
color: #333;
pointer-events: none;
z-index: 0;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.6;
letter-spacing: inherit;
overflow: hidden;
}
.param-token-editor-highlight.single-line {
white-space: pre;
word-break: normal;
}
.param-token-editor-highlight.multi-line {
white-space: pre-wrap;
word-break: break-word;
}
.param-token-input,
.param-token-textarea {
width: 100%;
position: relative;
z-index: 1;
background: transparent;
color: transparent;
caret-color: #333;
padding-right: 36px;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.6;
letter-spacing: inherit;
}
.param-token-input::placeholder,
.param-token-textarea::placeholder {
color: #ccc;
}
.param-token-textarea {
overflow: auto;
}
.param-token-action {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
}
:global(.param-token-button) {
height: 20px;
min-width: 20px;
border: 1px solid transparent;
padding: 0 4px;
font-size: 10px;
border-radius: 4px;
color: #666;
background: rgba(255, 255, 255, 0.9);
}
:global(.param-token-button:hover) {
background: #f0f3ff;
color: #2563eb;
border-color: #d3defd;
}
.param-token-panel {
min-width: 200px;
max-width: 320px;
max-height: 220px;
overflow: auto;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.param-token-item {
border: none;
background: #fff;
padding: 6px 8px;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
line-height: 1.3;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.param-token-item:hover {
background: #f5f7ff;
color: #2563eb;
}
.param-token-item.unresolved {
background: #fff8e6;
color: #92400e;
}
.param-token-item-warn {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #d97706;
flex-shrink: 0;
svg {
width: 14px;
height: 14px;
display: block;
}
}
.param-token-empty {
padding: 8px;
font-size: 12px;
color: #999;
text-align: center;
}
:global(.param-token-hidden) {
color: transparent;
}
:global(.param-token-token) {
display: inline;
}
:global(.param-token-chip) {
display: inline-block;
vertical-align: baseline;
line-height: inherit;
white-space: nowrap;
border-radius: 4px;
padding: 0;
margin: 0;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
:global(.param-token-chip-text) {
position: relative;
}
:global(.param-token-valid) {
background: #eaf2ff;
color: #1e4ed8;
box-shadow: inset 0 0 0 1px #c6d8ff;
}
:global(.param-token-invalid) {
background: #fff1f1;
color: #b42318;
box-shadow: inset 0 0 0 1px #f5c7c4;
}
:global(.param-token-warning) {
background: #fff8e6;
color: #92400e;
box-shadow: inset 0 0 0 1px #f7d79e;
}
</style>

View File

@@ -42,7 +42,7 @@
[key]: value
};
return {
[dataKeyName]: parameters
[dataKeyName]: [...parameters]
};
});
};
@@ -172,4 +172,3 @@
</style>