hookehuyr

feat(plan): 人寿/重疾模板 Schema 化

- 人寿/重疾模板改为 Schema 驱动渲染与校验
- 人寿/重疾产品配置增加 form_schema 入口
- 提取方式字段统一命名为 withdrawal_method
- 使用文档补充人寿/重疾示例
- README 同步最新更新

影响文件: plan-templates.js, LifeInsuranceTemplate.vue, CriticalIllnessTemplate.vue, plan-form-schema-usage.md, README.md, CHANGELOG.md
......@@ -54,6 +54,7 @@ pnpm lint
### 计划书表单演进
-**Schema 驱动** - 储蓄类模板字段由配置驱动渲染与校验
-**提交映射下沉** - 提交字段映射从容器迁移到模板配置
-**人寿/重疾同步** - 人寿与重疾模板改为 Schema 驱动
### 字段命名优化
-**提取方式字段** - 统一将 specified_amount_type 重命名为 withdrawal_method
......
## [2026-02-14] - 人寿/重疾模板Schema化
### 更新
- 人寿与重疾模板改为 Schema 驱动渲染与校验
- 人寿/重疾产品配置增加 form_schema 入口
- 使用文档补充人寿/重疾示例
- README 同步最新更新
---
**详细信息**
- **影响文件**: src/config/plan-templates.js, src/components/plan/PlanTemplates/LifeInsuranceTemplate.vue, src/components/plan/PlanTemplates/CriticalIllnessTemplate.vue, docs/plan/plan-form-schema-usage.md, README.md
- **技术栈**: Vue 3, Taro 4
- **测试状态**: 待测试
- **备注**: 保障类产品字段新增仅需调整 Schema 配置
---
## [2026-02-14] - 计划书Schema注释与使用文档
## [2026-02-14] - 计划书表单重构
......
......@@ -111,6 +111,24 @@ const template_config = {
</script>
```
## 8.1 人寿/重疾模板使用示例
```vue
<template>
<LifeInsuranceTemplate v-model="form_data" :config="template_config" />
</template>
<script setup>
const form_data = ref({})
const template_config = {
currency: 'USD',
payment_periods: ['整付(0-75 岁)', '5 年(0-70 岁)'],
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
</script>
```
## 9. 新增保险类型流程
1.`src/config/plan-templates.js` 新增产品项(配置 form_sn)
2. 为该产品选择已有模板组件或新增模板组件
......@@ -139,6 +157,20 @@ const template_config = {
}
```
```javascript
// 示例:新增人寿/重疾类产品配置
'life-insurance-new': {
name: '示例人寿产品',
component: 'LifeInsuranceTemplate',
config: {
currency: 'USD',
payment_periods: ['整付(0-75 岁)'],
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
}
```
## 11. 常见扩展点
- 新字段:仅在 form_schema 增加字段并补充 submit_mapping
- 新联动:在 show_when 与 reset_map 中定义条件
......
<template>
<div v-if="config">
<!-- 申请人 -->
<PlanFieldName
v-model="form.customer_name"
label="申请人"
placeholder="请输入申请人"
:required="true"
<template v-for="field in baseFields" :key="field.id || field.key">
<component
v-if="isFieldVisible(field) && field.type !== 'percentage'"
:is="getFieldComponent(field)"
v-model="form[field.key]"
v-bind="getFieldProps(field)"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
:required="true"
class="mb-5"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择年月日"
:required="true"
class="mb-5"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
:required="true"
class="mb-5"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:input-label="'请输入保额金额'"
:currency="config.currency"
:required="true"
class="mb-5"
/>
<!-- 缴费年期 - 单选形式 -->
<PaymentPeriodRadio
v-model="form.payment_period"
label="缴费年期"
:options="config.payment_periods"
:required="true"
class="mb-5"
<div v-else-if="isFieldVisible(field) && field.type === 'percentage'" class="mb-5">
<div class="text-sm text-gray-700 mb-2 flex items-center">
<span v-if="field.required" class="text-red-500 mr-1">*</span>
<span>{{ field.label }}</span>
</div>
<nut-input
v-model="form[field.key]"
type="digit"
:placeholder="field.placeholder"
@input="(value) => onPercentageInput(value, field.key)"
class="w-full"
/>
</div>
</template>
</div>
<!-- 配置缺失提示 -->
<div v-else class="text-center text-gray-500 py-10">
......@@ -77,7 +44,7 @@
* :config="templateConfig"
* />
*/
import { reactive, watch } from 'vue'
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
......@@ -105,6 +72,7 @@ const props = defineProps({
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
* @property {Object} form_schema - 表单 Schema
*/
config: {
type: Object,
......@@ -137,6 +105,110 @@ const form = reactive({})
let previousModelValue = null
// 字段类型与组件的对应关系
const fieldComponentMap = {
name: PlanFieldName,
radio: PlanFieldRadio,
date: PlanFieldDatePicker,
amount: PlanFieldAmount,
payment_period: PaymentPeriodRadio
}
// Schema 配置入口
const baseFields = computed(() => props.config?.form_schema?.base_fields || [])
/**
* 获取字段对应的渲染组件
* @param {Object} field - 字段配置
* @returns {Object|null} Vue 组件
*/
const getFieldComponent = (field) => {
return fieldComponentMap[field.type] || null
}
/**
* 组装字段渲染所需的 props
* @param {Object} field - 字段配置
* @returns {Object} 传入字段组件的 props
*/
const getFieldProps = (field) => {
const fieldProps = {
label: field.label,
placeholder: field.placeholder,
required: !!field.required
}
if (field.options) {
fieldProps.options = field.options
}
// 缴费年期选项由模板配置提供
if (field.options_from === 'payment_periods') {
fieldProps.options = fieldProps.options || props.config?.payment_periods
}
// 基础币种来自模板配置
if (field.currency_from === 'currency') {
fieldProps.currency = props.config?.currency
}
// 金额键盘的弹窗提示文本
if (field.input_label) {
fieldProps.inputLabel = field.input_label
}
return fieldProps
}
/**
* 判断字段是否可见
* @param {Object} field - 字段配置
* @returns {boolean} 是否显示
*/
const isFieldVisible = (field) => {
if (!field.show_when || field.show_when.length === 0) {
return true
}
return field.show_when.every(condition => {
return form[condition.field] === condition.equals
})
}
/**
* 获取 Schema 默认值
* @param {Object} value - 当前表单数据
* @returns {Object} 默认值集合
*/
const getSchemaDefaults = (value) => {
const defaults = {}
const fields = [...baseFields.value]
fields.forEach(field => {
if (field.default !== undefined && (value?.[field.key] === undefined || value?.[field.key] === null)) {
defaults[field.key] = field.default
}
})
return defaults
}
/**
* 初始化表单数据
* @param {Object} value - 初始数据
*/
const initializeForm = (value) => {
if (!value) {
Object.keys(form).forEach(key => delete form[key])
return
}
const defaults = getSchemaDefaults(value)
Object.assign(form, {
...value,
...defaults
})
}
// 监听父组件的数据变化
watch(
() => props.modelValue,
......@@ -155,58 +227,96 @@ watch(
if (isReset) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
initializeForm(newVal)
previousModelValue = newVal
} else {
// 正常更新:合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
// 正常更新:合并新字段,保留默认值逻辑
const defaults = getSchemaDefaults(newVal)
Object.assign(form, {
...newVal,
...defaults
})
previousModelValue = newVal
}
},
{ immediate: true }
{ immediate: true, deep: true }
)
/**
* 监听表单数据变化,同步到父组件
*/
// 监听表单数据变化,同步到父组件
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
form,
(newVal) => emit('update:modelValue', { ...newVal }),
{ deep: true }
)
/**
* 表单校验
* @returns {boolean} 是否通过校验
* 百分比输入清洗,避免非法字符
* @param {string|number} value - 输入值
* @param {string} key - 目标字段 key
*/
const onPercentageInput = (value, key) => {
// 转换为字符串(处理 value 为 null 或其他类型的情况)
let strValue = String(value ?? '')
// 只保留数字和小数点
let cleaned = strValue.replace(/[^\d.]/g, '')
// 只保留一个小数点
const parts = cleaned.split('.')
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('')
}
// 限制小数点后最多 2 位
if (parts.length === 2 && parts[1].length > 2) {
cleaned = parts[0] + '.' + parts[1].slice(0, 2)
}
// 限制范围:0-100
const numValue = parseFloat(cleaned)
if (!Number.isNaN(numValue)) {
if (numValue > 100) {
cleaned = '100'
} else if (numValue < 0) {
cleaned = '0'
}
}
form[key] = cleaned
}
/**
* 表单校验(基于 Schema)
* @returns {boolean} 校验是否通过
*/
const validate = () => {
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入申请人', icon: 'none' })
return false
const fields = [...baseFields.value]
for (const field of fields) {
if (!isFieldVisible(field)) {
continue
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
if (field.required) {
const value = form[field.key]
if (value === undefined || value === null || value === '') {
Taro.showToast({ title: field.label || '请完善必填信息', icon: 'none' })
return false
}
if (!form.birthday) {
Taro.showToast({ title: '请选择出生年月日', icon: 'none' })
return false
}
if (!form.smoker) {
Taro.showToast({ title: '请选择是否吸烟', icon: 'none' })
if (field.type === 'percentage' && isFieldVisible(field)) {
const percentage = parseFloat(form[field.key])
if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) {
Taro.showToast({ title: '请输入0-100之间的百分比', icon: 'none' })
return false
}
if (!form.coverage) {
Taro.showToast({ title: '请输入保额', icon: 'none' })
return false
}
if (!form.payment_period) {
Taro.showToast({ title: '请选择缴费年期', icon: 'none' })
return false
}
return true
}
......
<template>
<div v-if="config">
<!-- 申请人 -->
<PlanFieldName
v-model="form.customer_name"
label="申请人"
placeholder="请输入申请人"
:required="true"
<template v-for="field in baseFields" :key="field.id || field.key">
<component
v-if="isFieldVisible(field) && field.type !== 'percentage'"
:is="getFieldComponent(field)"
v-model="form[field.key]"
v-bind="getFieldProps(field)"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
:required="true"
class="mb-5"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择年月日"
:required="true"
class="mb-5"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
:required="true"
class="mb-5"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:input-label="'请输入保额金额'"
:currency="config.currency"
:required="true"
class="mb-5"
/>
<!-- 缴费年期 - 单选形式 -->
<PaymentPeriodRadio
v-model="form.payment_period"
label="缴费年期"
:options="config.payment_periods"
:required="true"
class="mb-5"
<div v-else-if="isFieldVisible(field) && field.type === 'percentage'" class="mb-5">
<div class="text-sm text-gray-700 mb-2 flex items-center">
<span v-if="field.required" class="text-red-500 mr-1">*</span>
<span>{{ field.label }}</span>
</div>
<nut-input
v-model="form[field.key]"
type="digit"
:placeholder="field.placeholder"
@input="(value) => onPercentageInput(value, field.key)"
class="w-full"
/>
</div>
</template>
</div>
<!-- 配置缺失提示 -->
<div v-else class="text-center text-gray-500 py-10">
......@@ -77,7 +44,7 @@
* :config="templateConfig"
* />
*/
import { reactive, watch, toRefs } from 'vue'
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
......@@ -105,6 +72,7 @@ const props = defineProps({
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
* @property {Object} form_schema - 表单 Schema
*/
config: {
type: Object,
......@@ -139,6 +107,110 @@ const form = reactive({})
let previousModelValue = null
// 字段类型与组件的对应关系
const fieldComponentMap = {
name: PlanFieldName,
radio: PlanFieldRadio,
date: PlanFieldDatePicker,
amount: PlanFieldAmount,
payment_period: PaymentPeriodRadio
}
// Schema 配置入口
const baseFields = computed(() => props.config?.form_schema?.base_fields || [])
/**
* 获取字段对应的渲染组件
* @param {Object} field - 字段配置
* @returns {Object|null} Vue 组件
*/
const getFieldComponent = (field) => {
return fieldComponentMap[field.type] || null
}
/**
* 组装字段渲染所需的 props
* @param {Object} field - 字段配置
* @returns {Object} 传入字段组件的 props
*/
const getFieldProps = (field) => {
const fieldProps = {
label: field.label,
placeholder: field.placeholder,
required: !!field.required
}
if (field.options) {
fieldProps.options = field.options
}
// 缴费年期选项由模板配置提供
if (field.options_from === 'payment_periods') {
fieldProps.options = fieldProps.options || props.config?.payment_periods
}
// 基础币种来自模板配置
if (field.currency_from === 'currency') {
fieldProps.currency = props.config?.currency
}
// 金额键盘的弹窗提示文本
if (field.input_label) {
fieldProps.inputLabel = field.input_label
}
return fieldProps
}
/**
* 判断字段是否可见
* @param {Object} field - 字段配置
* @returns {boolean} 是否显示
*/
const isFieldVisible = (field) => {
if (!field.show_when || field.show_when.length === 0) {
return true
}
return field.show_when.every(condition => {
return form[condition.field] === condition.equals
})
}
/**
* 获取 Schema 默认值
* @param {Object} value - 当前表单数据
* @returns {Object} 默认值集合
*/
const getSchemaDefaults = (value) => {
const defaults = {}
const fields = [...baseFields.value]
fields.forEach(field => {
if (field.default !== undefined && (value?.[field.key] === undefined || value?.[field.key] === null)) {
defaults[field.key] = field.default
}
})
return defaults
}
/**
* 初始化表单数据
* @param {Object} value - 初始数据
*/
const initializeForm = (value) => {
if (!value) {
Object.keys(form).forEach(key => delete form[key])
return
}
const defaults = getSchemaDefaults(value)
Object.assign(form, {
...value,
...defaults
})
}
// 监听父组件的数据变化
watch(
() => props.modelValue,
......@@ -157,58 +229,96 @@ watch(
if (isReset) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
initializeForm(newVal)
previousModelValue = newVal
} else {
// 正常更新:合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
// 正常更新:合并新字段,保留默认值逻辑
const defaults = getSchemaDefaults(newVal)
Object.assign(form, {
...newVal,
...defaults
})
previousModelValue = newVal
}
},
{ immediate: true }
{ immediate: true, deep: true }
)
/**
* 监听表单数据变化,同步到父组件
*/
// 监听表单数据变化,同步到父组件
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
form,
(newVal) => emit('update:modelValue', { ...newVal }),
{ deep: true }
)
/**
* 表单校验
* @returns {boolean} 是否通过校验
* 百分比输入清洗,避免非法字符
* @param {string|number} value - 输入值
* @param {string} key - 目标字段 key
*/
const onPercentageInput = (value, key) => {
// 转换为字符串(处理 value 为 null 或其他类型的情况)
let strValue = String(value ?? '')
// 只保留数字和小数点
let cleaned = strValue.replace(/[^\d.]/g, '')
// 只保留一个小数点
const parts = cleaned.split('.')
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('')
}
// 限制小数点后最多 2 位
if (parts.length === 2 && parts[1].length > 2) {
cleaned = parts[0] + '.' + parts[1].slice(0, 2)
}
// 限制范围:0-100
const numValue = parseFloat(cleaned)
if (!Number.isNaN(numValue)) {
if (numValue > 100) {
cleaned = '100'
} else if (numValue < 0) {
cleaned = '0'
}
}
form[key] = cleaned
}
/**
* 表单校验(基于 Schema)
* @returns {boolean} 校验是否通过
*/
const validate = () => {
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入申请人', icon: 'none' })
return false
const fields = [...baseFields.value]
for (const field of fields) {
if (!isFieldVisible(field)) {
continue
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
if (field.required) {
const value = form[field.key]
if (value === undefined || value === null || value === '') {
Taro.showToast({ title: field.label || '请完善必填信息', icon: 'none' })
return false
}
if (!form.birthday) {
Taro.showToast({ title: '请选择出生年月日', icon: 'none' })
return false
}
if (!form.smoker) {
Taro.showToast({ title: '请选择是否吸烟', icon: 'none' })
if (field.type === 'percentage' && isFieldVisible(field)) {
const percentage = parseFloat(form[field.key])
if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) {
Taro.showToast({ title: '请输入0-100之间的百分比', icon: 'none' })
return false
}
if (!form.coverage) {
Taro.showToast({ title: '请输入保额', icon: 'none' })
return false
}
if (!form.payment_period) {
Taro.showToast({ title: '请选择缴费年期', icon: 'none' })
return false
}
return true
}
......
......@@ -30,6 +30,18 @@ const baseSubmitMapping = {
total_amount: { api_field: 'total_premium', transform: 'fen_to_yuan' }
}
// 人寿/重疾基础表单 Schema(通用保障类)
const protectionFormSchema = {
base_fields: [
{ id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true },
{ id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true },
{ id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: true },
{ id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true },
{ id: 'coverage', key: 'coverage', type: 'amount', label: '保额', placeholder: '请输入保额', input_label: '请输入保额金额', required: true, currency_from: 'currency' },
{ id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' }
]
}
// 储蓄类提交字段映射(在基础映射上追加提取计划字段)
const savingsSubmitMapping = {
...baseSubmitMapping,
......@@ -89,6 +101,7 @@ export const PLAN_TEMPLATES = {
],
age_range: { min: 0, max: 75 }, // 年龄范围
insurance_period: '终身', // 保险期间
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -106,6 +119,7 @@ export const PLAN_TEMPLATES = {
],
age_range: { min: 0, max: 75 },
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -123,6 +137,7 @@ export const PLAN_TEMPLATES = {
],
age_range: { min: 0, max: 65 },
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -140,6 +155,7 @@ export const PLAN_TEMPLATES = {
],
age_range: { min: 0, max: 65 },
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -157,6 +173,7 @@ export const PLAN_TEMPLATES = {
],
age_range: { min: 0, max: 65 },
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......