feat: 优化工作流参数编辑与告警交互
- 新增 ParamTokenEditor,支持参数选择插入、token 高亮、整段删除与光标避让 - 参数候选改为动态监测,未映射参数可选择并在下拉与输入框顶部告警 - 接入知识库/搜索引擎/LLM/动态代码/HTTP Body 及 SQL、查询数据自定义节点 - 优化 Http 节点布局并补充参数解析工具与单测
This commit is contained in:
@@ -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>
|
||||
@@ -42,7 +42,7 @@
|
||||
[key]: value
|
||||
};
|
||||
return {
|
||||
[dataKeyName]: parameters
|
||||
[dataKeyName]: [...parameters]
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -172,4 +172,3 @@
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import {Textarea} from '../base/index.js';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
// 添加生命周期函数
|
||||
import {onMount} from 'svelte';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -25,7 +25,11 @@
|
||||
}
|
||||
});
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
@@ -76,15 +80,21 @@
|
||||
|
||||
<div class="setting-title">执行代码</div>
|
||||
<div class="setting-item">
|
||||
<Textarea rows={10}
|
||||
<ParamTokenEditor
|
||||
mode="textarea"
|
||||
rows={10}
|
||||
placeholder="请输入执行代码,注:输出内容需添加到_result中,如:_result['key'] = value 或者 _result.key = value"
|
||||
style="width: 100%" onchange={(e:any)=>{
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
oninput={(e:any)=>{
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
code: e.target.value
|
||||
}
|
||||
})
|
||||
}} value={data.code as string||""} />
|
||||
}}
|
||||
value={data.code as string||""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -125,5 +135,3 @@
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type Node, type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {type Node, type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
@@ -8,6 +8,7 @@
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import {fillParameterId} from '../utils/useAddParameter.svelte.js';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -15,9 +16,13 @@
|
||||
} = $props();
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const flowInstance = useSvelteFlow();
|
||||
const { updateNodeData: updateNodeDataInner } = flowInstance;
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
|
||||
const updateNodeData = (data: Record<string, any>) => {
|
||||
updateNodeDataInner(currentNodeId, data);
|
||||
@@ -104,19 +109,47 @@
|
||||
{#if form.type === 'input'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
<div class="setting-item">
|
||||
<Input
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
{...form.attrs}
|
||||
onchange={(e)=>{
|
||||
{#if form.templateSupport}
|
||||
<ParamTokenEditor
|
||||
mode="input"
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
parameters={editorParameters}
|
||||
{...form.attrs}
|
||||
oninput={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
{...form.attrs}
|
||||
onchange={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if form.type === 'textarea'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
<div class="setting-item">
|
||||
{#if form.templateSupport}
|
||||
<ParamTokenEditor
|
||||
mode="textarea"
|
||||
rows={3}
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
parameters={editorParameters}
|
||||
{...form.attrs}
|
||||
oninput={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={form.placeholder}
|
||||
@@ -127,6 +160,7 @@
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if form.type === 'slider'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
@@ -246,5 +280,3 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import {Input, Textarea} from '../base/index.js';
|
||||
import {Input} from '../base/index.js';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
// 添加生命周期函数
|
||||
import {onMount} from 'svelte';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -60,10 +61,23 @@
|
||||
{ value: 'head', label: 'HEAD' },
|
||||
{ value: 'patch', label: 'PATCH' }
|
||||
];
|
||||
const bodyTypeOptions = [
|
||||
{ value: '', label: 'none' },
|
||||
{ value: 'form-data', label: 'form-data' },
|
||||
{ value: 'x-www-form-urlencoded', label: 'x-www-form-urlencoded' },
|
||||
{ value: 'json', label: 'json' },
|
||||
{ value: 'raw', label: 'raw' }
|
||||
];
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
const bodyMethods = new Set(['post', 'put', 'delete', 'patch']);
|
||||
const showBodyConfig = $derived(bodyMethods.has((data.method || '').toLowerCase()));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -89,9 +103,10 @@
|
||||
</div>
|
||||
<RefParameterList />
|
||||
|
||||
<Heading level={3} mt="10px">URL 地址</Heading>
|
||||
<div style="display: flex;gap: 2px;width: 100%;padding: 10px 0">
|
||||
<div>
|
||||
<div class="http-section">
|
||||
<div class="section-title">请求配置</div>
|
||||
<div class="http-url-row">
|
||||
<div class="method-select">
|
||||
<Select items={method} style="width: 100%" placeholder="请选择请求方式" onSelect={(item)=>{
|
||||
const newValue = item.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
@@ -100,80 +115,68 @@
|
||||
}
|
||||
})
|
||||
}} value={data.method ? [data.method] : ['get']} />
|
||||
</div>
|
||||
<div style="width: 100%">
|
||||
<Input placeholder="请输入url" style="width: 100%" onchange={(e:any)=>{
|
||||
</div>
|
||||
<div class="url-input-wrap">
|
||||
<Input
|
||||
style="width: 100%"
|
||||
placeholder="请输入 URL"
|
||||
value={data.url as string||""}
|
||||
onchange={(e:any)=>{
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
url: e.target.value
|
||||
}
|
||||
})
|
||||
}} value={data.url as string||""} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>Http 头信息</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
<div class="http-section">
|
||||
<div class="heading heading-tight">
|
||||
<Heading level={3}>Http 头信息</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId,'headers')
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList dataKeyName="headers" />
|
||||
</div>
|
||||
<RefParameterList dataKeyName="headers" />
|
||||
|
||||
{#if data.method === 'post' || data.method === 'put' || data.method === 'delete' || data.method === 'patch'}
|
||||
|
||||
<Heading level={3} mt="10px">Body</Heading>
|
||||
<div class="radio-group">
|
||||
<label><Input type="radio" value="" checked={!data.bodyType || data.bodyType === ''}
|
||||
onchange={(e:any)=>{
|
||||
if (e.target?.checked){
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyType: '',
|
||||
})
|
||||
}
|
||||
}} />none</label>
|
||||
<label><Input type="radio" value="form-data" checked={data.bodyType === 'form-data'}
|
||||
onchange={(e:any)=>{
|
||||
if (e.target?.checked){
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyType: 'form-data',
|
||||
})
|
||||
}
|
||||
}} />form-data</label>
|
||||
<label><Input type="radio" value="x-www-form-urlencoded"
|
||||
checked={data.bodyType === 'x-www-form-urlencoded'} onchange={(e:any)=>{
|
||||
if (e.target?.checked){
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyType: 'x-www-form-urlencoded',
|
||||
})
|
||||
}
|
||||
}} />x-www-form-urlencoded</label>
|
||||
<label><Input type="radio" value="json" checked={data.bodyType === 'json'}
|
||||
onchange={(e:any)=>{
|
||||
if (e.target?.checked){
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyType: 'json',
|
||||
})
|
||||
}
|
||||
}} />json</label>
|
||||
<label><Input type="radio" value="raw" checked={data.bodyType === 'raw'}
|
||||
onchange={(e:any)=>{
|
||||
if (e.target?.checked){
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyType: 'raw',
|
||||
})
|
||||
}
|
||||
}} />raw</label>
|
||||
{#if showBodyConfig}
|
||||
<div class="http-section">
|
||||
<div class="section-title">Body 类型</div>
|
||||
<div class="radio-group">
|
||||
{#each bodyTypeOptions as option}
|
||||
<label class:active={((data.bodyType || '') === option.value)}>
|
||||
<Input
|
||||
type="radio"
|
||||
name={"http-body-type-" + currentNodeId}
|
||||
value={option.value}
|
||||
checked={(data.bodyType || '') === option.value}
|
||||
onchange={(e:any)=>{
|
||||
if (e.target?.checked){
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyType: option.value,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if data.bodyType === 'form-data'}
|
||||
<div class="heading" style="padding-top: 10px">
|
||||
<div class="http-section">
|
||||
<div class="heading heading-tight">
|
||||
<Heading level={3}>参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId,'formData')
|
||||
@@ -182,13 +185,15 @@
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList dataKeyName="formData" />
|
||||
</div>
|
||||
<RefParameterList dataKeyName="formData" />
|
||||
{/if}
|
||||
|
||||
|
||||
{#if data.bodyType === 'x-www-form-urlencoded'}
|
||||
<div class="heading" style="padding-top: 10px">
|
||||
<div class="http-section">
|
||||
<div class="heading heading-tight">
|
||||
<Heading level={3}>Body 参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId,'formUrlencoded')
|
||||
@@ -197,13 +202,20 @@
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList dataKeyName="formUrlencoded" />
|
||||
</div>
|
||||
<RefParameterList dataKeyName="formUrlencoded" />
|
||||
{/if}
|
||||
|
||||
{#if data.bodyType === 'json'}
|
||||
<div style="width: 100%">
|
||||
<Textarea rows={5} style="width: 100%" placeholder="请输入 json 信息" value={data.bodyJson}
|
||||
<div class="http-section">
|
||||
<div class="section-title">JSON Body</div>
|
||||
<ParamTokenEditor
|
||||
mode="textarea"
|
||||
rows={5}
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
placeholder="请输入 json 信息" value={data.bodyJson}
|
||||
oninput={(e:any)=>{
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyJson: e.target.value,
|
||||
@@ -214,8 +226,14 @@
|
||||
|
||||
|
||||
{#if data.bodyType === 'raw'}
|
||||
<div style="width: 100%">
|
||||
<Textarea rows={5} style="width: 100%" placeholder="请输入请求信息" value={data.bodyRaw}
|
||||
<div class="http-section">
|
||||
<div class="section-title">Raw Body</div>
|
||||
<ParamTokenEditor
|
||||
mode="textarea"
|
||||
rows={5}
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
placeholder="请输入请求信息" value={data.bodyRaw}
|
||||
oninput={(e:any)=>{
|
||||
updateNodeData(currentNodeId,{
|
||||
bodyRaw: e.target.value,
|
||||
@@ -242,22 +260,78 @@
|
||||
<style lang="less">
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.heading-tight {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.http-section {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 8px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.http-url-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.method-select {
|
||||
width: 118px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.url-input-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
margin: 10px 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #dbe3f3;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
label.active {
|
||||
background: #eef4ff;
|
||||
border-color: #c7d8ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
:global(input[type='radio']) {
|
||||
margin: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Input, Select} from '../base';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
@@ -9,6 +9,7 @@
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -16,7 +17,11 @@
|
||||
} = $props();
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
@@ -112,9 +117,13 @@
|
||||
|
||||
<div class="setting-title">关键字</div>
|
||||
<div class="setting-item">
|
||||
<Input placeholder="请输入关键字" style="width: 100%"
|
||||
value={data.keyword}
|
||||
onchange={(e)=>{
|
||||
<ParamTokenEditor
|
||||
mode="input"
|
||||
placeholder="请输入关键字"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.keyword || ''}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
@@ -128,14 +137,21 @@
|
||||
|
||||
<div class="setting-title">获取数据量</div>
|
||||
<div class="setting-item">
|
||||
<Input placeholder="搜索的数据条数" style="width: 100%" onchange={(e)=>{
|
||||
<ParamTokenEditor
|
||||
mode="input"
|
||||
placeholder="搜索的数据条数"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
limit: newValue
|
||||
}
|
||||
})
|
||||
}} value={data.limit || ''} />
|
||||
}}
|
||||
value={data.limit || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -169,5 +185,3 @@
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, FloatingTrigger, Heading, Select} from '../base';
|
||||
import {MenuButton, Textarea} from '../base/index.js';
|
||||
import {MenuButton} from '../base/index.js';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
@@ -10,6 +10,7 @@
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -17,7 +18,11 @@
|
||||
} = $props();
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
@@ -177,25 +182,29 @@
|
||||
|
||||
<div class="setting-title">系统提示词</div>
|
||||
<div class="setting-item">
|
||||
<Textarea rows={5}
|
||||
<ParamTokenEditor
|
||||
mode="textarea"
|
||||
rows={5}
|
||||
placeholder="请输入系统提示词"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.systemPrompt || ''}
|
||||
oninput={(e)=>{
|
||||
updateNodeData(currentNodeId, {
|
||||
systemPrompt: e.target.value
|
||||
});
|
||||
}}
|
||||
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-title">用户提示词</div>
|
||||
<div class="setting-item">
|
||||
<Textarea
|
||||
<ParamTokenEditor
|
||||
mode="textarea"
|
||||
rows={5}
|
||||
placeholder="请输入用户提示词"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.userPrompt || ''}
|
||||
oninput={(e)=>{
|
||||
updateNodeData(currentNodeId, {
|
||||
@@ -288,5 +297,3 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Input, Select} from '../base';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
@@ -9,6 +9,7 @@
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -16,7 +17,11 @@
|
||||
} = $props();
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
@@ -99,9 +104,13 @@
|
||||
|
||||
<div class="setting-title">关键字</div>
|
||||
<div class="setting-item">
|
||||
<Input placeholder="请输入关键字" style="width: 100%"
|
||||
value={data.keyword}
|
||||
onchange={(e)=>{
|
||||
<ParamTokenEditor
|
||||
mode="input"
|
||||
placeholder="请输入关键字"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.keyword || ''}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
@@ -114,15 +123,21 @@
|
||||
|
||||
<div class="setting-title">搜索数据量</div>
|
||||
<div class="setting-item">
|
||||
<Input placeholder="搜索的数据条数" style="width: 100%" value={data.limit}
|
||||
onchange={(e)=>{
|
||||
<ParamTokenEditor
|
||||
mode="input"
|
||||
placeholder="搜索的数据条数"
|
||||
style="width: 100%"
|
||||
value={data.limit || ''}
|
||||
parameters={editorParameters}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
limit: newValue
|
||||
}
|
||||
})
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -156,5 +171,3 @@
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
flattenParameterCandidates,
|
||||
findBackspaceTokenRange,
|
||||
findTokenRangeAtCursor,
|
||||
flattenParameterNames,
|
||||
insertTextAtCursor,
|
||||
parseTokenParts,
|
||||
splitTokenDisplay
|
||||
} from './paramToken';
|
||||
|
||||
describe('paramToken utils', () => {
|
||||
it('should flatten parameter names with nested paths', () => {
|
||||
const result = flattenParameterNames([
|
||||
{
|
||||
name: 'input'
|
||||
},
|
||||
{
|
||||
name: 'documents',
|
||||
children: [
|
||||
{
|
||||
name: 'title'
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
children: [
|
||||
{
|
||||
name: 'author'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
'input',
|
||||
'documents',
|
||||
'documents.title',
|
||||
'documents.meta',
|
||||
'documents.meta.author'
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep unresolved candidates in parameter list', () => {
|
||||
const result = flattenParameterCandidates([
|
||||
{
|
||||
name: 'input',
|
||||
refType: 'ref',
|
||||
ref: ''
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
refType: 'ref',
|
||||
ref: 'documents'
|
||||
},
|
||||
{
|
||||
name: 'runtimeInput',
|
||||
refType: 'input'
|
||||
}
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'input',
|
||||
resolved: false
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
resolved: true
|
||||
},
|
||||
{
|
||||
name: 'runtimeInput',
|
||||
resolved: true
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should insert token text in the middle by cursor range', () => {
|
||||
const result = insertTextAtCursor('hello world', '{{input}}', 6, 11);
|
||||
expect(result).toEqual({
|
||||
value: 'hello {{input}}',
|
||||
cursor: 15
|
||||
});
|
||||
});
|
||||
|
||||
it('should append token text when cursor info is missing', () => {
|
||||
const result = insertTextAtCursor('hello', '{{name}}');
|
||||
expect(result).toEqual({
|
||||
value: 'hello{{name}}',
|
||||
cursor: 13
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse token parts and mark valid tokens', () => {
|
||||
const parts = parseTokenParts(
|
||||
'你好 {{ user.name }} 与 {{unknown}}',
|
||||
['user.name', 'docs']
|
||||
);
|
||||
|
||||
expect(parts).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '你好 '
|
||||
},
|
||||
{
|
||||
type: 'token',
|
||||
text: '{{ user.name }}',
|
||||
key: 'user.name',
|
||||
valid: true
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' 与 '
|
||||
},
|
||||
{
|
||||
type: 'token',
|
||||
text: '{{unknown}}',
|
||||
key: 'unknown',
|
||||
valid: false
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep plain text when token syntax is invalid', () => {
|
||||
const parts = parseTokenParts('abc {{}} def', ['a']);
|
||||
expect(parts).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'abc {{}} def'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should split token display and hide braces text', () => {
|
||||
const result = splitTokenDisplay('{{ user.name }}', 'user.name');
|
||||
expect(result).toEqual({
|
||||
hiddenPrefix: '{{ ',
|
||||
visibleText: 'user.name',
|
||||
hiddenSuffix: ' }}'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find full token range for backspace delete', () => {
|
||||
const content = 'hello {{input}} world';
|
||||
const tokenEndCursor = 'hello {{input}}'.length;
|
||||
const range = findBackspaceTokenRange(content, tokenEndCursor);
|
||||
|
||||
expect(range).toEqual({
|
||||
start: 6,
|
||||
end: 15,
|
||||
text: '{{input}}',
|
||||
key: 'input'
|
||||
});
|
||||
});
|
||||
|
||||
it('should support boundary match for arrow skip behavior', () => {
|
||||
const content = 'x{{docs}}y';
|
||||
const tokenStart = 1;
|
||||
const tokenEnd = 9;
|
||||
|
||||
const rightBoundary = findTokenRangeAtCursor(content, tokenStart, {
|
||||
includeStart: true,
|
||||
includeEnd: false
|
||||
});
|
||||
const leftBoundary = findTokenRangeAtCursor(content, tokenEnd, {
|
||||
includeStart: false,
|
||||
includeEnd: true
|
||||
});
|
||||
|
||||
expect(rightBoundary?.key).toBe('docs');
|
||||
expect(leftBoundary?.key).toBe('docs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
export interface ParameterLike {
|
||||
name?: string;
|
||||
ref?: string;
|
||||
refType?: string;
|
||||
children?: ParameterLike[];
|
||||
}
|
||||
|
||||
export interface TokenRange {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type TokenPart =
|
||||
| {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'token';
|
||||
text: string;
|
||||
key: string;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export interface ParameterCandidate {
|
||||
name: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||
|
||||
export function normalizeTokenKey(tokenKey: string): string {
|
||||
return tokenKey.trim();
|
||||
}
|
||||
|
||||
export function flattenParameterNames(parameters?: ParameterLike[] | null): string[] {
|
||||
return flattenParameterCandidates(parameters).map((item) => item.name);
|
||||
}
|
||||
|
||||
function isParameterResolved(parameter?: ParameterLike): boolean {
|
||||
if (!parameter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const refType = (parameter.refType || '').trim();
|
||||
if (refType === 'fixed' || refType === 'input') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ref = (parameter.ref || '').trim();
|
||||
return !!ref;
|
||||
}
|
||||
|
||||
export function flattenParameterCandidates(parameters?: ParameterLike[] | null): ParameterCandidate[] {
|
||||
if (!parameters || parameters.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: ParameterCandidate[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
const addCandidate = (name: string, resolved: boolean) => {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const exists = indexMap.get(normalized);
|
||||
if (exists === undefined) {
|
||||
indexMap.set(normalized, candidates.length);
|
||||
candidates.push({
|
||||
name: normalized,
|
||||
resolved
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 同名参数只要有一个可解析,就视为可解析
|
||||
if (resolved) {
|
||||
candidates[exists].resolved = true;
|
||||
}
|
||||
};
|
||||
|
||||
const walk = (items: ParameterLike[], parentPath = '', inheritedResolved = true) => {
|
||||
for (const item of items) {
|
||||
const rawName = item?.name?.trim();
|
||||
if (!rawName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPath = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const currentResolved = inheritedResolved && isParameterResolved(item);
|
||||
addCandidate(currentPath, currentResolved);
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
walk(item.children, currentPath, currentResolved);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(parameters);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function parseTokenParts(content: string, validParams: string[] = []): TokenPart[] {
|
||||
const source = content ?? '';
|
||||
const validSet = new Set(validParams.map(normalizeTokenKey));
|
||||
const parts: TokenPart[] = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
TOKEN_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
|
||||
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex, match.index)
|
||||
});
|
||||
}
|
||||
|
||||
const rawToken = match[0];
|
||||
const tokenKey = normalizeTokenKey(match[1] || '');
|
||||
parts.push({
|
||||
type: 'token',
|
||||
text: rawToken,
|
||||
key: tokenKey,
|
||||
valid: validSet.has(tokenKey)
|
||||
});
|
||||
|
||||
lastIndex = match.index + rawToken.length;
|
||||
match = TOKEN_PATTERN.exec(source);
|
||||
}
|
||||
|
||||
if (lastIndex < source.length) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex)
|
||||
});
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: source
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function getTokenRanges(content: string): TokenRange[] {
|
||||
const source = content ?? '';
|
||||
const ranges: TokenRange[] = [];
|
||||
TOKEN_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
|
||||
|
||||
while (match) {
|
||||
const rawToken = match[0];
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + rawToken.length,
|
||||
text: rawToken,
|
||||
key: normalizeTokenKey(match[1] || '')
|
||||
});
|
||||
match = TOKEN_PATTERN.exec(source);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function findTokenRangeAtCursor(
|
||||
content: string,
|
||||
cursor: number,
|
||||
options?: {
|
||||
includeStart?: boolean;
|
||||
includeEnd?: boolean;
|
||||
}
|
||||
): TokenRange | null {
|
||||
if (!Number.isInteger(cursor) || cursor < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const includeStart = options?.includeStart ?? false;
|
||||
const includeEnd = options?.includeEnd ?? false;
|
||||
const ranges = getTokenRanges(content);
|
||||
for (const range of ranges) {
|
||||
const leftValid = includeStart ? cursor >= range.start : cursor > range.start;
|
||||
const rightValid = includeEnd ? cursor <= range.end : cursor < range.end;
|
||||
if (leftValid && rightValid) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findBackspaceTokenRange(content: string, cursor: number): TokenRange | null {
|
||||
return findTokenRangeAtCursor(content, cursor, {
|
||||
includeStart: false,
|
||||
includeEnd: true
|
||||
});
|
||||
}
|
||||
|
||||
export function splitTokenDisplay(rawToken: string, normalizedKey?: string): {
|
||||
hiddenPrefix: string;
|
||||
visibleText: string;
|
||||
hiddenSuffix: string;
|
||||
} {
|
||||
const source = rawToken ?? '';
|
||||
if (!source.startsWith('{{') || !source.endsWith('}}')) {
|
||||
return {
|
||||
hiddenPrefix: '',
|
||||
visibleText: normalizedKey || source,
|
||||
hiddenSuffix: ''
|
||||
};
|
||||
}
|
||||
|
||||
const inner = source.slice(2, -2);
|
||||
const visibleText = normalizeTokenKey(normalizedKey || inner);
|
||||
if (!visibleText) {
|
||||
return {
|
||||
hiddenPrefix: '',
|
||||
visibleText: source,
|
||||
hiddenSuffix: ''
|
||||
};
|
||||
}
|
||||
|
||||
const innerStart = inner.indexOf(visibleText);
|
||||
const leading = innerStart >= 0 ? inner.slice(0, innerStart) : '';
|
||||
const trailing = innerStart >= 0 ? inner.slice(innerStart + visibleText.length) : '';
|
||||
|
||||
return {
|
||||
hiddenPrefix: `{{${leading}`,
|
||||
visibleText,
|
||||
hiddenSuffix: `${trailing}}}`
|
||||
};
|
||||
}
|
||||
|
||||
export function insertTextAtCursor(
|
||||
content: string,
|
||||
insertedText: string,
|
||||
selectionStart?: number | null,
|
||||
selectionEnd?: number | null
|
||||
): {
|
||||
value: string;
|
||||
cursor: number;
|
||||
} {
|
||||
const source = content ?? '';
|
||||
const start = Number.isInteger(selectionStart)
|
||||
? Math.max(0, Math.min(selectionStart as number, source.length))
|
||||
: source.length;
|
||||
const end = Number.isInteger(selectionEnd)
|
||||
? Math.max(start, Math.min(selectionEnd as number, source.length))
|
||||
: start;
|
||||
|
||||
const nextValue = source.slice(0, start) + insertedText + source.slice(end);
|
||||
return {
|
||||
value: nextValue,
|
||||
cursor: start + insertedText.length
|
||||
};
|
||||
}
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export type CustomNodeForm = {
|
||||
defaultValue?: string | number | boolean;
|
||||
attrs?: Record<string, any>;
|
||||
options?: SelectItem[];
|
||||
templateSupport?: boolean;
|
||||
chosen?: {
|
||||
labelDataKey: string;
|
||||
valueDataKey: string;
|
||||
|
||||
Reference in New Issue
Block a user