You need to sign in or sign up before continuing.
plan-entry-module-summary.md 16.6 KB

计划书生成模块 - 完整总结与经验教训

创建时间: 2026-02-06 模块: 计划书生成(Plan Entry) 状态: ✅ 已完成


📐 模块架构

1. 整体架构图

PlanFormContainer.vue (表单容器)
    ↓ 根据 product.form_sn 动态加载
    ├─ LifeInsuranceTemplate.vue (人寿保险)
    ├─ CriticalIllnessTemplate.vue (重疾保险)
    └─ SavingsTemplate.vue (储蓄型产品)
        ↓ 使用通用字段组件
        └─ PlanFields/
            ├─ AgePicker.vue (年龄选择器)
            ├─ AmountInput.vue (保额输入)
            ├─ DatePicker.vue (日期选择器)
            ├─ RadioGroup.vue (单选组)
            └─ SelectPicker.vue (下拉选择)

2. 核心文件

文件 用途 关键特性
PlanFormContainer.vue 动态模板容器 根据 form_sn 加载模板
config/plan-templates.js 模板配置映射 form_sn → 组件配置
PlanTemplates/*.vue 具体模板组件 LifeInsurance/CriticalIllness/Savings
PlanFields/*.vue 通用表单字段 可复用的表单组件

3. 配置驱动设计

核心原则:通过产品的 form_sn 字段自动识别并加载对应模板

// 产品 API 返回
{
  id: 1,
  product_name: "WIOP3E 盈传创富保障计划 3",
  form_sn: "life-insurance-wiop3e"  // ← 关键字段
}

// 配置文件映射
export const PLAN_TEMPLATES = {
  'life-insurance-wiop3e': {
    name: 'WIOP3E...',
    component: 'LifeInsuranceTemplate',
    config: {
      currency: 'USD',
      payment_periods: ['整付(0-75 岁)', '5 年(0-70 岁)', ...]
    }
  }
}

⚠️ 关键问题与解决方案

问题 1: AmountInput 组件输入报错

症状

value.replace is not a function
TypeError: value.replace is not a function

根本原因

  • NutUI 在小程序环境下,@input 事件返回的对象结构是 { detail: { value: "xxx" } }
  • 原代码直接对 e 调用 .replace(),没有提取实际的值

✅ 正确做法

// src/components/PlanFields/AmountInput.vue

const onInput = (e) => {
  // 防御性提取值(兼容 Web 和小程序)
  const rawValue = e?.detail?.value || e?.target?.value || ''

  // 转换为字符串再处理
  const valueStr = String(rawValue)

  // 处理输入逻辑
  // ...
}

关键要点

  • ✅ 始终从 e.detail.valuee.target.value 提取值
  • ✅ 使用 String() 显式转换,避免类型错误
  • ✅ 使用内部状态 inputValue 分离显示值和模型值
  • ✅ 仅在 @blur 时进行严格格式化

问题 2: 提取计划字段结构错误(SavingsTemplate)

历史问题: 经过多次修正,最初实现的字段结构与需求不符。

最终正确的结构(小程序端):

// 第一层:是否启用提取计划
withdrawal_enabled: '是' | '否'

// 第二层:提取选项(仅当启用时显示)
withdrawal_mode: '指定提取金额' | '最高固定提取金额'

// 第三层:根据不同选项显示不同字段
if (withdrawal_mode === '指定提取金额') {
  specified_amount_type: '按年岁' | '按保单年度'

  if (specified_amount_type === '按年岁') {
    withdrawal_start_age: number  // 由几岁开始
    withdrawal_period: string      // 提取期(年)
    increase_rate: string          // 每年递增提取之百分比(%)
    // ❌ 不需要:annual_amount(小程序端不需要此字段)
  }

  if (specified_amount_type === '按保单年度') {
    withdrawal_start_age: number
    withdrawal_period: string
    // ❌ 不需要:annual_amount, increase_rate
  }
}

if (withdrawal_mode === '最高固定提取金额') {
  withdrawal_start_age: number  // 按年岁:由几岁开始
  withdrawal_period: string      // 提取期(年)
}

关键要点

  • 三层结构:启用确认 → 提取选项 → 具体字段
  • 小程序端币种固定:使用配置中的 default_currency,不需要用户选择
  • 字段按需显示:根据用户选择动态显示相关字段
  • 自动清理无关字段:使用 watch 监听变化,删除不相关字段

字段清理逻辑

// 当切换提取模式时
watch(
  () => form.withdrawal_mode,
  (mode) => {
    if (mode === '最高固定提取金额') {
      // 清除指定金额相关字段
      delete form.specified_amount_type
      delete form.annual_amount
      delete form.increase_rate
    }
  }
)

// 当切换指定金额类型时
watch(
  () => form.specified_amount_type,
  () => {
    // 小程序端不需要这些字段
    delete form.annual_amount
    delete form.increase_rate
  }
)

// 当关闭提取计划时
watch(
  () => form.withdrawal_enabled,
  (enabled) => {
    if (enabled === '否') {
      // 清除所有提取计划字段
      delete form.withdrawal_mode
      delete form.specified_amount_type
      delete form.withdrawal_start_age
      delete form.withdrawal_period
      delete form.annual_amount
      delete form.increase_rate
    }
  }
)

问题 3: 模板组件导入路径错误

症状

Failed to resolve component: SavingsTemplate

原因PlanFormContainer.vueSavingsTemplate 的导入路径错误

✅ 正确做法

// ❌ 错误
import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'

// ✅ 正确
import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'
// 或者使用路径别名
import SavingsTemplate from '@/components/PlanTemplates/SavingsTemplate.vue'

关键要点

  • ✅ 检查组件导入路径是否正确
  • ✅ 使用 VSCode 的"跳转到定义"功能验证路径
  • ✅ 组件名称和文件名保持一致(PascalCase)

问题 4: 年龄计算和显示

需求

  • 显示:3 位数字格式(018)
  • 提交:普通数字(18)

✅ 正确做法

// src/components/PlanFields/AgePicker.vue

// 显示格式化(转换为字符串,补齐3位)
const displayAge = computed(() => {
  return String(props.modelValue || 0).padStart(3, '0')
})

// 提交时转换为数字
const onConfirm = ({ value }) => {
  // value[0] 是 Picker 返回的数组,取出第一个值
  const ageValue = value[0] || 0

  // 发出数字值
  emit('update:modelValue', Number(ageValue))
}

关键要点

  • ✅ 显示和提交分开处理
  • ✅ 使用 padStart(3, '0') 格式化显示
  • ✅ 使用 Number() 转换提交值
  • ✅ 兼容 iOS 日期格式(将 - 替换为 /

💡 核心设计模式

1. 响应式表单数据同步

所有模板组件统一使用

import { reactive, watch } from 'vue'

const props = defineProps({
  modelValue: { type: Object, default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

// 使用 reactive 创建响应式表单
const form = reactive({
  ...props.modelValue,
  // 设置默认值
  field1: props.modelValue.field1 || '默认值'
})

// 监听表单变化,同步到父组件
watch(
  () => form,
  (newVal) => emit('update:modelValue', newVal),
  { deep: true }
)

关键要点

  • ✅ 使用 reactive 而非 ref(表单对象)
  • ✅ 使用 watch + deep: true 监听对象变化
  • ✅ 通过 emit('update:modelValue') 同步到父组件
  • ✅ 父组件使用 v-model 绑定

2. 动态组件加载

PlanFormContainer 中的实现

import { computed } from 'vue'
import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'

// 组件映射表
const componentMap = {
  'LifeInsuranceTemplate': LifeInsuranceTemplate,
  'CriticalIllnessTemplate': CriticalIllnessTemplate,
  'SavingsTemplate': SavingsTemplate
}

// 根据配置动态选择组件
const currentTemplateComponent = computed(() => {
  const componentName = templateConfig.value.component
  return componentMap[componentName] || null
})

关键要点

  • ✅ 使用 computed 动态计算组件
  • ✅ 使用组件映射表(对象)而非 if-else
  • ✅ 配置中的 component 字段与映射表 key 对应
  • ✅ 使用 <component :is="xxx"> 动态渲染

3. 配置驱动 + 后端覆盖

设计原则

  • 前端配置文件提供默认值
  • 后端 plan_config 可覆盖前端配置
// PlanFormContainer.vue
const templateConfig = computed(() => {
  // 从配置文件中查找
  const config = PLAN_TEMPLATES[props.product.form_sn]
  if (!config) return null

  // 合并配置:后端优先
  return {
    ...config,
    config: {
      ...config.config,                    // 前端默认配置
      ...(props.product.plan_config || {})  // 后端覆盖配置
    }
  }
})

关键要点

  • ✅ 前端配置作为后备(fallback)
  • ✅ 后端配置优先(通过展开运算符覆盖)
  • ✅ 支持部分覆盖(如只覆盖 currency,其他用默认值)

📋 完整使用流程

1. 在页面中使用

<script setup>
import { ref } from 'vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'

const showPlanPopup = ref(false)
const selectedProduct = ref(null)

const openPlanPopup = (product) => {
  selectedProduct.value = product
  showPlanPopup.value = true
}

const handleClose = () => {
  showPlanPopup.value = false
}

const handleSubmit = (formData) => {
  console.log('提交计划书:', formData)
  // 调用提交 API
  // submitPlanAPI({
  //   product_id: selectedProduct.value.id,
  //   form_sn: selectedProduct.value.form_sn,
  //   form_data: formData
  // })
}
</script>

<template>
  <button @click="openPlanPopup(product)">计划书</button>

  <PlanFormContainer
    v-model:visible="showPlanPopup"
    :product="selectedProduct"
    @close="handleClose"
    @submit="handleSubmit"
  />
</template>

2. 添加新产品配置

步骤

  1. 确认产品的 form_sn

    // 从产品 API 中查看
    {
     id: 10,
     product_name: "新产品",
     form_sn: "new-product-form-sn"  // ← 记住这个值
    }
    
  2. config/plan-templates.js 中添加配置

    export const PLAN_TEMPLATES = {
     // ... 其他配置
    
     'new-product-form-sn': {
       name: '新产品名称',
       component: 'NewProductTemplate',
       config: {
         currency: 'USD',
         payment_periods: ['5 年', '10 年'],
         age_range: { min: 0, max: 65 },
         insurance_period: '终身'
       }
     }
    }
    
  3. 创建模板组件

    <!-- src/components/PlanTemplates/NewProductTemplate.vue -->
    <template>
     <div v-if="config">
       <!-- 使用通用字段组件 -->
       <PlanFieldRadio v-model="form.gender" label="性别" :options="['男', '女']" />
       <!-- ... 其他字段 -->
     </div>
    </template>
    


4. **在 `PlanFormContainer.vue` 中导入**
   ```javascript
   import NewProductTemplate from './PlanTemplates/NewProductTemplate.vue'

   const componentMap = {
     // ... 其他组件
     'NewProductTemplate': NewProductTemplate
   }

🎯 最佳实践总结

1. 表单字段组件开发

必须遵循

  • ✅ 使用 v-model 双向绑定
  • ✅ 使用 <script setup> 语法
  • ✅ Props 必须有类型和默认值
  • ✅ Emits 必须定义事件名
  • ✅ 添加详细的 JSDoc 注释

示例

<script setup>
/**
 * 年龄选择器
 *
 * @description 使用 NutUI Picker 选择年龄,显示 3 位数字格式,提交普通数字
 * @author Claude Code
 * @example
 * <AgePicker
 *   v-model="age"
 *   label="年龄"
 *   placeholder="请选择年龄"
 * />
 */
