Skip to content

Commit c0836f1

Browse files
committed
feat: formula evaluator v1.1
1 parent 6eaa7c7 commit c0836f1

40 files changed

+1305
-135
lines changed

frontend/packages/web/src/components/business/crm-formula-editor/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ export const allFunctionSource: (FormCreateField & { isFunction: boolean })[] =
132132
},
133133
];
134134

135+
// 允许空值的函数
136+
export const AllowEmptyArgsFunctionList = ['NOW', 'TODAY'];
137+
135138
export const FormulaErrorCode = {
136139
EMPTY_ARGS: 'EMPTY_ARGS', // 参数个数为空
137140
ARG_COUNT_ERROR: 'ARG_COUNT_ERROR', // 参数个数错误

frontend/packages/web/src/components/business/crm-formula-editor/diagnose/diagnoseArgs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useI18n } from '@lib/shared/hooks/useI18n';
22

3-
import { FormulaErrorCode } from '../config';
3+
import { AllowEmptyArgsFunctionList, FormulaErrorCode } from '../config';
44
import { ASTNode, FormulaDiagnostic, FunctionNode, Token } from '../types';
55

66
const { t } = useI18n();
@@ -22,7 +22,7 @@ export default function diagnoseArgs(fnNode: FunctionNode, args: ASTNode[], toke
2222
// 所有逗号 token
2323
const commaTokens = innerTokens.filter((item) => item.type === 'comma');
2424

25-
if (args?.length === 0) {
25+
if (args?.length === 0 && !AllowEmptyArgsFunctionList.includes(fnNode.name)) {
2626
diagnostics.push({
2727
type: 'warning',
2828
functionName: fnNode.name,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useI18n } from '@lib/shared/hooks/useI18n';
2+
3+
import { FormulaErrorCode } from '../../config';
4+
import { FormulaDiagnostic, FormulaFunctionRule } from '../../types';
5+
import { isLogicalNode } from './rule-utils';
6+
7+
const { t } = useI18n();
8+
9+
export const AND_RULE: FormulaFunctionRule = {
10+
name: 'AND',
11+
12+
diagnose({ fnNode, args }) {
13+
const diagnostics: FormulaDiagnostic[] = [];
14+
15+
// 1. 至少一个参数
16+
if (args.length < 1) {
17+
diagnostics.push({
18+
type: 'error',
19+
code: FormulaErrorCode.ARG_COUNT_ERROR,
20+
functionName: fnNode.name,
21+
message: t('formulaEditor.diagnostics.argCountError'),
22+
highlight: {
23+
tokenRange: [fnNode.startTokenIndex, fnNode.endTokenIndex],
24+
},
25+
});
26+
return diagnostics;
27+
}
28+
29+
// 2. 所有参数必须为逻辑值
30+
args.forEach((arg, index) => {
31+
if (!isLogicalNode(arg)) {
32+
diagnostics.push({
33+
type: 'error',
34+
code: FormulaErrorCode.INVALID_FUNCTION_CALL,
35+
functionName: fnNode.name,
36+
message: t('formulaEditor.diagnostics.invalidConditionTypeOfAND', {
37+
index: index + 1,
38+
}),
39+
highlight: {
40+
tokenRange: [arg.startTokenIndex, arg.endTokenIndex],
41+
},
42+
});
43+
}
44+
});
45+
46+
return diagnostics;
47+
},
48+
};
49+
50+
export default AND_RULE;

frontend/packages/web/src/components/business/crm-formula-editor/diagnose/rules/concatenate.rule.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,48 @@
1+
import { useI18n } from '@lib/shared/hooks/useI18n';
2+
3+
import { FormulaErrorCode } from '../../config';
14
import { FormulaDiagnostic, FormulaFunctionRule } from '../../types';
5+
import { isTextNumberDateNode } from './rule-utils';
6+
7+
const { t } = useI18n();
28

3-
const CONCATENATE_RULE: FormulaFunctionRule = {
9+
export const CONCATENATE_RULE: FormulaFunctionRule = {
410
name: 'CONCATENATE',
511

612
diagnose({ fnNode, args }) {
713
const diagnostics: FormulaDiagnostic[] = [];
8-
// 后边CONCATENATE的其他规则可扩展在这里
14+
15+
// 1. 至少一个参数
16+
if (args.length < 1) {
17+
diagnostics.push({
18+
type: 'error',
19+
code: FormulaErrorCode.ARG_COUNT_ERROR,
20+
functionName: fnNode.name,
21+
message: t('formulaEditor.diagnostics.argCountError'),
22+
highlight: {
23+
tokenRange: [fnNode.startTokenIndex, fnNode.endTokenIndex],
24+
},
25+
});
26+
return diagnostics;
27+
}
28+
29+
// 2. 参数类型限制:文本、数字、日期
30+
args.forEach((arg, index) => {
31+
if (!isTextNumberDateNode(arg)) {
32+
diagnostics.push({
33+
type: 'error',
34+
code: FormulaErrorCode.INVALID_FUNCTION_CALL,
35+
functionName: fnNode.name,
36+
message: t('formulaEditor.diagnostics.invalidArgTypeOfCONCATENATE', {
37+
index: index + 1,
38+
}),
39+
highlight: {
40+
tokenRange: [arg.startTokenIndex, arg.endTokenIndex],
41+
},
42+
});
43+
}
44+
});
45+
946
return diagnostics;
1047
},
1148
};

frontend/packages/web/src/components/business/crm-formula-editor/diagnose/rules/days.rule.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@ import { useI18n } from '@lib/shared/hooks/useI18n';
22

33
import { FormulaErrorCode } from '../../config';
44
import { ASTNode, FormulaDiagnostic, FormulaFunctionRule } from '../../types';
5+
import { isColumnField } from './rule-utils';
56

67
const { t } = useI18n();
78

8-
function isColumnField(fieldId: string) {
9-
return fieldId.includes('.');
10-
}
11-
129
export const DAYS_RULE: FormulaFunctionRule = {
1310
name: 'DAYS',
1411

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useI18n } from '@lib/shared/hooks/useI18n';
2+
3+
import { FormulaErrorCode } from '../../config';
4+
import { FormulaDiagnostic, FormulaFunctionRule } from '../../types';
5+
6+
const { t } = useI18n();
7+
8+
export const IF_RULE: FormulaFunctionRule = {
9+
name: 'IF',
10+
11+
diagnose({ fnNode, args }) {
12+
const diagnostics: FormulaDiagnostic[] = [];
13+
14+
if (args.length < 2 || args.length > 3) {
15+
diagnostics.push({
16+
type: 'error',
17+
code: FormulaErrorCode.ARG_COUNT_ERROR,
18+
functionName: fnNode.name,
19+
message: t('formulaEditor.diagnostics.argCountErrorOfIF'),
20+
highlight: {
21+
tokenRange: [fnNode.startTokenIndex, fnNode.endTokenIndex],
22+
},
23+
});
24+
}
25+
26+
return diagnostics;
27+
},
28+
};
29+
30+
export default IF_RULE;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useI18n } from '@lib/shared/hooks/useI18n';
2+
3+
import { FormulaErrorCode } from '../../config';
4+
import { FormulaDiagnostic, FormulaFunctionRule } from '../../types';
5+
import { isLogicalNode, isTextNumberDateNode } from './rule-utils';
6+
7+
const { t } = useI18n();
8+
9+
export const IFS_RULE: FormulaFunctionRule = {
10+
name: 'IFS',
11+
12+
diagnose({ fnNode, args }) {
13+
const diagnostics: FormulaDiagnostic[] = [];
14+
15+
// 1. 参数个数必须为偶数,且至少 2 个
16+
if (args.length < 2 || args.length % 2 !== 0) {
17+
diagnostics.push({
18+
type: 'error',
19+
code: FormulaErrorCode.ARG_COUNT_ERROR,
20+
functionName: fnNode.name,
21+
message: t('formulaEditor.diagnostics.argCountErrorOfIFS'),
22+
highlight: {
23+
tokenRange: [fnNode.startTokenIndex, fnNode.endTokenIndex],
24+
},
25+
});
26+
return diagnostics;
27+
}
28+
29+
// 2. 条件 - 结果 成对校验
30+
args.forEach((arg, index) => {
31+
const isCondition = index % 2 === 0;
32+
33+
if (isCondition) {
34+
if (!isLogicalNode(arg)) {
35+
diagnostics.push({
36+
type: 'error',
37+
code: FormulaErrorCode.INVALID_FUNCTION_CALL,
38+
functionName: fnNode.name,
39+
message: t('formulaEditor.diagnostics.invalidConditionTypeOfIFS', {
40+
index: index + 1,
41+
}),
42+
highlight: {
43+
tokenRange: [arg.startTokenIndex, arg.endTokenIndex],
44+
},
45+
});
46+
}
47+
} else if (!isTextNumberDateNode(arg)) {
48+
diagnostics.push({
49+
type: 'error',
50+
code: FormulaErrorCode.INVALID_FUNCTION_CALL,
51+
functionName: fnNode.name,
52+
message: t('formulaEditor.diagnostics.invalidResultTypeOfIFS', {
53+
index: index + 1,
54+
}),
55+
highlight: {
56+
tokenRange: [arg.startTokenIndex, arg.endTokenIndex],
57+
},
58+
});
59+
}
60+
});
61+
62+
return diagnostics;
63+
},
64+
};
65+
66+
export default IFS_RULE;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
// index.ts 诊断函数配置总入口
22
import { FormulaFunctionRule } from '../../types';
3+
import AND from './and.rule';
34
import CONCATENATE from './concatenate.rule';
45
import DAYS from './days.rule';
6+
import IF from './if.rule';
7+
import IFS from './ifs.rule';
8+
import NOW from './now.rule';
59
import SUM from './sum.rule';
10+
import TEXT from './text.rule';
11+
import TODAY from './today.rule';
612

713
const FUNCTION_RULES: Record<string, FormulaFunctionRule> = {
814
SUM,
915
DAYS,
1016
CONCATENATE,
17+
AND,
18+
IF,
19+
IFS,
20+
NOW,
21+
TEXT,
22+
TODAY,
1123
};
1224

1325
export default FUNCTION_RULES;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useI18n } from '@lib/shared/hooks/useI18n';
2+
3+
import { FormulaErrorCode } from '../../config';
4+
import { FormulaDiagnostic, FormulaFunctionRule } from '../../types';
5+
6+
const { t } = useI18n();
7+
8+
export const NOW_RULE: FormulaFunctionRule = {
9+
name: 'NOW',
10+
11+
diagnose({ fnNode, args }) {
12+
const diagnostics: FormulaDiagnostic[] = [];
13+
14+
if (args?.length !== 0) {
15+
diagnostics.push({
16+
type: 'error',
17+
code: FormulaErrorCode.ARG_COUNT_ERROR,
18+
functionName: fnNode.name,
19+
message: t('formulaEditor.diagnostics.nowNotAcceptParams', {
20+
name: fnNode.name,
21+
}),
22+
highlight: {
23+
tokenRange: [fnNode.startTokenIndex, fnNode.endTokenIndex],
24+
},
25+
});
26+
}
27+
28+
return diagnostics;
29+
},
30+
};
31+
32+
export default NOW_RULE;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { FieldTypeEnum } from '@lib/shared/enums/formDesignEnum';
2+
3+
import { ASTNode } from '../../types';
4+
5+
export function isColumnField(fieldId: string) {
6+
return fieldId.includes('.');
7+
}
8+
9+
export function isLogicalNode(node: ASTNode): boolean {
10+
if (!node) return false;
11+
12+
if (node.type === 'compare') return true;
13+
14+
if (node.type === 'literal' && node.valueType === 'boolean') return true;
15+
16+
if (node.type === 'function') {
17+
return ['AND'].includes(node.name);
18+
}
19+
20+
return false;
21+
}
22+
23+
export function isTextNumberDateNode(node: ASTNode): boolean {
24+
if (!node) return false;
25+
26+
if (node.type === 'literal') {
27+
return ['string', 'number'].includes(node.valueType);
28+
}
29+
30+
if (node.type === 'field') {
31+
if (node.fieldType === FieldTypeEnum.DATE_TIME) return true;
32+
if (node.fieldType === FieldTypeEnum.INPUT_NUMBER) return true;
33+
34+
// 其他字段类型按“文本”处理
35+
return true;
36+
}
37+
38+
if (node.type === 'function') {
39+
// 当前版本这些函数返回文本 / 数字 / 日期
40+
return ['TEXT', 'TODAY', 'NOW', 'SUM', 'DAYS', 'CONCATENATE'].includes(node.name);
41+
}
42+
43+
return false;
44+
}
45+
46+
export function isTextNumberDateOrLogicalNode(node: ASTNode): boolean {
47+
return isTextNumberDateNode(node) || isLogicalNode(node);
48+
}

0 commit comments

Comments
 (0)