plan-entry-module-summary.md
16.4 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.value或e.target.value提取值 - ✅ 使用
String()显式转换,避免类型错误 - ✅ 使用内部状态
inputValue分离显示值和模型值 - ✅ 仅在
@blur时进行严格格式化
问题 2: 提取计划字段结构错误(SavingsTemplate)
历史问题: 经过多次修正,最初实现的字段结构与需求不符。
最终正确的结构(小程序端):
// 第一层:是否启用提取计划
withdrawal_enabled: '是' | '否'
// 第二层:提取选项(仅当启用时显示)
withdrawal_mode: '指定提取金额' | '最高固定提取金额'
// 第三层:根据不同选项显示不同字段
if (withdrawal_mode === '指定提取金额') {
withdrawal_method: '按年岁'
if (withdrawal_method === '按年岁') {
withdrawal_start_age: number // 由几岁开始
withdrawal_period: string // 提取期(年)
increase_rate: string // 每年递增提取之百分比(%)
// ❌ 不需要:annual_amount(小程序端不需要此字段)
}
}
if (withdrawal_mode === '最高固定提取金额') {
withdrawal_start_age: number // 按年岁:由几岁开始
withdrawal_period: string // 提取期(年)
}
关键要点:
- ✅ 三层结构:启用确认 → 提取选项 → 具体字段
- ✅ 小程序端币种固定:使用配置中的
default_currency,不需要用户选择 - ✅ 字段按需显示:根据用户选择动态显示相关字段
- ✅ 自动清理无关字段:使用
watch监听变化,删除不相关字段
字段清理逻辑:
// 当切换提取模式时
watch(
() => form.withdrawal_mode,
(mode) => {
if (mode === '最高固定提取金额') {
// 清除指定金额相关字段
delete form.withdrawal_method
delete form.annual_amount
delete form.increase_rate
}
}
)
// 当切换指定金额类型时
watch(
() => form.withdrawal_method,
() => {
// 小程序端不需要这些字段
delete form.annual_amount
delete form.increase_rate
}
)
// 当关闭提取计划时
watch(
() => form.withdrawal_enabled,
(enabled) => {
if (enabled === '否') {
// 清除所有提取计划字段
delete form.withdrawal_mode
delete form.withdrawal_method
delete form.withdrawal_start_age
delete form.withdrawal_period
delete form.annual_amount
delete form.increase_rate
}
}
)
问题 3: 模板组件导入路径错误
症状:
Failed to resolve component: SavingsTemplate
原因:
PlanFormContainer.vue 中 SavingsTemplate 的导入路径错误
✅ 正确做法:
// ❌ 错误
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. 添加新产品配置
步骤:
-
确认产品的
form_sn// 从产品 API 中查看 { id: 10, product_name: "新产品", form_sn: "new-product-form-sn" // ← 记住这个值 } -
在
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: '终身' } } } -
创建模板组件
<!-- 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)
- ✅ 导出工具函数(
getTemplateConfig、getCurrencySymbol)
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.log或debugger - 命名清晰,符合规范
- 组件职责单一
测试覆盖
- 测试不同产品的模板加载
- 测试表单字段输入和验证
- 测试字段联动逻辑
- 测试提交流程
- 测试边界情况(空值、异常值)
文档版本: v1.0 创建时间: 2026-02-06 最后更新: 2026-02-06