import { computed } from 'vue'

const props = defineProps({
  /**
   * 年龄值(数字)
   * @type {number}
   * @default 0
   */
  modelValue: {
    type: Number,
    default: 0
  },

  /**
   * 标签文本
   * @type {string}
   */
  label: {
    type: String,
    required: true
  },

  /**
   * 占位符文本
   * @type {string}
   */
  placeholder: {
    type: String,
    default: ''
  }
})

const emit = defineEmits([
  /**
   * 更新年龄值事件
   * @event update:modelValue
   * @param {number} value - 年龄值
   */
  'update:modelValue'
])

// ... 组件逻辑
</script>

2. 模板组件开发

必须遵循

  • ✅ 只负责字段布局,不包含复杂逻辑
  • ✅ 使用通用字段组件组合
  • ✅ 表单数据统一管理在 form 对象中
  • ✅ 使用 watch 同步数据变化

❌ 避免

  • ❌ 在模板组件中直接调用 API
  • ❌ 在模板组件中处理提交逻辑
  • ❌ 过度复杂的条件渲染(超过3层)

3. 配置文件管理

必须遵循

  • ✅ 每个产品必须有唯一的 form_sn
  • ✅ 配置必须有默认值(后端不传时的后备)
  • ✅ 币种使用标准代码(USD/CNY/HKD/EUR)
  • ✅ 导出工具函数(getTemplateConfiggetCurrencySymbol

4. 响应式数据管理

必须遵循

  • ✅ 表单对象使用 reactive
  • ✅ 监听对象使用 deep: true
  • ✅ 删除属性使用 delete form.xxx(不是 form.xxx = undefined
  • ✅ 条件渲染使用 v-if 而非 v-show(表单字段)

🚨 常见陷阱

陷阱 1: 直接修改 props

❌ 错误

const props = defineProps({ modelValue: Object })
props.modelValue.field = 'value'  // 直接修改 props

✅ 正确

const form = reactive({ ...props.modelValue })
form.field = 'value'  // 修改响应式对象

// 通过 watch 同步到父组件
watch(() => form, (newVal) => emit('update:modelValue', newVal), { deep: true })

陷阱 2: 忘记清理字段

❌ 错误

// 切换模式时,旧字段仍然存在
watch(() => form.mode, (mode) => {
  if (mode === 'mode_a') {
    // 添加新字段
    form.field_a = 'value'
  }
  // 忘记删除 mode_b 的字段
})

✅ 正确

watch(() => form.mode, (mode) => {
  if (mode === 'mode_a') {
    form.field_a = 'value'
    delete form.field_b  // 清理无关字段
  } else if (mode === 'mode_b') {
    form.field_b = 'value'
    delete form.field_a  // 清理无关字段
  }
})

陷阱 3: 事件对象处理不一致

❌ 错误

const onInput = (e) => {
  // 直接使用 e,可能在 Web 和小程序中表现不同
  const value = e.value
}

✅ 正确

const onInput = (e) => {
  // 防御性提取值,兼容 Web 和小程序
  const value = e?.detail?.value || e?.target?.value || ''
  // 显式转换为字符串
  const valueStr = String(value)
}

陷阱 4: 日期计算不兼容 iOS

❌ 错误

const birthDate = new Date('1990-01-01')  // iOS 可能不支持

✅ 正确

const dateStr = birthday.replace(/-/g, '/')  // 转换为 1990/01/01
const birthDate = new Date(dateStr)
if (!Number.isNaN(birthDate.getTime())) {
  // 安全使用
}

📚 参考文档

项目文档

技术文档

经验教训


✅ 验收清单

功能完整性

  • 所有产品类型都能正确加载对应模板
  • 表单字段联动正常(出生日期 → 年龄)
  • 提取计划多层条件渲染正确
  • 表单数据同步正常(v-model)
  • 提交数据格式正确

代码质量

  • 所有组件都有 JSDoc 注释
  • 所有函数都有类型定义
  • 没有 console.logdebugger
  • 命名清晰,符合规范
  • 组件职责单一

测试覆盖

  • 测试不同产品的模板加载
  • 测试表单字段输入和验证
  • 测试字段联动逻辑
  • 测试提交流程
  • 测试边界情况(空值、异常值)

文档版本: v1.0 创建时间: 2026-02-06 最后更新: 2026-02-06