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
Showing
6 changed files
with
446 additions
and
158 deletions
| ... | @@ -54,6 +54,7 @@ pnpm lint | ... | @@ -54,6 +54,7 @@ pnpm lint |
| 54 | ### 计划书表单演进 | 54 | ### 计划书表单演进 |
| 55 | - ✅ **Schema 驱动** - 储蓄类模板字段由配置驱动渲染与校验 | 55 | - ✅ **Schema 驱动** - 储蓄类模板字段由配置驱动渲染与校验 |
| 56 | - ✅ **提交映射下沉** - 提交字段映射从容器迁移到模板配置 | 56 | - ✅ **提交映射下沉** - 提交字段映射从容器迁移到模板配置 |
| 57 | +- ✅ **人寿/重疾同步** - 人寿与重疾模板改为 Schema 驱动 | ||
| 57 | 58 | ||
| 58 | ### 字段命名优化 | 59 | ### 字段命名优化 |
| 59 | - ✅ **提取方式字段** - 统一将 specified_amount_type 重命名为 withdrawal_method | 60 | - ✅ **提取方式字段** - 统一将 specified_amount_type 重命名为 withdrawal_method | ... | ... |
| 1 | +## [2026-02-14] - 人寿/重疾模板Schema化 | ||
| 2 | + | ||
| 3 | +### 更新 | ||
| 4 | +- 人寿与重疾模板改为 Schema 驱动渲染与校验 | ||
| 5 | +- 人寿/重疾产品配置增加 form_schema 入口 | ||
| 6 | +- 使用文档补充人寿/重疾示例 | ||
| 7 | +- README 同步最新更新 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +**详细信息**: | ||
| 12 | +- **影响文件**: 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 | ||
| 13 | +- **技术栈**: Vue 3, Taro 4 | ||
| 14 | +- **测试状态**: 待测试 | ||
| 15 | +- **备注**: 保障类产品字段新增仅需调整 Schema 配置 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 1 | ## [2026-02-14] - 计划书Schema注释与使用文档 | 19 | ## [2026-02-14] - 计划书Schema注释与使用文档 |
| 2 | ## [2026-02-14] - 计划书表单重构 | 20 | ## [2026-02-14] - 计划书表单重构 |
| 3 | 21 | ... | ... |
| ... | @@ -111,6 +111,24 @@ const template_config = { | ... | @@ -111,6 +111,24 @@ const template_config = { |
| 111 | </script> | 111 | </script> |
| 112 | ``` | 112 | ``` |
| 113 | 113 | ||
| 114 | +## 8.1 人寿/重疾模板使用示例 | ||
| 115 | +```vue | ||
| 116 | +<template> | ||
| 117 | + <LifeInsuranceTemplate v-model="form_data" :config="template_config" /> | ||
| 118 | +</template> | ||
| 119 | + | ||
| 120 | +<script setup> | ||
| 121 | +const form_data = ref({}) | ||
| 122 | + | ||
| 123 | +const template_config = { | ||
| 124 | + currency: 'USD', | ||
| 125 | + payment_periods: ['整付(0-75 岁)', '5 年(0-70 岁)'], | ||
| 126 | + form_schema: protectionFormSchema, | ||
| 127 | + submit_mapping: baseSubmitMapping | ||
| 128 | +} | ||
| 129 | +</script> | ||
| 130 | +``` | ||
| 131 | + | ||
| 114 | ## 9. 新增保险类型流程 | 132 | ## 9. 新增保险类型流程 |
| 115 | 1. 在 `src/config/plan-templates.js` 新增产品项(配置 form_sn) | 133 | 1. 在 `src/config/plan-templates.js` 新增产品项(配置 form_sn) |
| 116 | 2. 为该产品选择已有模板组件或新增模板组件 | 134 | 2. 为该产品选择已有模板组件或新增模板组件 |
| ... | @@ -139,6 +157,20 @@ const template_config = { | ... | @@ -139,6 +157,20 @@ const template_config = { |
| 139 | } | 157 | } |
| 140 | ``` | 158 | ``` |
| 141 | 159 | ||
| 160 | +```javascript | ||
| 161 | +// 示例:新增人寿/重疾类产品配置 | ||
| 162 | +'life-insurance-new': { | ||
| 163 | + name: '示例人寿产品', | ||
| 164 | + component: 'LifeInsuranceTemplate', | ||
| 165 | + config: { | ||
| 166 | + currency: 'USD', | ||
| 167 | + payment_periods: ['整付(0-75 岁)'], | ||
| 168 | + form_schema: protectionFormSchema, | ||
| 169 | + submit_mapping: baseSubmitMapping | ||
| 170 | + } | ||
| 171 | +} | ||
| 172 | +``` | ||
| 173 | + | ||
| 142 | ## 11. 常见扩展点 | 174 | ## 11. 常见扩展点 |
| 143 | - 新字段:仅在 form_schema 增加字段并补充 submit_mapping | 175 | - 新字段:仅在 form_schema 增加字段并补充 submit_mapping |
| 144 | - 新联动:在 show_when 与 reset_map 中定义条件 | 176 | - 新联动:在 show_when 与 reset_map 中定义条件 | ... | ... |
| 1 | <template> | 1 | <template> |
| 2 | <div v-if="config"> | 2 | <div v-if="config"> |
| 3 | - <!-- 申请人 --> | 3 | + <template v-for="field in baseFields" :key="field.id || field.key"> |
| 4 | - <PlanFieldName | 4 | + <component |
| 5 | - v-model="form.customer_name" | 5 | + v-if="isFieldVisible(field) && field.type !== 'percentage'" |
| 6 | - label="申请人" | 6 | + :is="getFieldComponent(field)" |
| 7 | - placeholder="请输入申请人" | 7 | + v-model="form[field.key]" |
| 8 | - :required="true" | 8 | + v-bind="getFieldProps(field)" |
| 9 | class="mb-5" | 9 | class="mb-5" |
| 10 | /> | 10 | /> |
| 11 | - | 11 | + <div v-else-if="isFieldVisible(field) && field.type === 'percentage'" class="mb-5"> |
| 12 | - <!-- 性别 --> | 12 | + <div class="text-sm text-gray-700 mb-2 flex items-center"> |
| 13 | - <PlanFieldRadio | 13 | + <span v-if="field.required" class="text-red-500 mr-1">*</span> |
| 14 | - v-model="form.gender" | 14 | + <span>{{ field.label }}</span> |
| 15 | - label="性别" | 15 | + </div> |
| 16 | - :options="['男', '女']" | 16 | + <nut-input |
| 17 | - :required="true" | 17 | + v-model="form[field.key]" |
| 18 | - class="mb-5" | 18 | + type="digit" |
| 19 | - /> | 19 | + :placeholder="field.placeholder" |
| 20 | - | 20 | + @input="(value) => onPercentageInput(value, field.key)" |
| 21 | - <!-- 出生年月日 --> | 21 | + class="w-full" |
| 22 | - <PlanFieldDatePicker | ||
| 23 | - v-model="form.birthday" | ||
| 24 | - label="出生年月日" | ||
| 25 | - placeholder="请选择年月日" | ||
| 26 | - :required="true" | ||
| 27 | - class="mb-5" | ||
| 28 | - /> | ||
| 29 | - | ||
| 30 | - <!-- 是否吸烟 --> | ||
| 31 | - <PlanFieldRadio | ||
| 32 | - v-model="form.smoker" | ||
| 33 | - label="是否吸烟" | ||
| 34 | - :options="['是', '否']" | ||
| 35 | - :required="true" | ||
| 36 | - class="mb-5" | ||
| 37 | - /> | ||
| 38 | - | ||
| 39 | - <!-- 保额 --> | ||
| 40 | - <PlanFieldAmount | ||
| 41 | - v-model="form.coverage" | ||
| 42 | - label="保额" | ||
| 43 | - placeholder="请输入保额" | ||
| 44 | - :input-label="'请输入保额金额'" | ||
| 45 | - :currency="config.currency" | ||
| 46 | - :required="true" | ||
| 47 | - class="mb-5" | ||
| 48 | - /> | ||
| 49 | - | ||
| 50 | - <!-- 缴费年期 - 单选形式 --> | ||
| 51 | - <PaymentPeriodRadio | ||
| 52 | - v-model="form.payment_period" | ||
| 53 | - label="缴费年期" | ||
| 54 | - :options="config.payment_periods" | ||
| 55 | - :required="true" | ||
| 56 | - class="mb-5" | ||
| 57 | /> | 22 | /> |
| 58 | </div> | 23 | </div> |
| 24 | + </template> | ||
| 25 | + </div> | ||
| 59 | 26 | ||
| 60 | <!-- 配置缺失提示 --> | 27 | <!-- 配置缺失提示 --> |
| 61 | <div v-else class="text-center text-gray-500 py-10"> | 28 | <div v-else class="text-center text-gray-500 py-10"> |
| ... | @@ -77,7 +44,7 @@ | ... | @@ -77,7 +44,7 @@ |
| 77 | * :config="templateConfig" | 44 | * :config="templateConfig" |
| 78 | * /> | 45 | * /> |
| 79 | */ | 46 | */ |
| 80 | -import { reactive, watch } from 'vue' | 47 | +import { reactive, watch, computed } from 'vue' |
| 81 | import Taro from '@tarojs/taro' | 48 | import Taro from '@tarojs/taro' |
| 82 | import PlanFieldName from '../PlanFields/NameInput.vue' | 49 | import PlanFieldName from '../PlanFields/NameInput.vue' |
| 83 | import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue' | 50 | import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue' |
| ... | @@ -105,6 +72,7 @@ const props = defineProps({ | ... | @@ -105,6 +72,7 @@ const props = defineProps({ |
| 105 | * @property {Array<string>} payment_periods - 缴费年期选项 | 72 | * @property {Array<string>} payment_periods - 缴费年期选项 |
| 106 | * @property {Object} age_range - 年龄范围 { min, max } | 73 | * @property {Object} age_range - 年龄范围 { min, max } |
| 107 | * @property {string} insurance_period - 保险期间 | 74 | * @property {string} insurance_period - 保险期间 |
| 75 | + * @property {Object} form_schema - 表单 Schema | ||
| 108 | */ | 76 | */ |
| 109 | config: { | 77 | config: { |
| 110 | type: Object, | 78 | type: Object, |
| ... | @@ -137,6 +105,110 @@ const form = reactive({}) | ... | @@ -137,6 +105,110 @@ const form = reactive({}) |
| 137 | 105 | ||
| 138 | let previousModelValue = null | 106 | let previousModelValue = null |
| 139 | 107 | ||
| 108 | +// 字段类型与组件的对应关系 | ||
| 109 | +const fieldComponentMap = { | ||
| 110 | + name: PlanFieldName, | ||
| 111 | + radio: PlanFieldRadio, | ||
| 112 | + date: PlanFieldDatePicker, | ||
| 113 | + amount: PlanFieldAmount, | ||
| 114 | + payment_period: PaymentPeriodRadio | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +// Schema 配置入口 | ||
| 118 | +const baseFields = computed(() => props.config?.form_schema?.base_fields || []) | ||
| 119 | + | ||
| 120 | +/** | ||
| 121 | + * 获取字段对应的渲染组件 | ||
| 122 | + * @param {Object} field - 字段配置 | ||
| 123 | + * @returns {Object|null} Vue 组件 | ||
| 124 | + */ | ||
| 125 | +const getFieldComponent = (field) => { | ||
| 126 | + return fieldComponentMap[field.type] || null | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +/** | ||
| 130 | + * 组装字段渲染所需的 props | ||
| 131 | + * @param {Object} field - 字段配置 | ||
| 132 | + * @returns {Object} 传入字段组件的 props | ||
| 133 | + */ | ||
| 134 | +const getFieldProps = (field) => { | ||
| 135 | + const fieldProps = { | ||
| 136 | + label: field.label, | ||
| 137 | + placeholder: field.placeholder, | ||
| 138 | + required: !!field.required | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + if (field.options) { | ||
| 142 | + fieldProps.options = field.options | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + // 缴费年期选项由模板配置提供 | ||
| 146 | + if (field.options_from === 'payment_periods') { | ||
| 147 | + fieldProps.options = fieldProps.options || props.config?.payment_periods | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + // 基础币种来自模板配置 | ||
| 151 | + if (field.currency_from === 'currency') { | ||
| 152 | + fieldProps.currency = props.config?.currency | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + // 金额键盘的弹窗提示文本 | ||
| 156 | + if (field.input_label) { | ||
| 157 | + fieldProps.inputLabel = field.input_label | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + return fieldProps | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +/** | ||
| 164 | + * 判断字段是否可见 | ||
| 165 | + * @param {Object} field - 字段配置 | ||
| 166 | + * @returns {boolean} 是否显示 | ||
| 167 | + */ | ||
| 168 | +const isFieldVisible = (field) => { | ||
| 169 | + if (!field.show_when || field.show_when.length === 0) { | ||
| 170 | + return true | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + return field.show_when.every(condition => { | ||
| 174 | + return form[condition.field] === condition.equals | ||
| 175 | + }) | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +/** | ||
| 179 | + * 获取 Schema 默认值 | ||
| 180 | + * @param {Object} value - 当前表单数据 | ||
| 181 | + * @returns {Object} 默认值集合 | ||
| 182 | + */ | ||
| 183 | +const getSchemaDefaults = (value) => { | ||
| 184 | + const defaults = {} | ||
| 185 | + const fields = [...baseFields.value] | ||
| 186 | + fields.forEach(field => { | ||
| 187 | + if (field.default !== undefined && (value?.[field.key] === undefined || value?.[field.key] === null)) { | ||
| 188 | + defaults[field.key] = field.default | ||
| 189 | + } | ||
| 190 | + }) | ||
| 191 | + return defaults | ||
| 192 | +} | ||
| 193 | + | ||
| 194 | +/** | ||
| 195 | + * 初始化表单数据 | ||
| 196 | + * @param {Object} value - 初始数据 | ||
| 197 | + */ | ||
| 198 | +const initializeForm = (value) => { | ||
| 199 | + if (!value) { | ||
| 200 | + Object.keys(form).forEach(key => delete form[key]) | ||
| 201 | + return | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + const defaults = getSchemaDefaults(value) | ||
| 205 | + | ||
| 206 | + Object.assign(form, { | ||
| 207 | + ...value, | ||
| 208 | + ...defaults | ||
| 209 | + }) | ||
| 210 | +} | ||
| 211 | + | ||
| 140 | // 监听父组件的数据变化 | 212 | // 监听父组件的数据变化 |
| 141 | watch( | 213 | watch( |
| 142 | () => props.modelValue, | 214 | () => props.modelValue, |
| ... | @@ -155,58 +227,96 @@ watch( | ... | @@ -155,58 +227,96 @@ watch( |
| 155 | 227 | ||
| 156 | if (isReset) { | 228 | if (isReset) { |
| 157 | // 父组件重置了:清空表单 | 229 | // 父组件重置了:清空表单 |
| 158 | - Object.keys(form).forEach(key => delete form[key]) | 230 | + initializeForm(newVal) |
| 159 | previousModelValue = newVal | 231 | previousModelValue = newVal |
| 160 | } else { | 232 | } else { |
| 161 | - // 正常更新:合并新字段,不删除已有字段 | 233 | + // 正常更新:合并新字段,保留默认值逻辑 |
| 162 | - // 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新 | 234 | + const defaults = getSchemaDefaults(newVal) |
| 163 | - Object.keys(newVal).forEach(key => { | 235 | + Object.assign(form, { |
| 164 | - form[key] = newVal[key] | 236 | + ...newVal, |
| 237 | + ...defaults | ||
| 165 | }) | 238 | }) |
| 166 | previousModelValue = newVal | 239 | previousModelValue = newVal |
| 167 | } | 240 | } |
| 168 | }, | 241 | }, |
| 169 | - { immediate: true } | 242 | + { immediate: true, deep: true } |
| 170 | ) | 243 | ) |
| 171 | 244 | ||
| 172 | /** | 245 | /** |
| 173 | * 监听表单数据变化,同步到父组件 | 246 | * 监听表单数据变化,同步到父组件 |
| 174 | */ | 247 | */ |
| 248 | +// 监听表单数据变化,同步到父组件 | ||
| 175 | watch( | 249 | watch( |
| 176 | - () => form, | 250 | + form, |
| 177 | - (newVal) => emit('update:modelValue', newVal), | 251 | + (newVal) => emit('update:modelValue', { ...newVal }), |
| 178 | { deep: true } | 252 | { deep: true } |
| 179 | ) | 253 | ) |
| 180 | 254 | ||
| 181 | /** | 255 | /** |
| 182 | - * 表单校验 | 256 | + * 百分比输入清洗,避免非法字符 |
| 183 | - * @returns {boolean} 是否通过校验 | 257 | + * @param {string|number} value - 输入值 |
| 258 | + * @param {string} key - 目标字段 key | ||
| 259 | + */ | ||
| 260 | +const onPercentageInput = (value, key) => { | ||
| 261 | + // 转换为字符串(处理 value 为 null 或其他类型的情况) | ||
| 262 | + let strValue = String(value ?? '') | ||
| 263 | + | ||
| 264 | + // 只保留数字和小数点 | ||
| 265 | + let cleaned = strValue.replace(/[^\d.]/g, '') | ||
| 266 | + | ||
| 267 | + // 只保留一个小数点 | ||
| 268 | + const parts = cleaned.split('.') | ||
| 269 | + if (parts.length > 2) { | ||
| 270 | + cleaned = parts[0] + '.' + parts.slice(1).join('') | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + // 限制小数点后最多 2 位 | ||
| 274 | + if (parts.length === 2 && parts[1].length > 2) { | ||
| 275 | + cleaned = parts[0] + '.' + parts[1].slice(0, 2) | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + // 限制范围:0-100 | ||
| 279 | + const numValue = parseFloat(cleaned) | ||
| 280 | + if (!Number.isNaN(numValue)) { | ||
| 281 | + if (numValue > 100) { | ||
| 282 | + cleaned = '100' | ||
| 283 | + } else if (numValue < 0) { | ||
| 284 | + cleaned = '0' | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + | ||
| 288 | + form[key] = cleaned | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +/** | ||
| 292 | + * 表单校验(基于 Schema) | ||
| 293 | + * @returns {boolean} 校验是否通过 | ||
| 184 | */ | 294 | */ |
| 185 | const validate = () => { | 295 | const validate = () => { |
| 186 | - if (!form.customer_name || !form.customer_name.trim()) { | 296 | + const fields = [...baseFields.value] |
| 187 | - Taro.showToast({ title: '请输入申请人', icon: 'none' }) | 297 | + |
| 188 | - return false | 298 | + for (const field of fields) { |
| 299 | + if (!isFieldVisible(field)) { | ||
| 300 | + continue | ||
| 189 | } | 301 | } |
| 190 | - if (!form.gender) { | 302 | + |
| 191 | - Taro.showToast({ title: '请选择性别', icon: 'none' }) | 303 | + if (field.required) { |
| 304 | + const value = form[field.key] | ||
| 305 | + if (value === undefined || value === null || value === '') { | ||
| 306 | + Taro.showToast({ title: field.label || '请完善必填信息', icon: 'none' }) | ||
| 192 | return false | 307 | return false |
| 193 | } | 308 | } |
| 194 | - if (!form.birthday) { | ||
| 195 | - Taro.showToast({ title: '请选择出生年月日', icon: 'none' }) | ||
| 196 | - return false | ||
| 197 | } | 309 | } |
| 198 | - if (!form.smoker) { | 310 | + |
| 199 | - Taro.showToast({ title: '请选择是否吸烟', icon: 'none' }) | 311 | + if (field.type === 'percentage' && isFieldVisible(field)) { |
| 312 | + const percentage = parseFloat(form[field.key]) | ||
| 313 | + if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) { | ||
| 314 | + Taro.showToast({ title: '请输入0-100之间的百分比', icon: 'none' }) | ||
| 200 | return false | 315 | return false |
| 201 | } | 316 | } |
| 202 | - if (!form.coverage) { | ||
| 203 | - Taro.showToast({ title: '请输入保额', icon: 'none' }) | ||
| 204 | - return false | ||
| 205 | } | 317 | } |
| 206 | - if (!form.payment_period) { | ||
| 207 | - Taro.showToast({ title: '请选择缴费年期', icon: 'none' }) | ||
| 208 | - return false | ||
| 209 | } | 318 | } |
| 319 | + | ||
| 210 | return true | 320 | return true |
| 211 | } | 321 | } |
| 212 | 322 | ... | ... |
| 1 | <template> | 1 | <template> |
| 2 | <div v-if="config"> | 2 | <div v-if="config"> |
| 3 | - <!-- 申请人 --> | 3 | + <template v-for="field in baseFields" :key="field.id || field.key"> |
| 4 | - <PlanFieldName | 4 | + <component |
| 5 | - v-model="form.customer_name" | 5 | + v-if="isFieldVisible(field) && field.type !== 'percentage'" |
| 6 | - label="申请人" | 6 | + :is="getFieldComponent(field)" |
| 7 | - placeholder="请输入申请人" | 7 | + v-model="form[field.key]" |
| 8 | - :required="true" | 8 | + v-bind="getFieldProps(field)" |
| 9 | class="mb-5" | 9 | class="mb-5" |
| 10 | /> | 10 | /> |
| 11 | - | 11 | + <div v-else-if="isFieldVisible(field) && field.type === 'percentage'" class="mb-5"> |
| 12 | - <!-- 性别 --> | 12 | + <div class="text-sm text-gray-700 mb-2 flex items-center"> |
| 13 | - <PlanFieldRadio | 13 | + <span v-if="field.required" class="text-red-500 mr-1">*</span> |
| 14 | - v-model="form.gender" | 14 | + <span>{{ field.label }}</span> |
| 15 | - label="性别" | 15 | + </div> |
| 16 | - :options="['男', '女']" | 16 | + <nut-input |
| 17 | - :required="true" | 17 | + v-model="form[field.key]" |
| 18 | - class="mb-5" | 18 | + type="digit" |
| 19 | - /> | 19 | + :placeholder="field.placeholder" |
| 20 | - | 20 | + @input="(value) => onPercentageInput(value, field.key)" |
| 21 | - <!-- 出生年月日 --> | 21 | + class="w-full" |
| 22 | - <PlanFieldDatePicker | ||
| 23 | - v-model="form.birthday" | ||
| 24 | - label="出生年月日" | ||
| 25 | - placeholder="请选择年月日" | ||
| 26 | - :required="true" | ||
| 27 | - class="mb-5" | ||
| 28 | - /> | ||
| 29 | - | ||
| 30 | - <!-- 是否吸烟 --> | ||
| 31 | - <PlanFieldRadio | ||
| 32 | - v-model="form.smoker" | ||
| 33 | - label="是否吸烟" | ||
| 34 | - :options="['是', '否']" | ||
| 35 | - :required="true" | ||
| 36 | - class="mb-5" | ||
| 37 | - /> | ||
| 38 | - | ||
| 39 | - <!-- 保额 --> | ||
| 40 | - <PlanFieldAmount | ||
| 41 | - v-model="form.coverage" | ||
| 42 | - label="保额" | ||
| 43 | - placeholder="请输入保额" | ||
| 44 | - :input-label="'请输入保额金额'" | ||
| 45 | - :currency="config.currency" | ||
| 46 | - :required="true" | ||
| 47 | - class="mb-5" | ||
| 48 | - /> | ||
| 49 | - | ||
| 50 | - <!-- 缴费年期 - 单选形式 --> | ||
| 51 | - <PaymentPeriodRadio | ||
| 52 | - v-model="form.payment_period" | ||
| 53 | - label="缴费年期" | ||
| 54 | - :options="config.payment_periods" | ||
| 55 | - :required="true" | ||
| 56 | - class="mb-5" | ||
| 57 | /> | 22 | /> |
| 58 | </div> | 23 | </div> |
| 24 | + </template> | ||
| 25 | + </div> | ||
| 59 | 26 | ||
| 60 | <!-- 配置缺失提示 --> | 27 | <!-- 配置缺失提示 --> |
| 61 | <div v-else class="text-center text-gray-500 py-10"> | 28 | <div v-else class="text-center text-gray-500 py-10"> |
| ... | @@ -77,7 +44,7 @@ | ... | @@ -77,7 +44,7 @@ |
| 77 | * :config="templateConfig" | 44 | * :config="templateConfig" |
| 78 | * /> | 45 | * /> |
| 79 | */ | 46 | */ |
| 80 | -import { reactive, watch, toRefs } from 'vue' | 47 | +import { reactive, watch, computed } from 'vue' |
| 81 | import Taro from '@tarojs/taro' | 48 | import Taro from '@tarojs/taro' |
| 82 | import PlanFieldName from '../PlanFields/NameInput.vue' | 49 | import PlanFieldName from '../PlanFields/NameInput.vue' |
| 83 | import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue' | 50 | import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue' |
| ... | @@ -105,6 +72,7 @@ const props = defineProps({ | ... | @@ -105,6 +72,7 @@ const props = defineProps({ |
| 105 | * @property {Array<string>} payment_periods - 缴费年期选项 | 72 | * @property {Array<string>} payment_periods - 缴费年期选项 |
| 106 | * @property {Object} age_range - 年龄范围 { min, max } | 73 | * @property {Object} age_range - 年龄范围 { min, max } |
| 107 | * @property {string} insurance_period - 保险期间 | 74 | * @property {string} insurance_period - 保险期间 |
| 75 | + * @property {Object} form_schema - 表单 Schema | ||
| 108 | */ | 76 | */ |
| 109 | config: { | 77 | config: { |
| 110 | type: Object, | 78 | type: Object, |
| ... | @@ -139,6 +107,110 @@ const form = reactive({}) | ... | @@ -139,6 +107,110 @@ const form = reactive({}) |
| 139 | 107 | ||
| 140 | let previousModelValue = null | 108 | let previousModelValue = null |
| 141 | 109 | ||
| 110 | +// 字段类型与组件的对应关系 | ||
| 111 | +const fieldComponentMap = { | ||
| 112 | + name: PlanFieldName, | ||
| 113 | + radio: PlanFieldRadio, | ||
| 114 | + date: PlanFieldDatePicker, | ||
| 115 | + amount: PlanFieldAmount, | ||
| 116 | + payment_period: PaymentPeriodRadio | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// Schema 配置入口 | ||
| 120 | +const baseFields = computed(() => props.config?.form_schema?.base_fields || []) | ||
| 121 | + | ||
| 122 | +/** | ||
| 123 | + * 获取字段对应的渲染组件 | ||
| 124 | + * @param {Object} field - 字段配置 | ||
| 125 | + * @returns {Object|null} Vue 组件 | ||
| 126 | + */ | ||
| 127 | +const getFieldComponent = (field) => { | ||
| 128 | + return fieldComponentMap[field.type] || null | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +/** | ||
| 132 | + * 组装字段渲染所需的 props | ||
| 133 | + * @param {Object} field - 字段配置 | ||
| 134 | + * @returns {Object} 传入字段组件的 props | ||
| 135 | + */ | ||
| 136 | +const getFieldProps = (field) => { | ||
| 137 | + const fieldProps = { | ||
| 138 | + label: field.label, | ||
| 139 | + placeholder: field.placeholder, | ||
| 140 | + required: !!field.required | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + if (field.options) { | ||
| 144 | + fieldProps.options = field.options | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + // 缴费年期选项由模板配置提供 | ||
| 148 | + if (field.options_from === 'payment_periods') { | ||
| 149 | + fieldProps.options = fieldProps.options || props.config?.payment_periods | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + // 基础币种来自模板配置 | ||
| 153 | + if (field.currency_from === 'currency') { | ||
| 154 | + fieldProps.currency = props.config?.currency | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + // 金额键盘的弹窗提示文本 | ||
| 158 | + if (field.input_label) { | ||
| 159 | + fieldProps.inputLabel = field.input_label | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + return fieldProps | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +/** | ||
| 166 | + * 判断字段是否可见 | ||
| 167 | + * @param {Object} field - 字段配置 | ||
| 168 | + * @returns {boolean} 是否显示 | ||
| 169 | + */ | ||
| 170 | +const isFieldVisible = (field) => { | ||
| 171 | + if (!field.show_when || field.show_when.length === 0) { | ||
| 172 | + return true | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + return field.show_when.every(condition => { | ||
| 176 | + return form[condition.field] === condition.equals | ||
| 177 | + }) | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +/** | ||
| 181 | + * 获取 Schema 默认值 | ||
| 182 | + * @param {Object} value - 当前表单数据 | ||
| 183 | + * @returns {Object} 默认值集合 | ||
| 184 | + */ | ||
| 185 | +const getSchemaDefaults = (value) => { | ||
| 186 | + const defaults = {} | ||
| 187 | + const fields = [...baseFields.value] | ||
| 188 | + fields.forEach(field => { | ||
| 189 | + if (field.default !== undefined && (value?.[field.key] === undefined || value?.[field.key] === null)) { | ||
| 190 | + defaults[field.key] = field.default | ||
| 191 | + } | ||
| 192 | + }) | ||
| 193 | + return defaults | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +/** | ||
| 197 | + * 初始化表单数据 | ||
| 198 | + * @param {Object} value - 初始数据 | ||
| 199 | + */ | ||
| 200 | +const initializeForm = (value) => { | ||
| 201 | + if (!value) { | ||
| 202 | + Object.keys(form).forEach(key => delete form[key]) | ||
| 203 | + return | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + const defaults = getSchemaDefaults(value) | ||
| 207 | + | ||
| 208 | + Object.assign(form, { | ||
| 209 | + ...value, | ||
| 210 | + ...defaults | ||
| 211 | + }) | ||
| 212 | +} | ||
| 213 | + | ||
| 142 | // 监听父组件的数据变化 | 214 | // 监听父组件的数据变化 |
| 143 | watch( | 215 | watch( |
| 144 | () => props.modelValue, | 216 | () => props.modelValue, |
| ... | @@ -157,58 +229,96 @@ watch( | ... | @@ -157,58 +229,96 @@ watch( |
| 157 | 229 | ||
| 158 | if (isReset) { | 230 | if (isReset) { |
| 159 | // 父组件重置了:清空表单 | 231 | // 父组件重置了:清空表单 |
| 160 | - Object.keys(form).forEach(key => delete form[key]) | 232 | + initializeForm(newVal) |
| 161 | previousModelValue = newVal | 233 | previousModelValue = newVal |
| 162 | } else { | 234 | } else { |
| 163 | - // 正常更新:合并新字段,不删除已有字段 | 235 | + // 正常更新:合并新字段,保留默认值逻辑 |
| 164 | - // 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新 | 236 | + const defaults = getSchemaDefaults(newVal) |
| 165 | - Object.keys(newVal).forEach(key => { | 237 | + Object.assign(form, { |
| 166 | - form[key] = newVal[key] | 238 | + ...newVal, |
| 239 | + ...defaults | ||
| 167 | }) | 240 | }) |
| 168 | previousModelValue = newVal | 241 | previousModelValue = newVal |
| 169 | } | 242 | } |
| 170 | }, | 243 | }, |
| 171 | - { immediate: true } | 244 | + { immediate: true, deep: true } |
| 172 | ) | 245 | ) |
| 173 | 246 | ||
| 174 | /** | 247 | /** |
| 175 | * 监听表单数据变化,同步到父组件 | 248 | * 监听表单数据变化,同步到父组件 |
| 176 | */ | 249 | */ |
| 250 | +// 监听表单数据变化,同步到父组件 | ||
| 177 | watch( | 251 | watch( |
| 178 | - () => form, | 252 | + form, |
| 179 | - (newVal) => emit('update:modelValue', newVal), | 253 | + (newVal) => emit('update:modelValue', { ...newVal }), |
| 180 | { deep: true } | 254 | { deep: true } |
| 181 | ) | 255 | ) |
| 182 | 256 | ||
| 183 | /** | 257 | /** |
| 184 | - * 表单校验 | 258 | + * 百分比输入清洗,避免非法字符 |
| 185 | - * @returns {boolean} 是否通过校验 | 259 | + * @param {string|number} value - 输入值 |
| 260 | + * @param {string} key - 目标字段 key | ||
| 261 | + */ | ||
| 262 | +const onPercentageInput = (value, key) => { | ||
| 263 | + // 转换为字符串(处理 value 为 null 或其他类型的情况) | ||
| 264 | + let strValue = String(value ?? '') | ||
| 265 | + | ||
| 266 | + // 只保留数字和小数点 | ||
| 267 | + let cleaned = strValue.replace(/[^\d.]/g, '') | ||
| 268 | + | ||
| 269 | + // 只保留一个小数点 | ||
| 270 | + const parts = cleaned.split('.') | ||
| 271 | + if (parts.length > 2) { | ||
| 272 | + cleaned = parts[0] + '.' + parts.slice(1).join('') | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + // 限制小数点后最多 2 位 | ||
| 276 | + if (parts.length === 2 && parts[1].length > 2) { | ||
| 277 | + cleaned = parts[0] + '.' + parts[1].slice(0, 2) | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + // 限制范围:0-100 | ||
| 281 | + const numValue = parseFloat(cleaned) | ||
| 282 | + if (!Number.isNaN(numValue)) { | ||
| 283 | + if (numValue > 100) { | ||
| 284 | + cleaned = '100' | ||
| 285 | + } else if (numValue < 0) { | ||
| 286 | + cleaned = '0' | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + form[key] = cleaned | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +/** | ||
| 294 | + * 表单校验(基于 Schema) | ||
| 295 | + * @returns {boolean} 校验是否通过 | ||
| 186 | */ | 296 | */ |
| 187 | const validate = () => { | 297 | const validate = () => { |
| 188 | - if (!form.customer_name || !form.customer_name.trim()) { | 298 | + const fields = [...baseFields.value] |
| 189 | - Taro.showToast({ title: '请输入申请人', icon: 'none' }) | 299 | + |
| 190 | - return false | 300 | + for (const field of fields) { |
| 301 | + if (!isFieldVisible(field)) { | ||
| 302 | + continue | ||
| 191 | } | 303 | } |
| 192 | - if (!form.gender) { | 304 | + |
| 193 | - Taro.showToast({ title: '请选择性别', icon: 'none' }) | 305 | + if (field.required) { |
| 306 | + const value = form[field.key] | ||
| 307 | + if (value === undefined || value === null || value === '') { | ||
| 308 | + Taro.showToast({ title: field.label || '请完善必填信息', icon: 'none' }) | ||
| 194 | return false | 309 | return false |
| 195 | } | 310 | } |
| 196 | - if (!form.birthday) { | ||
| 197 | - Taro.showToast({ title: '请选择出生年月日', icon: 'none' }) | ||
| 198 | - return false | ||
| 199 | } | 311 | } |
| 200 | - if (!form.smoker) { | 312 | + |
| 201 | - Taro.showToast({ title: '请选择是否吸烟', icon: 'none' }) | 313 | + if (field.type === 'percentage' && isFieldVisible(field)) { |
| 314 | + const percentage = parseFloat(form[field.key]) | ||
| 315 | + if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) { | ||
| 316 | + Taro.showToast({ title: '请输入0-100之间的百分比', icon: 'none' }) | ||
| 202 | return false | 317 | return false |
| 203 | } | 318 | } |
| 204 | - if (!form.coverage) { | ||
| 205 | - Taro.showToast({ title: '请输入保额', icon: 'none' }) | ||
| 206 | - return false | ||
| 207 | } | 319 | } |
| 208 | - if (!form.payment_period) { | ||
| 209 | - Taro.showToast({ title: '请选择缴费年期', icon: 'none' }) | ||
| 210 | - return false | ||
| 211 | } | 320 | } |
| 321 | + | ||
| 212 | return true | 322 | return true |
| 213 | } | 323 | } |
| 214 | 324 | ... | ... |
| ... | @@ -30,6 +30,18 @@ const baseSubmitMapping = { | ... | @@ -30,6 +30,18 @@ const baseSubmitMapping = { |
| 30 | total_amount: { api_field: 'total_premium', transform: 'fen_to_yuan' } | 30 | total_amount: { api_field: 'total_premium', transform: 'fen_to_yuan' } |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | +// 人寿/重疾基础表单 Schema(通用保障类) | ||
| 34 | +const protectionFormSchema = { | ||
| 35 | + base_fields: [ | ||
| 36 | + { id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true }, | ||
| 37 | + { id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true }, | ||
| 38 | + { id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: true }, | ||
| 39 | + { id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true }, | ||
| 40 | + { id: 'coverage', key: 'coverage', type: 'amount', label: '保额', placeholder: '请输入保额', input_label: '请输入保额金额', required: true, currency_from: 'currency' }, | ||
| 41 | + { id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' } | ||
| 42 | + ] | ||
| 43 | +} | ||
| 44 | + | ||
| 33 | // 储蓄类提交字段映射(在基础映射上追加提取计划字段) | 45 | // 储蓄类提交字段映射(在基础映射上追加提取计划字段) |
| 34 | const savingsSubmitMapping = { | 46 | const savingsSubmitMapping = { |
| 35 | ...baseSubmitMapping, | 47 | ...baseSubmitMapping, |
| ... | @@ -89,6 +101,7 @@ export const PLAN_TEMPLATES = { | ... | @@ -89,6 +101,7 @@ export const PLAN_TEMPLATES = { |
| 89 | ], | 101 | ], |
| 90 | age_range: { min: 0, max: 75 }, // 年龄范围 | 102 | age_range: { min: 0, max: 75 }, // 年龄范围 |
| 91 | insurance_period: '终身', // 保险期间 | 103 | insurance_period: '终身', // 保险期间 |
| 104 | + form_schema: protectionFormSchema, | ||
| 92 | submit_mapping: baseSubmitMapping | 105 | submit_mapping: baseSubmitMapping |
| 93 | } | 106 | } |
| 94 | }, | 107 | }, |
| ... | @@ -106,6 +119,7 @@ export const PLAN_TEMPLATES = { | ... | @@ -106,6 +119,7 @@ export const PLAN_TEMPLATES = { |
| 106 | ], | 119 | ], |
| 107 | age_range: { min: 0, max: 75 }, | 120 | age_range: { min: 0, max: 75 }, |
| 108 | insurance_period: '终身', | 121 | insurance_period: '终身', |
| 122 | + form_schema: protectionFormSchema, | ||
| 109 | submit_mapping: baseSubmitMapping | 123 | submit_mapping: baseSubmitMapping |
| 110 | } | 124 | } |
| 111 | }, | 125 | }, |
| ... | @@ -123,6 +137,7 @@ export const PLAN_TEMPLATES = { | ... | @@ -123,6 +137,7 @@ export const PLAN_TEMPLATES = { |
| 123 | ], | 137 | ], |
| 124 | age_range: { min: 0, max: 65 }, | 138 | age_range: { min: 0, max: 65 }, |
| 125 | insurance_period: '终身', | 139 | insurance_period: '终身', |
| 140 | + form_schema: protectionFormSchema, | ||
| 126 | submit_mapping: baseSubmitMapping | 141 | submit_mapping: baseSubmitMapping |
| 127 | } | 142 | } |
| 128 | }, | 143 | }, |
| ... | @@ -140,6 +155,7 @@ export const PLAN_TEMPLATES = { | ... | @@ -140,6 +155,7 @@ export const PLAN_TEMPLATES = { |
| 140 | ], | 155 | ], |
| 141 | age_range: { min: 0, max: 65 }, | 156 | age_range: { min: 0, max: 65 }, |
| 142 | insurance_period: '终身', | 157 | insurance_period: '终身', |
| 158 | + form_schema: protectionFormSchema, | ||
| 143 | submit_mapping: baseSubmitMapping | 159 | submit_mapping: baseSubmitMapping |
| 144 | } | 160 | } |
| 145 | }, | 161 | }, |
| ... | @@ -157,6 +173,7 @@ export const PLAN_TEMPLATES = { | ... | @@ -157,6 +173,7 @@ export const PLAN_TEMPLATES = { |
| 157 | ], | 173 | ], |
| 158 | age_range: { min: 0, max: 65 }, | 174 | age_range: { min: 0, max: 65 }, |
| 159 | insurance_period: '终身', | 175 | insurance_period: '终身', |
| 176 | + form_schema: protectionFormSchema, | ||
| 160 | submit_mapping: baseSubmitMapping | 177 | submit_mapping: baseSubmitMapping |
| 161 | } | 178 | } |
| 162 | }, | 179 | }, | ... | ... |
-
Please register or login to post a comment