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 ...@@ -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 22 + />
23 - v-model="form.birthday" 23 + </div>
24 - label="出生年月日" 24 + </template>
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 - />
58 </div> 25 </div>
59 26
60 <!-- 配置缺失提示 --> 27 <!-- 配置缺失提示 -->
...@@ -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
184 */ 259 */
185 -const validate = () => { 260 +const onPercentageInput = (value, key) => {
186 - if (!form.customer_name || !form.customer_name.trim()) { 261 + // 转换为字符串(处理 value 为 null 或其他类型的情况)
187 - Taro.showToast({ title: '请输入申请人', icon: 'none' }) 262 + let strValue = String(value ?? '')
188 - return false 263 +
189 - } 264 + // 只保留数字和小数点
190 - if (!form.gender) { 265 + let cleaned = strValue.replace(/[^\d.]/g, '')
191 - Taro.showToast({ title: '请选择性别', icon: 'none' }) 266 +
192 - return false 267 + // 只保留一个小数点
193 - } 268 + const parts = cleaned.split('.')
194 - if (!form.birthday) { 269 + if (parts.length > 2) {
195 - Taro.showToast({ title: '请选择出生年月日', icon: 'none' }) 270 + cleaned = parts[0] + '.' + parts.slice(1).join('')
196 - return false
197 } 271 }
198 - if (!form.smoker) { 272 +
199 - Taro.showToast({ title: '请选择是否吸烟', icon: 'none' }) 273 + // 限制小数点后最多 2 位
200 - return false 274 + if (parts.length === 2 && parts[1].length > 2) {
275 + cleaned = parts[0] + '.' + parts[1].slice(0, 2)
201 } 276 }
202 - if (!form.coverage) { 277 +
203 - Taro.showToast({ title: '请输入保额', icon: 'none' }) 278 + // 限制范围:0-100
204 - return false 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 + }
205 } 286 }
206 - if (!form.payment_period) { 287 +
207 - Taro.showToast({ title: '请选择缴费年期', icon: 'none' }) 288 + form[key] = cleaned
208 - return false 289 +}
290 +
291 +/**
292 + * 表单校验(基于 Schema)
293 + * @returns {boolean} 校验是否通过
294 + */
295 +const validate = () => {
296 + const fields = [...baseFields.value]
297 +
298 + for (const field of fields) {
299 + if (!isFieldVisible(field)) {
300 + continue
301 + }
302 +
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' })
307 + return false
308 + }
309 + }
310 +
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' })
315 + return false
316 + }
317 + }
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 22 + />
23 - v-model="form.birthday" 23 + </div>
24 - label="出生年月日" 24 + </template>
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 - />
58 </div> 25 </div>
59 26
60 <!-- 配置缺失提示 --> 27 <!-- 配置缺失提示 -->
...@@ -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
186 */ 261 */
187 -const validate = () => { 262 +const onPercentageInput = (value, key) => {
188 - if (!form.customer_name || !form.customer_name.trim()) { 263 + // 转换为字符串(处理 value 为 null 或其他类型的情况)
189 - Taro.showToast({ title: '请输入申请人', icon: 'none' }) 264 + let strValue = String(value ?? '')
190 - return false 265 +
191 - } 266 + // 只保留数字和小数点
192 - if (!form.gender) { 267 + let cleaned = strValue.replace(/[^\d.]/g, '')
193 - Taro.showToast({ title: '请选择性别', icon: 'none' }) 268 +
194 - return false 269 + // 只保留一个小数点
195 - } 270 + const parts = cleaned.split('.')
196 - if (!form.birthday) { 271 + if (parts.length > 2) {
197 - Taro.showToast({ title: '请选择出生年月日', icon: 'none' }) 272 + cleaned = parts[0] + '.' + parts.slice(1).join('')
198 - return false
199 } 273 }
200 - if (!form.smoker) { 274 +
201 - Taro.showToast({ title: '请选择是否吸烟', icon: 'none' }) 275 + // 限制小数点后最多 2 位
202 - return false 276 + if (parts.length === 2 && parts[1].length > 2) {
277 + cleaned = parts[0] + '.' + parts[1].slice(0, 2)
203 } 278 }
204 - if (!form.coverage) { 279 +
205 - Taro.showToast({ title: '请输入保额', icon: 'none' }) 280 + // 限制范围:0-100
206 - return false 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 + }
207 } 288 }
208 - if (!form.payment_period) { 289 +
209 - Taro.showToast({ title: '请选择缴费年期', icon: 'none' }) 290 + form[key] = cleaned
210 - return false 291 +}
292 +
293 +/**
294 + * 表单校验(基于 Schema)
295 + * @returns {boolean} 校验是否通过
296 + */
297 +const validate = () => {
298 + const fields = [...baseFields.value]
299 +
300 + for (const field of fields) {
301 + if (!isFieldVisible(field)) {
302 + continue
303 + }
304 +
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' })
309 + return false
310 + }
311 + }
312 +
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' })
317 + return false
318 + }
319 + }
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 },
......