feat(plan): 添加字段条件显示系统扩展功能
- 新增条件规则引擎 (plan-conditions.js) - 支持 eq/neq/gt/gte/lt/lte/in/not_in 等操作符 - 支持 AND/OR 逻辑组合 - 添加完整的单元测试 (plan-conditions.test.js) - 添加功能扩展计划文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Showing
3 changed files
with
1191 additions
and
0 deletions
docs/tasks/plan/字段条件显示系统扩展.md
0 → 100644
| 1 | +# 字段条件显示系统扩展计划 | ||
| 2 | + | ||
| 3 | +## 背景与目标 | ||
| 4 | + | ||
| 5 | +### 当前问题 | ||
| 6 | +- `show_when` 只支持简单的等于比较 (`equals`) | ||
| 7 | +- 不支持 OR 条件、嵌套条件 | ||
| 8 | +- 不支持不等于、大于、小于等操作符 | ||
| 9 | +- 隐藏字段的清理逻辑分散在 `reset_map` 中 | ||
| 10 | +- 提交时可能包含隐藏字段的脏数据 | ||
| 11 | + | ||
| 12 | +### 目标 | ||
| 13 | +构建一个声明式的条件规则系统,支持复杂逻辑同时保持配置可读性。 | ||
| 14 | + | ||
| 15 | +## 技术方案:方案 B - 条件规则引擎 | ||
| 16 | + | ||
| 17 | +### 核心文件变更 | ||
| 18 | + | ||
| 19 | +| 文件 | 操作 | 说明 | | ||
| 20 | +|------|------|------| | ||
| 21 | +| `src/config/plan-conditions.js` | 新建 | 条件操作符和评估引擎 | | ||
| 22 | +| `src/composables/useFieldDependencies.js` | 修改 | 集成新的条件评估器 | | ||
| 23 | +| `src/composables/usePlanSubmit.js` | 新建 | 提交时字段过滤逻辑 | | ||
| 24 | +| `src/config/plan-templates.js` | 修改 | 迁移现有配置到新格式 | | ||
| 25 | +| `src/composables/__tests__/plan-conditions.test.js` | 新建 | 条件引擎单元测试 | | ||
| 26 | + | ||
| 27 | +--- | ||
| 28 | + | ||
| 29 | +## 阶段 1:核心条件引擎 | ||
| 30 | + | ||
| 31 | +### 任务 1.1 创建条件操作符定义 | ||
| 32 | +- [x] 创建 `src/config/plan-conditions.js` | ||
| 33 | +- [x] 定义比较操作符:`eq`, `ne`, `gt`, `gte`, `lt`, `lte` | ||
| 34 | +- [x] 定义集合操作符:`in`, `nin` | ||
| 35 | +- [x] 定义字符串操作符:`contains`, `startsWith`, `matches` | ||
| 36 | +- [x] 定义布尔操作符:`truthy`, `falsy`, `empty`, `notEmpty` | ||
| 37 | +- [x] 编写单元测试验证操作符正确性 | ||
| 38 | + | ||
| 39 | +### 任务 1.2 实现条件评估函数 | ||
| 40 | +- [x] 实现 `evaluateCondition(condition, formData)` 函数 | ||
| 41 | +- [x] 支持简单条件:`{ field, op, value }` | ||
| 42 | +- [x] 支持 AND 逻辑:`{ and: [...] }` | ||
| 43 | +- [x] 支持 OR 逻辑:`{ or: [...] }` | ||
| 44 | +- [x] 支持 NOT 逻辑:`{ not: {...} }` | ||
| 45 | +- [x] 支持嵌套条件组合 | ||
| 46 | +- [x] 编写单元测试覆盖各种条件场景 | ||
| 47 | + | ||
| 48 | +### 任务 1.3 向后兼容处理 | ||
| 49 | +- [x] 支持旧格式 `show_when: { field: 'x', equals: 'y' }` | ||
| 50 | +- [x] 支持旧格式数组 `show_when: [{ field: 'x', equals: 'y' }]` | ||
| 51 | +- [x] 自动转换为新格式 | ||
| 52 | + | ||
| 53 | +--- | ||
| 54 | + | ||
| 55 | +## 阶段 2:清理机制 | ||
| 56 | + | ||
| 57 | +### 任务 2.1 字段清理规则 | ||
| 58 | +- [x] 在字段定义中添加 `clear_when_hidden` 属性 | ||
| 59 | +- [x] 实现 `clear_when_hidden: true` 自动清空 | ||
| 60 | +- [x] 实现 `clear_when_hidden: false` 保留值 | ||
| 61 | +- [x] 实现级联清理 `clear_when_hidden: { clear_dependents: [...] }` | ||
| 62 | + | ||
| 63 | +### 任务 2.2 提交时字段过滤 | ||
| 64 | +- [x] 实现 `filterHiddenFields(formData, visibleFields)` 函数 | ||
| 65 | +- [x] 只提交当前可见的字段 | ||
| 66 | +- [x] 保持 API 兼容性 | ||
| 67 | + | ||
| 68 | +### 任务 2.3 更新 useFieldDependencies | ||
| 69 | +- [x] 集成新的条件评估引擎 | ||
| 70 | +- [x] 实现字段隐藏时的自动清理 | ||
| 71 | +- [x] 更新 `isFieldVisible` 使用新引擎 | ||
| 72 | +- [x] 保持 API 兼容性 | ||
| 73 | + | ||
| 74 | +--- | ||
| 75 | + | ||
| 76 | +## 阶段 3:配置迁移 | ||
| 77 | + | ||
| 78 | +### 任务 3.1 迁移储蓄类模板 | ||
| 79 | +- [x] 迁移 `savingsFormSchema.withdrawal_fields` 到新格式 | ||
| 80 | +- [x] 删除 `reset_map`,使用 `clear_when_hidden` 替代 | ||
| 81 | +- [x] 验证功能正常(测试通过 160/160) | ||
| 82 | + | ||
| 83 | +### 任务 3.2 迁移保障类模板 | ||
| 84 | +- [x] 检查 `protectionFormSchema` 是否需要条件 | ||
| 85 | +- [x] 按需添加条件规则(保障类暂无条件需求,无需迁移) | ||
| 86 | + | ||
| 87 | +### 任务 3.3 更新文档解析工具 ⚠️ 重要 | ||
| 88 | +- [x] 更新 `src/utils/parsers/config-generator.js` 生成新格式配置 | ||
| 89 | +- [x] 修改 `show_when` 生成逻辑,使用 `{ field, op, value }` 格式 | ||
| 90 | +- [x] 停止生成 `reset_map`(已忽略) | ||
| 91 | +- [x] 测试通过(160/160) | ||
| 92 | + | ||
| 93 | +**说明**:MCP 文档解析服务保持不变,仅更新配置生成器输出格式。 | ||
| 94 | + | ||
| 95 | +### 任务 3.4 更新文档 | ||
| 96 | +- [x] 更新 `plan-templates.js` 顶部的使用说明 | ||
| 97 | +- [x] 添加条件规则配置示例(已在 plan-templates.js 注释中说明) | ||
| 98 | +- [x] 添加常见场景示例(已在 savingsFormSchema 中实现) | ||
| 99 | + | ||
| 100 | +--- | ||
| 101 | + | ||
| 102 | +## 阶段 4:测试与验证 | ||
| 103 | + | ||
| 104 | +### 任务 4.1 单元测试 | ||
| 105 | +- [ ] 条件操作符测试覆盖率 > 90% | ||
| 106 | +- [ ] 条件评估引擎测试覆盖率 > 90% | ||
| 107 | +- [ ] useFieldDependencies 测试更新 | ||
| 108 | + | ||
| 109 | +### 任务 4.2 集成测试 | ||
| 110 | +- [ ] 测试储蓄类产品完整流程 | ||
| 111 | +- [ ] 测试字段显示/隐藏切换 | ||
| 112 | +- [ ] 测试提交时字段过滤 | ||
| 113 | +- [ ] 测试向后兼容性 | ||
| 114 | + | ||
| 115 | +### 任务 4.3 真机验证 | ||
| 116 | +- [ ] 微信开发者工具验证 | ||
| 117 | +- [ ] 检查性能影响 | ||
| 118 | +- [ ] 检查内存占用 | ||
| 119 | + | ||
| 120 | +--- | ||
| 121 | + | ||
| 122 | +## 配置格式示例 | ||
| 123 | + | ||
| 124 | +### 新格式示例 | ||
| 125 | + | ||
| 126 | +```javascript | ||
| 127 | +// 简单条件 | ||
| 128 | +show_when: { field: 'smoker', op: 'eq', value: '是' } | ||
| 129 | + | ||
| 130 | +// 多条件 AND | ||
| 131 | +show_when: { | ||
| 132 | + and: [ | ||
| 133 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 134 | + { field: 'age', op: 'gte', value: 30 } | ||
| 135 | + ] | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// OR 条件 | ||
| 139 | +show_when: { | ||
| 140 | + or: [ | ||
| 141 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 142 | + { field: 'age', op: 'gt', value: 50 } | ||
| 143 | + ] | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// 嵌套条件 | ||
| 147 | +show_when: { | ||
| 148 | + and: [ | ||
| 149 | + { field: 'product_type', op: 'in', value: ['A', 'B'] }, | ||
| 150 | + { | ||
| 151 | + or: [ | ||
| 152 | + { field: 'coverage', op: 'gte', value: 100000 }, | ||
| 153 | + { field: 'payment_period', op: 'eq', value: '20年' } | ||
| 154 | + ] | ||
| 155 | + } | ||
| 156 | + ] | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +// 清理规则 | ||
| 160 | +{ | ||
| 161 | + id: 'withdrawal_amount', | ||
| 162 | + show_when: { field: 'withdrawal_mode', op: 'eq', value: '指定提取金额' }, | ||
| 163 | + clear_when_hidden: true // 隐藏时清空 | ||
| 164 | +} | ||
| 165 | +``` | ||
| 166 | + | ||
| 167 | +### 旧格式(保持兼容) | ||
| 168 | + | ||
| 169 | +```javascript | ||
| 170 | +// 旧格式仍然支持 | ||
| 171 | +show_when: { field: 'withdrawal_mode', equals: '指定提取金额' } | ||
| 172 | +show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }] | ||
| 173 | +``` | ||
| 174 | + | ||
| 175 | +--- | ||
| 176 | + | ||
| 177 | +## 风险与注意事项 | ||
| 178 | + | ||
| 179 | +1. **向后兼容**:必须保持现有配置格式可用 | ||
| 180 | +2. **性能**:条件评估不能影响表单响应速度 | ||
| 181 | +3. **可读性**:配置文件需要保持可理解性 | ||
| 182 | +4. **测试覆盖**:每个操作符都需要充分测试 | ||
| 183 | + | ||
| 184 | +--- | ||
| 185 | + | ||
| 186 | +## 进度追踪 | ||
| 187 | + | ||
| 188 | +| 阶段 | 状态 | 完成时间 | | ||
| 189 | +|------|------|---------| | ||
| 190 | +| 阶段 1:核心引擎 | ✅ 完成 | 2026-02-15 | | ||
| 191 | +| 阶段 2:清理机制 | ✅ 完成 | 2026-02-15 | | ||
| 192 | +| 阶段 3:配置迁移 | ✅ 完成 | 2026-02-15 | | ||
| 193 | +| 阶段 4:测试验证 | 🔄 待真机验证 | - | | ||
| 194 | + | ||
| 195 | +--- | ||
| 196 | + | ||
| 197 | +**创建时间**: 2026-02-15 | ||
| 198 | +**预计工期**: 3-4 天 | ||
| 199 | +**维护者**: Claude Code |
src/config/__tests__/plan-conditions.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 计划书字段条件引擎测试 | ||
| 3 | + * | ||
| 4 | + * @description 测试条件操作符和评估逻辑 | ||
| 5 | + * @module config/__tests__/plan-conditions.test | ||
| 6 | + */ | ||
| 7 | + | ||
| 8 | +import { describe, it, expect } from 'vitest' | ||
| 9 | +import { | ||
| 10 | + CONDITION_OPERATORS, | ||
| 11 | + normalizeOperator, | ||
| 12 | + evaluateSingleCondition, | ||
| 13 | + evaluateAnd, | ||
| 14 | + evaluateOr, | ||
| 15 | + evaluateNot, | ||
| 16 | + evaluateCondition, | ||
| 17 | + getConditionDependencies, | ||
| 18 | + convertToNewFormat | ||
| 19 | +} from '../plan-conditions' | ||
| 20 | + | ||
| 21 | +describe('CONDITION_OPERATORS', () => { | ||
| 22 | + describe('比较操作符', () => { | ||
| 23 | + it('eq - 等于', () => { | ||
| 24 | + expect(CONDITION_OPERATORS.eq('是', '是')).toBe(true) | ||
| 25 | + expect(CONDITION_OPERATORS.eq('是', '否')).toBe(false) | ||
| 26 | + expect(CONDITION_OPERATORS.eq(1, 1)).toBe(true) | ||
| 27 | + expect(CONDITION_OPERATORS.eq(1, '1')).toBe(false) // 严格相等 | ||
| 28 | + }) | ||
| 29 | + | ||
| 30 | + it('ne - 不等于', () => { | ||
| 31 | + expect(CONDITION_OPERATORS.ne('是', '否')).toBe(true) | ||
| 32 | + expect(CONDITION_OPERATORS.ne('是', '是')).toBe(false) | ||
| 33 | + }) | ||
| 34 | + | ||
| 35 | + it('gt - 大于', () => { | ||
| 36 | + expect(CONDITION_OPERATORS.gt(30, 18)).toBe(true) | ||
| 37 | + expect(CONDITION_OPERATORS.gt(18, 18)).toBe(false) | ||
| 38 | + expect(CONDITION_OPERATORS.gt(10, 18)).toBe(false) | ||
| 39 | + }) | ||
| 40 | + | ||
| 41 | + it('gte - 大于等于', () => { | ||
| 42 | + expect(CONDITION_OPERATORS.gte(30, 18)).toBe(true) | ||
| 43 | + expect(CONDITION_OPERATORS.gte(18, 18)).toBe(true) | ||
| 44 | + expect(CONDITION_OPERATORS.gte(10, 18)).toBe(false) | ||
| 45 | + }) | ||
| 46 | + | ||
| 47 | + it('lt - 小于', () => { | ||
| 48 | + expect(CONDITION_OPERATORS.lt(10, 18)).toBe(true) | ||
| 49 | + expect(CONDITION_OPERATORS.lt(18, 18)).toBe(false) | ||
| 50 | + expect(CONDITION_OPERATORS.lt(30, 18)).toBe(false) | ||
| 51 | + }) | ||
| 52 | + | ||
| 53 | + it('lte - 小于等于', () => { | ||
| 54 | + expect(CONDITION_OPERATORS.lte(10, 18)).toBe(true) | ||
| 55 | + expect(CONDITION_OPERATORS.lte(18, 18)).toBe(true) | ||
| 56 | + expect(CONDITION_OPERATORS.lte(30, 18)).toBe(false) | ||
| 57 | + }) | ||
| 58 | + }) | ||
| 59 | + | ||
| 60 | + describe('集合操作符', () => { | ||
| 61 | + it('in - 包含于', () => { | ||
| 62 | + expect(CONDITION_OPERATORS.in('A', ['A', 'B', 'C'])).toBe(true) | ||
| 63 | + expect(CONDITION_OPERATORS.in('D', ['A', 'B', 'C'])).toBe(false) | ||
| 64 | + expect(CONDITION_OPERATORS.in('A', 'not-array')).toBe(false) | ||
| 65 | + }) | ||
| 66 | + | ||
| 67 | + it('nin - 不包含于', () => { | ||
| 68 | + expect(CONDITION_OPERATORS.nin('D', ['A', 'B', 'C'])).toBe(true) | ||
| 69 | + expect(CONDITION_OPERATORS.nin('A', ['A', 'B', 'C'])).toBe(false) | ||
| 70 | + }) | ||
| 71 | + }) | ||
| 72 | + | ||
| 73 | + describe('字符串操作符', () => { | ||
| 74 | + it('contains - 包含子串', () => { | ||
| 75 | + expect(CONDITION_OPERATORS.contains('Hello World', 'World')).toBe(true) | ||
| 76 | + expect(CONDITION_OPERATORS.contains('Hello World', 'world')).toBe(false) // 大小写敏感 | ||
| 77 | + expect(CONDITION_OPERATORS.contains(null, 'test')).toBe(false) | ||
| 78 | + }) | ||
| 79 | + | ||
| 80 | + it('startsWith - 前缀匹配', () => { | ||
| 81 | + expect(CONDITION_OPERATORS.startsWith('Hello', 'Hel')).toBe(true) | ||
| 82 | + expect(CONDITION_OPERATORS.startsWith('Hello', 'hel')).toBe(false) | ||
| 83 | + }) | ||
| 84 | + | ||
| 85 | + it('endsWith - 后缀匹配', () => { | ||
| 86 | + expect(CONDITION_OPERATORS.endsWith('Hello', 'llo')).toBe(true) | ||
| 87 | + expect(CONDITION_OPERATORS.endsWith('Hello', 'LLO')).toBe(false) | ||
| 88 | + }) | ||
| 89 | + | ||
| 90 | + it('matches - 正则匹配', () => { | ||
| 91 | + expect(CONDITION_OPERATORS.matches('ABC123', '^[A-Z]+\\d+$')).toBe(true) | ||
| 92 | + expect(CONDITION_OPERATORS.matches('abc', '^[A-Z]+$')).toBe(false) | ||
| 93 | + expect(CONDITION_OPERATORS.matches('test', '[invalid')).toBe(false) // 无效正则 | ||
| 94 | + }) | ||
| 95 | + }) | ||
| 96 | + | ||
| 97 | + describe('布尔/空值操作符', () => { | ||
| 98 | + it('truthy - 真值', () => { | ||
| 99 | + expect(CONDITION_OPERATORS.truthy(true)).toBe(true) | ||
| 100 | + expect(CONDITION_OPERATORS.truthy(1)).toBe(true) | ||
| 101 | + expect(CONDITION_OPERATORS.truthy('text')).toBe(true) | ||
| 102 | + expect(CONDITION_OPERATORS.truthy(false)).toBe(false) | ||
| 103 | + expect(CONDITION_OPERATORS.truthy(0)).toBe(false) | ||
| 104 | + expect(CONDITION_OPERATORS.truthy('')).toBe(false) | ||
| 105 | + expect(CONDITION_OPERATORS.truthy(null)).toBe(false) | ||
| 106 | + }) | ||
| 107 | + | ||
| 108 | + it('falsy - 假值', () => { | ||
| 109 | + expect(CONDITION_OPERATORS.falsy(false)).toBe(true) | ||
| 110 | + expect(CONDITION_OPERATORS.falsy(0)).toBe(true) | ||
| 111 | + expect(CONDITION_OPERATORS.falsy('')).toBe(true) | ||
| 112 | + expect(CONDITION_OPERATORS.falsy(null)).toBe(true) | ||
| 113 | + expect(CONDITION_OPERATORS.falsy(true)).toBe(false) | ||
| 114 | + }) | ||
| 115 | + | ||
| 116 | + it('empty - 空值', () => { | ||
| 117 | + expect(CONDITION_OPERATORS.empty('')).toBe(true) | ||
| 118 | + expect(CONDITION_OPERATORS.empty(null)).toBe(true) | ||
| 119 | + expect(CONDITION_OPERATORS.empty(undefined)).toBe(true) | ||
| 120 | + expect(CONDITION_OPERATORS.empty(0)).toBe(false) | ||
| 121 | + expect(CONDITION_OPERATORS.empty('text')).toBe(false) | ||
| 122 | + }) | ||
| 123 | + | ||
| 124 | + it('notEmpty - 非空', () => { | ||
| 125 | + expect(CONDITION_OPERATORS.notEmpty('text')).toBe(true) | ||
| 126 | + expect(CONDITION_OPERATORS.notEmpty(0)).toBe(true) | ||
| 127 | + expect(CONDITION_OPERATORS.notEmpty('')).toBe(false) | ||
| 128 | + expect(CONDITION_OPERATORS.notEmpty(null)).toBe(false) | ||
| 129 | + }) | ||
| 130 | + | ||
| 131 | + it('emptyArray - 空数组', () => { | ||
| 132 | + expect(CONDITION_OPERATORS.emptyArray([])).toBe(true) | ||
| 133 | + expect(CONDITION_OPERATORS.emptyArray(null)).toBe(true) | ||
| 134 | + expect(CONDITION_OPERATORS.emptyArray([1])).toBe(false) | ||
| 135 | + }) | ||
| 136 | + | ||
| 137 | + it('notEmptyArray - 非空数组', () => { | ||
| 138 | + expect(CONDITION_OPERATORS.notEmptyArray([1])).toBe(true) | ||
| 139 | + expect(CONDITION_OPERATORS.notEmptyArray([])).toBe(false) | ||
| 140 | + }) | ||
| 141 | + }) | ||
| 142 | +}) | ||
| 143 | + | ||
| 144 | +describe('normalizeOperator', () => { | ||
| 145 | + it('返回标准操作符', () => { | ||
| 146 | + expect(normalizeOperator('eq')).toBe('eq') | ||
| 147 | + expect(normalizeOperator('gt')).toBe('gt') | ||
| 148 | + }) | ||
| 149 | + | ||
| 150 | + it('处理别名', () => { | ||
| 151 | + expect(normalizeOperator('equals')).toBe('eq') | ||
| 152 | + expect(normalizeOperator('==')).toBe('eq') | ||
| 153 | + expect(normalizeOperator('===')).toBe('eq') | ||
| 154 | + expect(normalizeOperator('!=')).toBe('ne') | ||
| 155 | + expect(normalizeOperator('>')).toBe('gt') | ||
| 156 | + expect(normalizeOperator('>=')).toBe('gte') | ||
| 157 | + }) | ||
| 158 | + | ||
| 159 | + it('未知操作符返回 null', () => { | ||
| 160 | + expect(normalizeOperator('unknown')).toBe(null) | ||
| 161 | + }) | ||
| 162 | +}) | ||
| 163 | + | ||
| 164 | +describe('evaluateSingleCondition', () => { | ||
| 165 | + const formData = { smoker: '是', age: 30, name: 'John' } | ||
| 166 | + | ||
| 167 | + it('评估等于条件', () => { | ||
| 168 | + expect(evaluateSingleCondition( | ||
| 169 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 170 | + formData | ||
| 171 | + )).toBe(true) | ||
| 172 | + | ||
| 173 | + expect(evaluateSingleCondition( | ||
| 174 | + { field: 'smoker', op: 'eq', value: '否' }, | ||
| 175 | + formData | ||
| 176 | + )).toBe(false) | ||
| 177 | + }) | ||
| 178 | + | ||
| 179 | + it('评估大于条件', () => { | ||
| 180 | + expect(evaluateSingleCondition( | ||
| 181 | + { field: 'age', op: 'gt', value: 18 }, | ||
| 182 | + formData | ||
| 183 | + )).toBe(true) | ||
| 184 | + }) | ||
| 185 | + | ||
| 186 | + it('评估包含条件', () => { | ||
| 187 | + expect(evaluateSingleCondition( | ||
| 188 | + { field: 'name', op: 'contains', value: 'John' }, | ||
| 189 | + formData | ||
| 190 | + )).toBe(true) | ||
| 191 | + }) | ||
| 192 | + | ||
| 193 | + it('缺少字段或操作符返回 false', () => { | ||
| 194 | + expect(evaluateSingleCondition({ op: 'eq', value: 'x' }, formData)).toBe(false) | ||
| 195 | + expect(evaluateSingleCondition({ field: 'x', value: 'y' }, formData)).toBe(false) | ||
| 196 | + }) | ||
| 197 | + | ||
| 198 | + it('未知操作符返回 false', () => { | ||
| 199 | + expect(evaluateSingleCondition( | ||
| 200 | + { field: 'smoker', op: 'unknown', value: '是' }, | ||
| 201 | + formData | ||
| 202 | + )).toBe(false) | ||
| 203 | + }) | ||
| 204 | +}) | ||
| 205 | + | ||
| 206 | +describe('evaluateAnd', () => { | ||
| 207 | + const formData = { smoker: '是', age: 30 } | ||
| 208 | + | ||
| 209 | + it('所有条件满足返回 true', () => { | ||
| 210 | + expect(evaluateAnd([ | ||
| 211 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 212 | + { field: 'age', op: 'gte', value: 18 } | ||
| 213 | + ], formData)).toBe(true) | ||
| 214 | + }) | ||
| 215 | + | ||
| 216 | + it('任一条件不满足返回 false', () => { | ||
| 217 | + expect(evaluateAnd([ | ||
| 218 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 219 | + { field: 'age', op: 'gte', value: 50 } | ||
| 220 | + ], formData)).toBe(false) | ||
| 221 | + }) | ||
| 222 | + | ||
| 223 | + it('空数组返回 true', () => { | ||
| 224 | + expect(evaluateAnd([], formData)).toBe(true) | ||
| 225 | + }) | ||
| 226 | +}) | ||
| 227 | + | ||
| 228 | +describe('evaluateOr', () => { | ||
| 229 | + const formData = { smoker: '是', age: 30 } | ||
| 230 | + | ||
| 231 | + it('任一条件满足返回 true', () => { | ||
| 232 | + expect(evaluateOr([ | ||
| 233 | + { field: 'smoker', op: 'eq', value: '否' }, | ||
| 234 | + { field: 'age', op: 'gte', value: 18 } | ||
| 235 | + ], formData)).toBe(true) | ||
| 236 | + }) | ||
| 237 | + | ||
| 238 | + it('所有条件不满足返回 false', () => { | ||
| 239 | + expect(evaluateOr([ | ||
| 240 | + { field: 'smoker', op: 'eq', value: '否' }, | ||
| 241 | + { field: 'age', op: 'gte', value: 50 } | ||
| 242 | + ], formData)).toBe(false) | ||
| 243 | + }) | ||
| 244 | + | ||
| 245 | + it('空数组返回 false', () => { | ||
| 246 | + expect(evaluateOr([], formData)).toBe(false) | ||
| 247 | + }) | ||
| 248 | +}) | ||
| 249 | + | ||
| 250 | +describe('evaluateNot', () => { | ||
| 251 | + const formData = { smoker: '是' } | ||
| 252 | + | ||
| 253 | + it('条件不满足返回 true', () => { | ||
| 254 | + expect(evaluateNot( | ||
| 255 | + { field: 'smoker', op: 'eq', value: '否' }, | ||
| 256 | + formData | ||
| 257 | + )).toBe(true) | ||
| 258 | + }) | ||
| 259 | + | ||
| 260 | + it('条件满足返回 false', () => { | ||
| 261 | + expect(evaluateNot( | ||
| 262 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 263 | + formData | ||
| 264 | + )).toBe(false) | ||
| 265 | + }) | ||
| 266 | +}) | ||
| 267 | + | ||
| 268 | +describe('evaluateCondition', () => { | ||
| 269 | + const formData = { | ||
| 270 | + smoker: '是', | ||
| 271 | + age: 30, | ||
| 272 | + product_type: 'A', | ||
| 273 | + coverage: 100000, | ||
| 274 | + payment_period: '20年' | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + it('空条件返回 true', () => { | ||
| 278 | + expect(evaluateCondition(null, formData)).toBe(true) | ||
| 279 | + expect(evaluateCondition(undefined, formData)).toBe(true) | ||
| 280 | + }) | ||
| 281 | + | ||
| 282 | + it('简单条件', () => { | ||
| 283 | + expect(evaluateCondition( | ||
| 284 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 285 | + formData | ||
| 286 | + )).toBe(true) | ||
| 287 | + }) | ||
| 288 | + | ||
| 289 | + it('AND 逻辑', () => { | ||
| 290 | + expect(evaluateCondition({ | ||
| 291 | + and: [ | ||
| 292 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 293 | + { field: 'age', op: 'gte', value: 30 } | ||
| 294 | + ] | ||
| 295 | + }, formData)).toBe(true) | ||
| 296 | + | ||
| 297 | + expect(evaluateCondition({ | ||
| 298 | + and: [ | ||
| 299 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 300 | + { field: 'age', op: 'gt', value: 50 } | ||
| 301 | + ] | ||
| 302 | + }, formData)).toBe(false) | ||
| 303 | + }) | ||
| 304 | + | ||
| 305 | + it('OR 逻辑', () => { | ||
| 306 | + expect(evaluateCondition({ | ||
| 307 | + or: [ | ||
| 308 | + { field: 'smoker', op: 'eq', value: '否' }, | ||
| 309 | + { field: 'age', op: 'gte', value: 30 } | ||
| 310 | + ] | ||
| 311 | + }, formData)).toBe(true) | ||
| 312 | + | ||
| 313 | + expect(evaluateCondition({ | ||
| 314 | + or: [ | ||
| 315 | + { field: 'smoker', op: 'eq', value: '否' }, | ||
| 316 | + { field: 'age', op: 'gt', value: 50 } | ||
| 317 | + ] | ||
| 318 | + }, formData)).toBe(false) | ||
| 319 | + }) | ||
| 320 | + | ||
| 321 | + it('NOT 逻辑', () => { | ||
| 322 | + expect(evaluateCondition({ | ||
| 323 | + not: { field: 'smoker', op: 'eq', value: '否' } | ||
| 324 | + }, formData)).toBe(true) | ||
| 325 | + }) | ||
| 326 | + | ||
| 327 | + it('嵌套条件', () => { | ||
| 328 | + // (smoker == '是' OR age > 50) AND product_type in ['A', 'B'] | ||
| 329 | + expect(evaluateCondition({ | ||
| 330 | + and: [ | ||
| 331 | + { | ||
| 332 | + or: [ | ||
| 333 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 334 | + { field: 'age', op: 'gt', value: 50 } | ||
| 335 | + ] | ||
| 336 | + }, | ||
| 337 | + { field: 'product_type', op: 'in', value: ['A', 'B'] } | ||
| 338 | + ] | ||
| 339 | + }, formData)).toBe(true) | ||
| 340 | + }) | ||
| 341 | + | ||
| 342 | + it('数组默认为 AND', () => { | ||
| 343 | + expect(evaluateCondition([ | ||
| 344 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 345 | + { field: 'age', op: 'gte', value: 30 } | ||
| 346 | + ], formData)).toBe(true) | ||
| 347 | + }) | ||
| 348 | + | ||
| 349 | + it('旧格式兼容 - equals', () => { | ||
| 350 | + expect(evaluateCondition( | ||
| 351 | + { field: 'smoker', equals: '是' }, | ||
| 352 | + formData | ||
| 353 | + )).toBe(true) | ||
| 354 | + }) | ||
| 355 | + | ||
| 356 | + it('旧格式兼容 - 扁平对象', () => { | ||
| 357 | + expect(evaluateCondition( | ||
| 358 | + { smoker: '是', age: 30 }, | ||
| 359 | + formData | ||
| 360 | + )).toBe(true) | ||
| 361 | + }) | ||
| 362 | +}) | ||
| 363 | + | ||
| 364 | +describe('getConditionDependencies', () => { | ||
| 365 | + it('提取简单条件的依赖', () => { | ||
| 366 | + const deps = getConditionDependencies({ field: 'smoker', op: 'eq', value: '是' }) | ||
| 367 | + expect(deps.has('smoker')).toBe(true) | ||
| 368 | + expect(deps.size).toBe(1) | ||
| 369 | + }) | ||
| 370 | + | ||
| 371 | + it('提取 AND 条件的依赖', () => { | ||
| 372 | + const deps = getConditionDependencies({ | ||
| 373 | + and: [ | ||
| 374 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 375 | + { field: 'age', op: 'gte', value: 30 } | ||
| 376 | + ] | ||
| 377 | + }) | ||
| 378 | + expect(deps.has('smoker')).toBe(true) | ||
| 379 | + expect(deps.has('age')).toBe(true) | ||
| 380 | + expect(deps.size).toBe(2) | ||
| 381 | + }) | ||
| 382 | + | ||
| 383 | + it('提取嵌套条件的依赖', () => { | ||
| 384 | + const deps = getConditionDependencies({ | ||
| 385 | + and: [ | ||
| 386 | + { field: 'a', op: 'eq', value: 1 }, | ||
| 387 | + { | ||
| 388 | + or: [ | ||
| 389 | + { field: 'b', op: 'eq', value: 2 }, | ||
| 390 | + { field: 'c', op: 'eq', value: 3 } | ||
| 391 | + ] | ||
| 392 | + } | ||
| 393 | + ] | ||
| 394 | + }) | ||
| 395 | + expect(deps.has('a')).toBe(true) | ||
| 396 | + expect(deps.has('b')).toBe(true) | ||
| 397 | + expect(deps.has('c')).toBe(true) | ||
| 398 | + expect(deps.size).toBe(3) | ||
| 399 | + }) | ||
| 400 | + | ||
| 401 | + it('提取扁平对象的依赖', () => { | ||
| 402 | + const deps = getConditionDependencies({ smoker: '是', age: 30 }) | ||
| 403 | + expect(deps.has('smoker')).toBe(true) | ||
| 404 | + expect(deps.has('age')).toBe(true) | ||
| 405 | + }) | ||
| 406 | +}) | ||
| 407 | + | ||
| 408 | +describe('convertToNewFormat', () => { | ||
| 409 | + it('已经是新格式则直接返回', () => { | ||
| 410 | + const condition = { field: 'smoker', op: 'eq', value: '是' } | ||
| 411 | + expect(convertToNewFormat(condition)).toEqual(condition) | ||
| 412 | + }) | ||
| 413 | + | ||
| 414 | + it('转换旧格式 equals', () => { | ||
| 415 | + expect(convertToNewFormat( | ||
| 416 | + { field: 'smoker', equals: '是' } | ||
| 417 | + )).toEqual({ field: 'smoker', op: 'eq', value: '是' }) | ||
| 418 | + }) | ||
| 419 | + | ||
| 420 | + it('转换旧格式数组为 AND', () => { | ||
| 421 | + expect(convertToNewFormat([ | ||
| 422 | + { field: 'a', equals: 'x' }, | ||
| 423 | + { field: 'b', equals: 'y' } | ||
| 424 | + ])).toEqual({ | ||
| 425 | + and: [ | ||
| 426 | + { field: 'a', op: 'eq', value: 'x' }, | ||
| 427 | + { field: 'b', op: 'eq', value: 'y' } | ||
| 428 | + ] | ||
| 429 | + }) | ||
| 430 | + }) | ||
| 431 | + | ||
| 432 | + it('单个条件的数组不包装 AND', () => { | ||
| 433 | + expect(convertToNewFormat([ | ||
| 434 | + { field: 'a', equals: 'x' } | ||
| 435 | + ])).toEqual({ field: 'a', op: 'eq', value: 'x' }) | ||
| 436 | + }) | ||
| 437 | + | ||
| 438 | + it('转换扁平对象', () => { | ||
| 439 | + expect(convertToNewFormat({ smoker: '是' })).toEqual({ | ||
| 440 | + field: 'smoker', | ||
| 441 | + op: 'eq', | ||
| 442 | + value: '是' | ||
| 443 | + }) | ||
| 444 | + }) | ||
| 445 | + | ||
| 446 | + it('转换多字段扁平对象为 AND', () => { | ||
| 447 | + expect(convertToNewFormat({ smoker: '是', age: 30 })).toEqual({ | ||
| 448 | + and: [ | ||
| 449 | + { field: 'smoker', op: 'eq', value: '是' }, | ||
| 450 | + { field: 'age', op: 'eq', value: 30 } | ||
| 451 | + ] | ||
| 452 | + }) | ||
| 453 | + }) | ||
| 454 | +}) |
src/config/plan-conditions.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 计划书字段条件规则引擎 | ||
| 3 | + * | ||
| 4 | + * @description 定义条件操作符和评估逻辑,支持复杂的字段显示/隐藏条件 | ||
| 5 | + * @module config/plan-conditions | ||
| 6 | + * @author Claude Code | ||
| 7 | + * @created 2026-02-15 | ||
| 8 | + * @version 1.0.0 | ||
| 9 | + */ | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * 条件操作符定义 | ||
| 13 | + * | ||
| 14 | + * @description 所有可用的条件比较操作符 | ||
| 15 | + * @type {Object<string, Function>} | ||
| 16 | + */ | ||
| 17 | +export const CONDITION_OPERATORS = { | ||
| 18 | + // ========== 比较操作 ========== | ||
| 19 | + | ||
| 20 | + /** | ||
| 21 | + * 等于(严格相等) | ||
| 22 | + * @param {*} actual - 实际值 | ||
| 23 | + * @param {*} expected - 期望值 | ||
| 24 | + * @returns {boolean} | ||
| 25 | + */ | ||
| 26 | + eq: (actual, expected) => actual === expected, | ||
| 27 | + | ||
| 28 | + /** | ||
| 29 | + * 不等于 | ||
| 30 | + * @param {*} actual - 实际值 | ||
| 31 | + * @param {*} expected - 期望值 | ||
| 32 | + * @returns {boolean} | ||
| 33 | + */ | ||
| 34 | + ne: (actual, expected) => actual !== expected, | ||
| 35 | + | ||
| 36 | + /** | ||
| 37 | + * 大于 | ||
| 38 | + * @param {number} actual - 实际值 | ||
| 39 | + * @param {number} expected - 期望值 | ||
| 40 | + * @returns {boolean} | ||
| 41 | + */ | ||
| 42 | + gt: (actual, expected) => { | ||
| 43 | + const num = Number(actual) | ||
| 44 | + const exp = Number(expected) | ||
| 45 | + return !Number.isNaN(num) && !Number.isNaN(exp) && num > exp | ||
| 46 | + }, | ||
| 47 | + | ||
| 48 | + /** | ||
| 49 | + * 大于等于 | ||
| 50 | + * @param {number} actual - 实际值 | ||
| 51 | + * @param {number} expected - 期望值 | ||
| 52 | + * @returns {boolean} | ||
| 53 | + */ | ||
| 54 | + gte: (actual, expected) => { | ||
| 55 | + const num = Number(actual) | ||
| 56 | + const exp = Number(expected) | ||
| 57 | + return !Number.isNaN(num) && !Number.isNaN(exp) && num >= exp | ||
| 58 | + }, | ||
| 59 | + | ||
| 60 | + /** | ||
| 61 | + * 小于 | ||
| 62 | + * @param {number} actual - 实际值 | ||
| 63 | + * @param {number} expected - 期望值 | ||
| 64 | + * @returns {boolean} | ||
| 65 | + */ | ||
| 66 | + lt: (actual, expected) => { | ||
| 67 | + const num = Number(actual) | ||
| 68 | + const exp = Number(expected) | ||
| 69 | + return !Number.isNaN(num) && !Number.isNaN(exp) && num < exp | ||
| 70 | + }, | ||
| 71 | + | ||
| 72 | + /** | ||
| 73 | + * 小于等于 | ||
| 74 | + * @param {number} actual - 实际值 | ||
| 75 | + * @param {number} expected - 期望值 | ||
| 76 | + * @returns {boolean} | ||
| 77 | + */ | ||
| 78 | + lte: (actual, expected) => { | ||
| 79 | + const num = Number(actual) | ||
| 80 | + const exp = Number(expected) | ||
| 81 | + return !Number.isNaN(num) && !Number.isNaN(exp) && num <= exp | ||
| 82 | + }, | ||
| 83 | + | ||
| 84 | + // ========== 集合操作 ========== | ||
| 85 | + | ||
| 86 | + /** | ||
| 87 | + * 包含于(值在数组中) | ||
| 88 | + * @param {*} actual - 实际值 | ||
| 89 | + * @param {Array} expected - 期望数组 | ||
| 90 | + * @returns {boolean} | ||
| 91 | + */ | ||
| 92 | + in: (actual, expected) => { | ||
| 93 | + if (!Array.isArray(expected)) return false | ||
| 94 | + return expected.includes(actual) | ||
| 95 | + }, | ||
| 96 | + | ||
| 97 | + /** | ||
| 98 | + * 不包含于(值不在数组中) | ||
| 99 | + * @param {*} actual - 实际值 | ||
| 100 | + * @param {Array} expected - 期望数组 | ||
| 101 | + * @returns {boolean} | ||
| 102 | + */ | ||
| 103 | + nin: (actual, expected) => { | ||
| 104 | + if (!Array.isArray(expected)) return true | ||
| 105 | + return !expected.includes(actual) | ||
| 106 | + }, | ||
| 107 | + | ||
| 108 | + // ========== 字符串操作 ========== | ||
| 109 | + | ||
| 110 | + /** | ||
| 111 | + * 字符串包含 | ||
| 112 | + * @param {string} actual - 实际值 | ||
| 113 | + * @param {string} expected - 期望子串 | ||
| 114 | + * @returns {boolean} | ||
| 115 | + */ | ||
| 116 | + contains: (actual, expected) => { | ||
| 117 | + return String(actual ?? '').includes(String(expected ?? '')) | ||
| 118 | + }, | ||
| 119 | + | ||
| 120 | + /** | ||
| 121 | + * 字符串前缀匹配 | ||
| 122 | + * @param {string} actual - 实际值 | ||
| 123 | + * @param {string} expected - 期望前缀 | ||
| 124 | + * @returns {boolean} | ||
| 125 | + */ | ||
| 126 | + startsWith: (actual, expected) => { | ||
| 127 | + return String(actual ?? '').startsWith(String(expected ?? '')) | ||
| 128 | + }, | ||
| 129 | + | ||
| 130 | + /** | ||
| 131 | + * 字符串后缀匹配 | ||
| 132 | + * @param {string} actual - 实际值 | ||
| 133 | + * @param {string} expected - 期望后缀 | ||
| 134 | + * @returns {boolean} | ||
| 135 | + */ | ||
| 136 | + endsWith: (actual, expected) => { | ||
| 137 | + return String(actual ?? '').endsWith(String(expected ?? '')) | ||
| 138 | + }, | ||
| 139 | + | ||
| 140 | + /** | ||
| 141 | + * 正则表达式匹配 | ||
| 142 | + * @param {string} actual - 实际值 | ||
| 143 | + * @param {string|RegExp} expected - 正则表达式 | ||
| 144 | + * @returns {boolean} | ||
| 145 | + */ | ||
| 146 | + matches: (actual, expected) => { | ||
| 147 | + try { | ||
| 148 | + const regex = expected instanceof RegExp ? expected : new RegExp(expected) | ||
| 149 | + return regex.test(String(actual ?? '')) | ||
| 150 | + } catch { | ||
| 151 | + return false | ||
| 152 | + } | ||
| 153 | + }, | ||
| 154 | + | ||
| 155 | + // ========== 布尔/空值操作 ========== | ||
| 156 | + | ||
| 157 | + /** | ||
| 158 | + * 真值检查 | ||
| 159 | + * @param {*} actual - 实际值 | ||
| 160 | + * @returns {boolean} | ||
| 161 | + */ | ||
| 162 | + truthy: (actual) => !!actual, | ||
| 163 | + | ||
| 164 | + /** | ||
| 165 | + * 假值检查 | ||
| 166 | + * @param {*} actual - 实际值 | ||
| 167 | + * @returns {boolean} | ||
| 168 | + */ | ||
| 169 | + falsy: (actual) => !actual, | ||
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * 空值检查(null, undefined, 空字符串) | ||
| 173 | + * @param {*} actual - 实际值 | ||
| 174 | + * @returns {boolean} | ||
| 175 | + */ | ||
| 176 | + empty: (actual) => actual === '' || actual === null || actual === undefined, | ||
| 177 | + | ||
| 178 | + /** | ||
| 179 | + * 非空检查 | ||
| 180 | + * @param {*} actual - 实际值 | ||
| 181 | + * @returns {boolean} | ||
| 182 | + */ | ||
| 183 | + notEmpty: (actual) => actual !== '' && actual !== null && actual !== undefined, | ||
| 184 | + | ||
| 185 | + /** | ||
| 186 | + * 空数组检查 | ||
| 187 | + * @param {*} actual - 实际值 | ||
| 188 | + * @returns {boolean} | ||
| 189 | + */ | ||
| 190 | + emptyArray: (actual) => !Array.isArray(actual) || actual.length === 0, | ||
| 191 | + | ||
| 192 | + /** | ||
| 193 | + * 非空数组检查 | ||
| 194 | + * @param {*} actual - 实际值 | ||
| 195 | + * @returns {boolean} | ||
| 196 | + */ | ||
| 197 | + notEmptyArray: (actual) => Array.isArray(actual) && actual.length > 0 | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +/** | ||
| 201 | + * 操作符别名映射(向后兼容) | ||
| 202 | + * | ||
| 203 | + * @description 旧格式操作符到新格式的映射 | ||
| 204 | + * @type {Object<string, string>} | ||
| 205 | + */ | ||
| 206 | +export const OPERATOR_ALIASES = { | ||
| 207 | + equals: 'eq', | ||
| 208 | + '==': 'eq', | ||
| 209 | + '===': 'eq', | ||
| 210 | + '!=': 'ne', | ||
| 211 | + '!==': 'ne', | ||
| 212 | + '>': 'gt', | ||
| 213 | + '>=': 'gte', | ||
| 214 | + '<': 'lt', | ||
| 215 | + '<=': 'lte' | ||
| 216 | +} | ||
| 217 | + | ||
| 218 | +/** | ||
| 219 | + * 解析操作符(支持别名) | ||
| 220 | + * | ||
| 221 | + * @param {string} op - 操作符名称或别名 | ||
| 222 | + * @returns {string|null} 标准操作符名称 | ||
| 223 | + */ | ||
| 224 | +export function normalizeOperator(op) { | ||
| 225 | + if (CONDITION_OPERATORS[op]) return op | ||
| 226 | + if (OPERATOR_ALIASES[op]) return OPERATOR_ALIASES[op] | ||
| 227 | + return null | ||
| 228 | +} | ||
| 229 | + | ||
| 230 | +/** | ||
| 231 | + * 评估单个条件 | ||
| 232 | + * | ||
| 233 | + * @description 评估一个简单的条件表达式 | ||
| 234 | + * @param {Object} condition - 条件对象 | ||
| 235 | + * @param {string} condition.field - 字段名 | ||
| 236 | + * @param {string} condition.op - 操作符 | ||
| 237 | + * @param {*} condition.value - 期望值 | ||
| 238 | + * @param {Object} formData - 表单数据 | ||
| 239 | + * @returns {boolean} 条件是否满足 | ||
| 240 | + * | ||
| 241 | + * @example | ||
| 242 | + * evaluateSingleCondition( | ||
| 243 | + * { field: 'smoker', op: 'eq', value: '是' }, | ||
| 244 | + * { smoker: '是', age: 30 } | ||
| 245 | + * ) // true | ||
| 246 | + */ | ||
| 247 | +export function evaluateSingleCondition(condition, formData) { | ||
| 248 | + const { field, op, value } = condition | ||
| 249 | + | ||
| 250 | + if (!field || !op) { | ||
| 251 | + console.warn('[plan-conditions] 条件缺少 field 或 op:', condition) | ||
| 252 | + return false | ||
| 253 | + } | ||
| 254 | + | ||
| 255 | + const normalizedOp = normalizeOperator(op) | ||
| 256 | + if (!normalizedOp) { | ||
| 257 | + console.warn(`[plan-conditions] 未知操作符: ${op}`) | ||
| 258 | + return false | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + const operator = CONDITION_OPERATORS[normalizedOp] | ||
| 262 | + const actualValue = formData[field] | ||
| 263 | + | ||
| 264 | + try { | ||
| 265 | + return operator(actualValue, value) | ||
| 266 | + } catch (err) { | ||
| 267 | + console.error(`[plan-conditions] 条件评估失败:`, err) | ||
| 268 | + return false | ||
| 269 | + } | ||
| 270 | +} | ||
| 271 | + | ||
| 272 | +/** | ||
| 273 | + * 评估 AND 逻辑 | ||
| 274 | + * | ||
| 275 | + * @param {Array} conditions - 条件数组 | ||
| 276 | + * @param {Object} formData - 表单数据 | ||
| 277 | + * @returns {boolean} 所有条件是否都满足 | ||
| 278 | + */ | ||
| 279 | +export function evaluateAnd(conditions, formData) { | ||
| 280 | + if (!Array.isArray(conditions) || conditions.length === 0) return true | ||
| 281 | + return conditions.every(c => evaluateCondition(c, formData)) | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +/** | ||
| 285 | + * 评估 OR 逻辑 | ||
| 286 | + * | ||
| 287 | + * @param {Array} conditions - 条件数组 | ||
| 288 | + * @param {Object} formData - 表单数据 | ||
| 289 | + * @returns {boolean} 是否有任一条件满足 | ||
| 290 | + */ | ||
| 291 | +export function evaluateOr(conditions, formData) { | ||
| 292 | + if (!Array.isArray(conditions) || conditions.length === 0) return false | ||
| 293 | + return conditions.some(c => evaluateCondition(c, formData)) | ||
| 294 | +} | ||
| 295 | + | ||
| 296 | +/** | ||
| 297 | + * 评估 NOT 逻辑 | ||
| 298 | + * | ||
| 299 | + * @param {Object} condition - 条件对象 | ||
| 300 | + * @param {Object} formData - 表单数据 | ||
| 301 | + * @returns {boolean} 条件是否不满足 | ||
| 302 | + */ | ||
| 303 | +export function evaluateNot(condition, formData) { | ||
| 304 | + if (!condition) return false | ||
| 305 | + return !evaluateCondition(condition, formData) | ||
| 306 | +} | ||
| 307 | + | ||
| 308 | +/** | ||
| 309 | + * 评估条件(主入口) | ||
| 310 | + * | ||
| 311 | + * @description 评估任意条件表达式,支持简单条件和逻辑组合 | ||
| 312 | + * @param {Object|Array} condition - 条件表达式 | ||
| 313 | + * @param {Object} formData - 表单数据 | ||
| 314 | + * @returns {boolean} 条件是否满足 | ||
| 315 | + * | ||
| 316 | + * @example | ||
| 317 | + * // 简单条件 | ||
| 318 | + * evaluateCondition({ field: 'smoker', op: 'eq', value: '是' }, formData) | ||
| 319 | + * | ||
| 320 | + * @example | ||
| 321 | + * // AND 条件 | ||
| 322 | + * evaluateCondition({ | ||
| 323 | + * and: [ | ||
| 324 | + * { field: 'smoker', op: 'eq', value: '是' }, | ||
| 325 | + * { field: 'age', op: 'gte', value: 30 } | ||
| 326 | + * ] | ||
| 327 | + * }, formData) | ||
| 328 | + * | ||
| 329 | + * @example | ||
| 330 | + * // OR 条件 | ||
| 331 | + * evaluateCondition({ | ||
| 332 | + * or: [ | ||
| 333 | + * { field: 'smoker', op: 'eq', value: '是' }, | ||
| 334 | + * { field: 'age', op: 'gt', value: 50 } | ||
| 335 | + * ] | ||
| 336 | + * }, formData) | ||
| 337 | + * | ||
| 338 | + * @example | ||
| 339 | + * // 嵌套条件 | ||
| 340 | + * evaluateCondition({ | ||
| 341 | + * and: [ | ||
| 342 | + * { field: 'type', op: 'in', value: ['A', 'B'] }, | ||
| 343 | + * { | ||
| 344 | + * or: [ | ||
| 345 | + * { field: 'coverage', op: 'gte', value: 100000 }, | ||
| 346 | + * { field: 'period', op: 'eq', value: '20年' } | ||
| 347 | + * ] | ||
| 348 | + * } | ||
| 349 | + * ] | ||
| 350 | + * }, formData) | ||
| 351 | + */ | ||
| 352 | +export function evaluateCondition(condition, formData) { | ||
| 353 | + // 空条件默认为 true | ||
| 354 | + if (!condition) return true | ||
| 355 | + | ||
| 356 | + // 数组条件:默认为 AND 逻辑 | ||
| 357 | + if (Array.isArray(condition)) { | ||
| 358 | + return evaluateAnd(condition, formData) | ||
| 359 | + } | ||
| 360 | + | ||
| 361 | + // 对象条件 | ||
| 362 | + if (typeof condition === 'object') { | ||
| 363 | + // AND 逻辑 | ||
| 364 | + if (condition.and) { | ||
| 365 | + return evaluateAnd(condition.and, formData) | ||
| 366 | + } | ||
| 367 | + | ||
| 368 | + // OR 逻辑 | ||
| 369 | + if (condition.or) { | ||
| 370 | + return evaluateOr(condition.or, formData) | ||
| 371 | + } | ||
| 372 | + | ||
| 373 | + // NOT 逻辑 | ||
| 374 | + if (condition.not) { | ||
| 375 | + return evaluateNot(condition.not, formData) | ||
| 376 | + } | ||
| 377 | + | ||
| 378 | + // 简单条件(包含 field 和 op) | ||
| 379 | + if (condition.field && condition.op) { | ||
| 380 | + return evaluateSingleCondition(condition, formData) | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + // 旧格式兼容:{ field: 'xxx', equals: 'yyy' } | ||
| 384 | + if (condition.field && condition.equals !== undefined) { | ||
| 385 | + return evaluateSingleCondition( | ||
| 386 | + { field: condition.field, op: 'eq', value: condition.equals }, | ||
| 387 | + formData | ||
| 388 | + ) | ||
| 389 | + } | ||
| 390 | + | ||
| 391 | + // 旧格式兼容:{ field: 'xxx', not_equals: 'yyy' } | ||
| 392 | + if (condition.field && condition.not_equals !== undefined) { | ||
| 393 | + return evaluateSingleCondition( | ||
| 394 | + { field: condition.field, op: 'ne', value: condition.not_equals }, | ||
| 395 | + formData | ||
| 396 | + ) | ||
| 397 | + } | ||
| 398 | + | ||
| 399 | + // 扁平条件对象:{ smoker: '是', age: 30 } → 所有条件 AND | ||
| 400 | + const keys = Object.keys(condition) | ||
| 401 | + if (keys.length > 0 && !keys.some(k => ['and', 'or', 'not', 'field', 'op'].includes(k))) { | ||
| 402 | + return keys.every(field => { | ||
| 403 | + const expectedValue = condition[field] | ||
| 404 | + const actualValue = formData[field] | ||
| 405 | + return actualValue === expectedValue | ||
| 406 | + }) | ||
| 407 | + } | ||
| 408 | + } | ||
| 409 | + | ||
| 410 | + console.warn('[plan-conditions] 无法识别的条件格式:', condition) | ||
| 411 | + return false | ||
| 412 | +} | ||
| 413 | + | ||
| 414 | +/** | ||
| 415 | + * 获取条件依赖的字段列表 | ||
| 416 | + * | ||
| 417 | + * @description 从条件表达式中提取所有依赖的字段名 | ||
| 418 | + * @param {Object|Array} condition - 条件表达式 | ||
| 419 | + * @returns {Set<string>} 依赖的字段名集合 | ||
| 420 | + * | ||
| 421 | + * @example | ||
| 422 | + * getConditionDependencies({ and: [{ field: 'a', op: 'eq', value: 1 }, { field: 'b', op: 'eq', value: 2 }] }) | ||
| 423 | + * // Set { 'a', 'b' } | ||
| 424 | + */ | ||
| 425 | +export function getConditionDependencies(condition) { | ||
| 426 | + const deps = new Set() | ||
| 427 | + | ||
| 428 | + if (!condition) return deps | ||
| 429 | + | ||
| 430 | + // 数组条件 | ||
| 431 | + if (Array.isArray(condition)) { | ||
| 432 | + condition.forEach(c => { | ||
| 433 | + const subDeps = getConditionDependencies(c) | ||
| 434 | + subDeps.forEach(d => deps.add(d)) | ||
| 435 | + }) | ||
| 436 | + return deps | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + if (typeof condition === 'object') { | ||
| 440 | + // 简单条件 | ||
| 441 | + if (condition.field) { | ||
| 442 | + deps.add(condition.field) | ||
| 443 | + } | ||
| 444 | + | ||
| 445 | + // 扁平条件对象 | ||
| 446 | + const keys = Object.keys(condition) | ||
| 447 | + if (keys.length > 0 && !keys.some(k => ['and', 'or', 'not', 'field', 'op', 'equals'].includes(k))) { | ||
| 448 | + keys.forEach(k => deps.add(k)) | ||
| 449 | + } | ||
| 450 | + | ||
| 451 | + // 递归处理逻辑组合 | ||
| 452 | + if (condition.and) { | ||
| 453 | + condition.and.forEach(c => { | ||
| 454 | + const subDeps = getConditionDependencies(c) | ||
| 455 | + subDeps.forEach(d => deps.add(d)) | ||
| 456 | + }) | ||
| 457 | + } | ||
| 458 | + if (condition.or) { | ||
| 459 | + condition.or.forEach(c => { | ||
| 460 | + const subDeps = getConditionDependencies(c) | ||
| 461 | + subDeps.forEach(d => deps.add(d)) | ||
| 462 | + }) | ||
| 463 | + } | ||
| 464 | + if (condition.not) { | ||
| 465 | + const subDeps = getConditionDependencies(condition.not) | ||
| 466 | + subDeps.forEach(d => deps.add(d)) | ||
| 467 | + } | ||
| 468 | + } | ||
| 469 | + | ||
| 470 | + return deps | ||
| 471 | +} | ||
| 472 | + | ||
| 473 | +/** | ||
| 474 | + * 将旧格式转换为新格式 | ||
| 475 | + * | ||
| 476 | + * @description 将旧的 show_when 格式转换为新的条件格式 | ||
| 477 | + * @param {Object|Array|string} oldFormat - 旧格式条件 | ||
| 478 | + * @returns {Object|null} 新格式条件 | ||
| 479 | + * | ||
| 480 | + * @example | ||
| 481 | + * // 旧格式 | ||
| 482 | + * { field: 'withdrawal_mode', equals: '指定提取金额' } | ||
| 483 | + * // 转换为 | ||
| 484 | + * { field: 'withdrawal_mode', op: 'eq', value: '指定提取金额' } | ||
| 485 | + * | ||
| 486 | + * @example | ||
| 487 | + * // 旧格式数组 | ||
| 488 | + * [{ field: 'a', equals: 'x' }, { field: 'b', equals: 'y' }] | ||
| 489 | + * // 转换为 | ||
| 490 | + * { and: [{ field: 'a', op: 'eq', value: 'x' }, { field: 'b', op: 'eq', value: 'y' }] } | ||
| 491 | + */ | ||
| 492 | +export function convertToNewFormat(oldFormat) { | ||
| 493 | + if (!oldFormat) return null | ||
| 494 | + | ||
| 495 | + // 已经是新格式 | ||
| 496 | + if (oldFormat.and || oldFormat.or || oldFormat.not || oldFormat.op) { | ||
| 497 | + return oldFormat | ||
| 498 | + } | ||
| 499 | + | ||
| 500 | + // 旧格式单个条件 | ||
| 501 | + if (oldFormat.field && oldFormat.equals !== undefined) { | ||
| 502 | + return { | ||
| 503 | + field: oldFormat.field, | ||
| 504 | + op: 'eq', | ||
| 505 | + value: oldFormat.equals | ||
| 506 | + } | ||
| 507 | + } | ||
| 508 | + | ||
| 509 | + // 旧格式数组 | ||
| 510 | + if (Array.isArray(oldFormat) && oldFormat.length > 0) { | ||
| 511 | + const conditions = oldFormat.map(c => { | ||
| 512 | + if (c.field && c.equals !== undefined) { | ||
| 513 | + return { field: c.field, op: 'eq', value: c.equals } | ||
| 514 | + } | ||
| 515 | + return c | ||
| 516 | + }) | ||
| 517 | + | ||
| 518 | + // 单个条件不需要包装 and | ||
| 519 | + if (conditions.length === 1) { | ||
| 520 | + return conditions[0] | ||
| 521 | + } | ||
| 522 | + | ||
| 523 | + return { and: conditions } | ||
| 524 | + } | ||
| 525 | + | ||
| 526 | + // 扁平对象格式 { smoker: '是' } | ||
| 527 | + const keys = Object.keys(oldFormat) | ||
| 528 | + if (keys.length > 0 && !keys.some(k => ['and', 'or', 'not', 'field', 'op', 'equals'].includes(k))) { | ||
| 529 | + if (keys.length === 1) { | ||
| 530 | + return { field: keys[0], op: 'eq', value: oldFormat[keys[0]] } | ||
| 531 | + } | ||
| 532 | + return { | ||
| 533 | + and: keys.map(field => ({ field, op: 'eq', value: oldFormat[field] })) | ||
| 534 | + } | ||
| 535 | + } | ||
| 536 | + | ||
| 537 | + return oldFormat | ||
| 538 | +} |
-
Please register or login to post a comment