Skip to content

案例:人寿保险投保申请 ​

金融保险行业是表单联动复杂度最高的场景之一:费率随多个字段联动、风险等级动态计算、附加费多源叠加、字段显隐取决于多重条件。

这个案例包含 7 大板块、50+ 个字段、22 条联动规则,板块顺序遵循真实保险填写习惯:

板块内容主要联动
投保人姓名/性别/出生年份/联系电话/证件/与被保险人关系出生年份 → 年龄;证件类型 → 证件号占位文本
被保险人姓名/性别/出生年份/证件/职业出生年份 → 年龄;证件类型 → 占位文本;职业 → 风险等级
险种方案险种/期限/缴费期/频率/缴费方式/保额/附加险险种 → 期限选项 + 缴费期选项;保额 → 是否需要体检
健康告知身高/体重/吸烟/慢性病/手术史/家族史身高+体重 → BMI → BMI状态
受益人受益人类型类型 → 三个字段显隐联动
核保提示条件警告5 个独立警告,各由不同条件触发
保费汇总完整保费分解7 个输入 → 基础费率 → 各项附加 → 年保费 → 每期金额

实时演示 ​

依赖关系图 ​

联动规则清单 ​

用 engine.define 实现时,核心规则如下:

typescript
engine.define({
  // ── 被保险人 ──────────────────────────────────────────────────────────
  // 出生年份 → 年龄
  'insured.age.value': from('insured.birthYear',
    (y: number) => new Date().getFullYear() - y
  ),

  // 证件类型 → 证件号码占位文字
  'insured.idNumber.placeholder': from('insured.idType', (t: string) => ({
    id: '请输入 18 位身份证号码',
    passport: '请输入护照号码',
    military: '请输入军官证号码',
    hk: '请输入港澳通行证号码',
  }[t] ?? '')),

  // 职业类别 → 职业风险等级
  'insured.occupationRisk.value': from('insured.occupation',
    (occ: string) => OCCUPATION_MAP[occ]?.risk ?? 'normal'
  ),

  // ── 健康告知 ──────────────────────────────────────────────────────────
  // 身高+体重 → BMI
  'health.bmi.value': from(
    ['health.height', 'health.weight'],
    (h: number, w: number) => h > 0 ? +(w / (h / 100) ** 2).toFixed(1) : 0
  ),

  // BMI → 状态文字
  'health.bmiStatus.value': from('health.bmi', (bmi: number) => {
    if (bmi < 18.5) return '偏瘦'
    if (bmi < 24)   return '正常'
    if (bmi < 28)   return '超重'
    return bmi < 32 ? '肥胖 I 级' : '肥胖 II 级'
  }),

  // ── 险种 → 期限/缴费期选项联动 ────────────────────────────────────────
  'product.term.options': from('product.productType',
    (type: string) => TERM_OPTIONS[type] ?? [],
    {
      effect: ({ options, value }: any) => ({
        value: options.some((o: any) => o.value === value)
          ? undefined
          : options[0]?.value
      }),
      effectArgs: ['options', 'value'],
    }
  ),

  'product.paymentPeriod.options': from('product.productType',
    (type: string) => PAYMENT_PERIOD_OPTIONS[type] ?? [],
    { effect: ({ options }: any) => ({ value: options[0]?.value }), effectArgs: ['options'] }
  ),

  // 保额 → 是否需要体检
  'product.needMedical.value': from('product.coverageAmount',
    (amount: number) => amount >= 500
  ),

  // 保额 → 最高可选保额(受险种限制)
  'product.maxCoverage.value': from('product.productType',
    (type: string) => MAX_COVERAGE[type] ?? 2000
  ),

  // ── 受益人 ──────────────────────────────────────────────────────────
  // 受益人类型 → 3 个字段显隐
  'beneficiary.name.hidden':         from('beneficiary.type', (t: string) => t !== 'designated'),
  'beneficiary.relationship.hidden': from('beneficiary.type', (t: string) => t !== 'designated'),
  'beneficiary.proportion.hidden':   from('beneficiary.type', (t: string) => t !== 'designated'),

  // ── 核保提示(5 条独立警告)─────────────────────────────────────────
  'warnings.highRisk.hidden':   from('insured.occupationRisk', (r: string) => r !== 'high'),
  'warnings.bmi.hidden':        from('health.bmi',             (b: number) => b < 28),
  'warnings.disease.hidden':    from('health.chronicDisease',  (v: boolean) => !v),
  'warnings.age.hidden':        from('insured.age',            (a: number) => a <= 60),
  'warnings.surgery.hidden':    from('health.surgery',         (v: boolean) => !v),

  // ── 保费计算(7 个输入 → 层层推导)────────────────────────────────────
  // 基础费率(依赖:险种 + 年龄段 + 性别)
  'premium.baseRate.value': from(
    ['product.productType', 'insured.age', 'insured.gender'],
    (type: string, age: number, gender: string) => {
      const raw = RATE_TABLE[type]?.[ageBracket(age)] ?? 5
      return +(raw * (gender === 'F' ? 0.85 : 1.0)).toFixed(2)
    }
  ),

  // 基础年保费 = 保额(万) × 费率(‰) × 10
  'premium.basePremium.value': from(
    ['premium.baseRate', 'product.coverageAmount'],
    (rate: number, amount: number) => Math.round(amount * rate * 10)
  ),

  // 各项附加费
  'premium.smokerSurcharge.value':     from(['premium.basePremium', 'health.smoke'],
    (base: number, smoke: boolean) => smoke ? Math.round(base * 0.30) : 0),

  'premium.occupationSurcharge.value': from(['premium.basePremium', 'insured.occupationRisk'],
    (base: number, risk: string) => Math.round(base * (OCCUPATION_SURCHARGE[risk] ?? 0))),

  'premium.bmiSurcharge.value':        from(['premium.basePremium', 'health.bmi'],
    (base: number, bmi: number) => {
      if (bmi >= 32) return Math.round(base * 0.20)
      if (bmi >= 28) return Math.round(base * 0.10)
      return 0
    }),

  'premium.diseaseSurcharge.value':    from(['premium.basePremium', 'health.chronicDisease'],
    (base: number, chronic: boolean) => chronic ? Math.round(base * 0.25) : 0),

  // 调整后年保费(汇聚 6 个来源)
  'premium.adjustedAnnual.value': from(
    ['premium.basePremium', 'premium.smokerSurcharge', 'premium.occupationSurcharge',
     'premium.bmiSurcharge', 'premium.diseaseSurcharge',
     'product.addAccidentPremium', 'product.addDisabilityPremium'],
    (...args: number[]) => args.reduce((a, b) => a + b, 0)
  ),

  // 缴费频率系数
  'premium.freqFactor.value': from('product.paymentFreq',
    (f: string) => FREQ_CONFIG[f]?.factor ?? 1
  ),

  // 最终年保费
  'premium.annualPremium.value': from(
    ['premium.adjustedAnnual', 'premium.freqFactor'],
    (adj: number, factor: number) => Math.round(adj * factor)
  ),

  // 每次缴费
  'premium.perPayment.value': from(
    ['premium.annualPremium', 'product.paymentFreq'],
    (annual: number, freq: string) => Math.round(annual / (FREQ_CONFIG[freq]?.times ?? 1))
  ),
})

为什么用 meshflow 合适 ​

这个表单有一条从 insured.birthYear 到 premium.perPayment 的 8 层计算链:

birthYear → age → baseRate → basePremium → smokerSurcharge
                                          → occupationSurcharge → adjustedAnnual → annualPremium → perPayment
                                          → bmiSurcharge ↗
                                          → diseaseSurcharge ↗

其中 adjustedAnnual 同时依赖 6 个上游字段,annualPremium 还要乘以频率系数。

用 watch 手写这条链:执行顺序无法保证,basePremium 更新时 4 个附加费字段会各自触发一次 adjustedAnnual 的 watch,导致 annualPremium 被中间状态多次错误计算。

用 engine.define:引擎将整个依赖图拓扑排序,确保每个字段只在所有上游稳定后执行一次,从根节点变化到叶节点只有一次完整传播。

MeshForm Vue - deterministic state orchestration for JSON Forms