feat: 增加条件节点正则匹配
- 使用 RE2/J 完成安全正则执行和分层校验 - 增加全宽多行输入、说明提示和专项测试
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<svelte:options customElement={{ props: {} }} />
|
||||
|
||||
<script lang="ts">
|
||||
import {onDestroy} from 'svelte';
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {
|
||||
type Edge,
|
||||
@@ -10,9 +11,13 @@
|
||||
useSvelteFlow,
|
||||
useUpdateNodeInternals
|
||||
} from '@xyflow/svelte';
|
||||
import {Button, Input, MixedInput, Select} from '../base';
|
||||
import {Button, Input, MixedInput, Select, Textarea} from '../base';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {
|
||||
CONDITION_REGEX_MAX_LENGTH,
|
||||
getConditionRegexError
|
||||
} from '#components/utils/conditionRegex';
|
||||
import {genShortId} from '../utils/IdGen';
|
||||
import {deepEqual} from '../utils/deepEqual';
|
||||
import {useRefOptions} from '#components/utils/useRefOptions.svelte';
|
||||
@@ -32,7 +37,8 @@
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'contains'
|
||||
| 'notContains';
|
||||
| 'notContains'
|
||||
| 'regexMatch';
|
||||
|
||||
type RuleJoiner = 'AND' | 'OR';
|
||||
type BranchMode = 'visual' | 'expression';
|
||||
@@ -72,8 +78,11 @@
|
||||
{ value: 'isEmpty', label: '为空' },
|
||||
{ value: 'isNotEmpty', label: '不为空' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'notContains', label: '不包含' }
|
||||
{ value: 'notContains', label: '不包含' },
|
||||
{ value: 'regexMatch', label: '匹配正则' }
|
||||
] as const;
|
||||
const REGEX_HELP =
|
||||
'正则默认匹配文本中的任意位置;完整匹配请使用 ^ 和 $。支持常用正则语法,不支持环视和反向引用。';
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -210,6 +219,8 @@
|
||||
return !['isEmpty', 'isNotEmpty'].includes(operator);
|
||||
};
|
||||
|
||||
const isRegexOperator = (operator: ConditionOperator) => operator === 'regexMatch';
|
||||
|
||||
const branchKeyword = (branch: ConditionBranch, index: number, defaultId: string) => {
|
||||
if (branch.id === defaultId) {
|
||||
return 'Else';
|
||||
@@ -435,10 +446,19 @@
|
||||
[key]: value
|
||||
};
|
||||
|
||||
if (key === 'operator' && !isOperatorNeedRight(value as ConditionOperator)) {
|
||||
nextRule.rightType = 'fixed';
|
||||
nextRule.rightRef = '';
|
||||
nextRule.rightValue = '';
|
||||
if (key === 'operator') {
|
||||
const nextOperator = value as ConditionOperator;
|
||||
if (isRegexOperator(nextOperator)) {
|
||||
nextRule.rightType = 'fixed';
|
||||
nextRule.rightRef = '';
|
||||
if (rule.rightType === 'ref') {
|
||||
nextRule.rightValue = '';
|
||||
}
|
||||
} else if (!isOperatorNeedRight(nextOperator)) {
|
||||
nextRule.rightType = 'fixed';
|
||||
nextRule.rightRef = '';
|
||||
nextRule.rightValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'rightValue') {
|
||||
@@ -536,6 +556,22 @@
|
||||
return branches.filter((branch) => branch.id !== defaultBranchId).length;
|
||||
});
|
||||
|
||||
let nodeInternalsFrame: number | undefined;
|
||||
const scheduleNodeInternalsUpdate = () => {
|
||||
if (nodeInternalsFrame !== undefined) {
|
||||
cancelAnimationFrame(nodeInternalsFrame);
|
||||
}
|
||||
nodeInternalsFrame = requestAnimationFrame(() => {
|
||||
nodeInternalsFrame = undefined;
|
||||
updateNodeInternals(currentNodeId);
|
||||
});
|
||||
};
|
||||
onDestroy(() => {
|
||||
if (nodeInternalsFrame !== undefined) {
|
||||
cancelAnimationFrame(nodeInternalsFrame);
|
||||
}
|
||||
});
|
||||
|
||||
const missingBranchLabels = $derived.by(() => {
|
||||
const edges = store.getEdges();
|
||||
const connected = new Set<string>();
|
||||
@@ -622,7 +658,21 @@
|
||||
|
||||
<div class="condition-node-content">
|
||||
<div class="condition-rail-head">
|
||||
<div class="condition-rail-title">分支出口</div>
|
||||
<div class="condition-rail-title-group">
|
||||
<div class="condition-rail-title">分支出口</div>
|
||||
<button
|
||||
type="button"
|
||||
class="condition-regex-help nodrag"
|
||||
data-help={REGEX_HELP}
|
||||
aria-label="正则匹配说明"
|
||||
aria-describedby={`condition-regex-help-${currentNodeId}`}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
<span id={`condition-regex-help-${currentNodeId}`} class="condition-sr-only">
|
||||
{REGEX_HELP}
|
||||
</span>
|
||||
</div>
|
||||
<Button class="condition-icon-btn" title="新增分支" onclick={addBranch}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
@@ -740,7 +790,14 @@
|
||||
>OR</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="condition-simple-rule-row">
|
||||
{@const regexError = isRegexOperator(rule.operator)
|
||||
? getConditionRegexError(rule.rightValue)
|
||||
: ''}
|
||||
{@const regexErrorId = `condition-regex-error-${currentNodeId}-${rule.id}`}
|
||||
<div
|
||||
class:condition-regex-rule={isRegexOperator(rule.operator)}
|
||||
class="condition-simple-rule-row"
|
||||
>
|
||||
<Select
|
||||
items={refOptions.current}
|
||||
variant="reference"
|
||||
@@ -755,7 +812,53 @@
|
||||
value={[rule.operator]}
|
||||
onSelect={(item) => updateRuleField(activeBranch.id, rule.id, 'operator', item.value)}
|
||||
/>
|
||||
{#if isOperatorNeedRight(rule.operator)}
|
||||
{#if isRegexOperator(rule.operator)}
|
||||
<div class="condition-regex-field">
|
||||
<Textarea
|
||||
value={rule.rightValue}
|
||||
rows={3}
|
||||
maxHeight={112}
|
||||
maxlength={CONDITION_REGEX_MAX_LENGTH}
|
||||
placeholder="输入正则表达式"
|
||||
aria-label="正则表达式"
|
||||
aria-invalid={regexError ? 'true' : undefined}
|
||||
aria-describedby={regexErrorId}
|
||||
class="condition-regex-textarea"
|
||||
onHeightChange={scheduleNodeInternalsUpdate}
|
||||
oninput={(event: Event) => updateRuleField(
|
||||
activeBranch.id,
|
||||
rule.id,
|
||||
'rightValue',
|
||||
(event.target as HTMLTextAreaElement).value
|
||||
)}
|
||||
onchange={(event: Event) => updateRuleField(
|
||||
activeBranch.id,
|
||||
rule.id,
|
||||
'rightValue',
|
||||
(event.target as HTMLTextAreaElement).value
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
id={regexErrorId}
|
||||
class:visible={Boolean(regexError)}
|
||||
class="condition-regex-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
{regexError}
|
||||
</div>
|
||||
{#if activeBranch.rules!.length > 1}
|
||||
<Button
|
||||
class="condition-rule-remove-btn"
|
||||
title="删除条件"
|
||||
onclick={() => updateBranchField(activeBranch.id, 'removeRule', rule.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.47 2 2 6.47 2 12C2 17.53 6.47 22 12 22C17.53 22 22 17.53 22 12C22 6.47 17.53 2 12 2ZM17 13H7V11H17V13Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isOperatorNeedRight(rule.operator)}
|
||||
<div class="condition-right-unified-box">
|
||||
<MixedInput
|
||||
type={rule.rightType}
|
||||
@@ -846,6 +949,81 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.condition-rail-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.condition-regex-help {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--tf-border-color-strong);
|
||||
border-radius: 50%;
|
||||
background: var(--tf-bg-surface);
|
||||
color: var(--tf-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.condition-regex-help:hover,
|
||||
.condition-regex-help:focus-visible {
|
||||
border-color: var(--tf-primary-color);
|
||||
color: var(--tf-primary-color);
|
||||
background: var(--tf-primary-soft-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.condition-regex-help:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--tf-primary-soft-border);
|
||||
}
|
||||
|
||||
.condition-regex-help::after {
|
||||
content: attr(data-help);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: 300px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--tf-tip-bg);
|
||||
color: var(--tf-tip-text);
|
||||
box-shadow: var(--tf-shadow-medium);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.condition-regex-help:hover::after,
|
||||
.condition-regex-help:focus-visible::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.condition-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.condition-branch-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1110,13 +1288,18 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.condition-simple-rule-row.condition-regex-rule {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.condition-simple-rule-row > * {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.condition-simple-rule-row .tf-select),
|
||||
:global(.condition-simple-rule-row .tf-mixed-input-root) {
|
||||
:global(.condition-simple-rule-row .tf-mixed-input-root),
|
||||
:global(.condition-simple-rule-row .tf-textarea) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1131,10 +1314,45 @@
|
||||
}
|
||||
|
||||
:global(.condition-simple-rule-row .tf-select-input:hover),
|
||||
:global(.condition-simple-rule-row .tf-mixed-input-root:hover) {
|
||||
:global(.condition-simple-rule-row .tf-mixed-input-root:hover),
|
||||
:global(.condition-simple-rule-row .tf-textarea:hover) {
|
||||
border-color: var(--tf-border-color-strong);
|
||||
}
|
||||
|
||||
.condition-regex-field {
|
||||
position: relative;
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.condition-regex-textarea) {
|
||||
min-height: 72px;
|
||||
max-height: 112px;
|
||||
resize: none;
|
||||
overflow-x: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.condition-regex-textarea[aria-invalid='true']) {
|
||||
border-color: var(--tf-danger-color);
|
||||
box-shadow: 0 0 0 1px var(--tf-danger-soft-border);
|
||||
}
|
||||
|
||||
.condition-regex-error {
|
||||
min-height: 18px;
|
||||
padding-top: 4px;
|
||||
color: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.condition-regex-error.visible {
|
||||
color: var(--tf-danger-soft-text);
|
||||
}
|
||||
|
||||
:global(.condition-icon-btn) {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -1208,6 +1426,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.condition-regex-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.condition-editor-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CONDITION_REGEX_MAX_LENGTH,
|
||||
getConditionRegexError,
|
||||
} from './conditionRegex';
|
||||
|
||||
describe('condition regex validation', () => {
|
||||
it('接受常用语法、锚点和安全内联标志', () => {
|
||||
expect(getConditionRegexError('(?i)^[a-z]+-\\d+$')).toBe('');
|
||||
});
|
||||
|
||||
it('拒绝空值和超长表达式', () => {
|
||||
expect(getConditionRegexError(' ')).toBe('请输入正则表达式');
|
||||
expect(
|
||||
getConditionRegexError('a'.repeat(CONDITION_REGEX_MAX_LENGTH + 1)),
|
||||
).toContain(String(CONDITION_REGEX_MAX_LENGTH));
|
||||
});
|
||||
|
||||
it('不使用浏览器正则语法拦截服务端负责的语法校验', () => {
|
||||
expect(getConditionRegexError('VIP(?=用户)')).toBe('');
|
||||
expect(getConditionRegexError('(VIP)-\\1')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export const CONDITION_REGEX_MAX_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* 返回条件节点正则输入可即时确认的错误。
|
||||
*
|
||||
* 完整语法由后端 RE2/J 校验,避免浏览器正则语法差异误拦截有效配置。
|
||||
*/
|
||||
export const getConditionRegexError = (value: unknown) => {
|
||||
const regex = String(value ?? '');
|
||||
if (!regex.trim()) {
|
||||
return '请输入正则表达式';
|
||||
}
|
||||
if (regex.length > CONDITION_REGEX_MAX_LENGTH) {
|
||||
return `正则表达式不能超过 ${CONDITION_REGEX_MAX_LENGTH} 个字符`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
Reference in New Issue
Block a user