hookehuyr

feat(plan): 年龄与出生年月日改为二选一填写

实现方案B:两个字段都显示,用户可任选其一填写
- 填年龄 → 自动推算生日(默认当年1月1日)
- 填生日 → 自动计算年龄
- 两个都填 → 以生日为准
- 两个都不填 → 校验提示"至少填写一项"

覆盖所有计划书模板:
- LifeInsuranceTemplate(人寿保险)
- CriticalIllnessTemplate(重疾保险)
- SavingsTemplate(储蓄型产品)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
...@@ -49,6 +49,7 @@ import Taro from '@tarojs/taro' ...@@ -49,6 +49,7 @@ import Taro from '@tarojs/taro'
49 import PlanFieldName from '../PlanFields/NameInput.vue' 49 import PlanFieldName from '../PlanFields/NameInput.vue'
50 import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue' 50 import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
51 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue' 51 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
52 +import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
52 import PlanFieldRadio from '../PlanFields/RadioGroup.vue' 53 import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
53 import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue' 54 import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
54 import { useFieldDependencies } from '@/composables/useFieldDependencies' 55 import { useFieldDependencies } from '@/composables/useFieldDependencies'
...@@ -111,6 +112,7 @@ const fieldComponentMap = { ...@@ -111,6 +112,7 @@ const fieldComponentMap = {
111 name: PlanFieldName, 112 name: PlanFieldName,
112 radio: PlanFieldRadio, 113 radio: PlanFieldRadio,
113 date: PlanFieldDatePicker, 114 date: PlanFieldDatePicker,
115 + age: PlanFieldAgePicker,
114 amount: PlanFieldAmount, 116 amount: PlanFieldAmount,
115 payment_period: PaymentPeriodRadio 117 payment_period: PaymentPeriodRadio
116 } 118 }
...@@ -248,6 +250,33 @@ watch( ...@@ -248,6 +250,33 @@ watch(
248 ) 250 )
249 251
250 /** 252 /**
253 + * 年龄与出生年月日自动计算逻辑
254 + * - 填年龄 → 推算生日(默认当年1月1日)
255 + * - 填生日 → 计算年龄
256 + */
257 +watch(
258 + () => form.age,
259 + (newAge) => {
260 + if (!isEmptyValue(newAge) && isEmptyValue(form.birthday)) {
261 + const currentYear = new Date().getFullYear()
262 + const birthYear = currentYear - parseInt(newAge)
263 + form.birthday = `${birthYear}-01-01`
264 + }
265 + }
266 +)
267 +
268 +watch(
269 + () => form.birthday,
270 + (newBirthday) => {
271 + if (!isEmptyValue(newBirthday)) {
272 + const birthYear = new Date(newBirthday).getFullYear()
273 + const currentYear = new Date().getFullYear()
274 + form.age = currentYear - birthYear
275 + }
276 + }
277 +)
278 +
279 +/**
251 * 百分比输入清洗,避免非法字符 280 * 百分比输入清洗,避免非法字符
252 * @param {string|number} value - 输入值 281 * @param {string|number} value - 输入值
253 * @param {string} key - 目标字段 key 282 * @param {string} key - 目标字段 key
...@@ -311,11 +340,30 @@ const isFieldRequired = (field) => { ...@@ -311,11 +340,30 @@ const isFieldRequired = (field) => {
311 const validate = () => { 340 const validate = () => {
312 const fields = [...baseFields.value] 341 const fields = [...baseFields.value]
313 342
343 + // 年龄与出生年月日二选一校验
344 + const hasAge = !isEmptyValue(form.age)
345 + const hasBirthday = !isEmptyValue(form.birthday)
346 +
347 + if (!hasAge && !hasBirthday) {
348 + Taro.showToast({ title: '年龄与出生年月日至少填写一项', icon: 'none' })
349 + return false
350 + }
351 +
352 + // 如果都填写了,以生日为准,重新计算年龄
353 + if (hasAge && hasBirthday) {
354 + const birthYear = new Date(form.birthday).getFullYear()
355 + const currentYear = new Date().getFullYear()
356 + form.age = currentYear - birthYear
357 + }
358 +
314 for (const field of fields) { 359 for (const field of fields) {
315 if (!isFieldVisible(field.key)) { 360 if (!isFieldVisible(field.key)) {
316 continue 361 continue
317 } 362 }
318 363
364 + // 跳过年龄字段的单独校验(已和生日一起校验)
365 + if (field.key === 'age') continue
366 +
319 if (isFieldRequired(field)) { 367 if (isFieldRequired(field)) {
320 const value = form[field.key] 368 const value = form[field.key]
321 if (isEmptyValue(value)) { 369 if (isEmptyValue(value)) {
......
...@@ -49,6 +49,7 @@ import Taro from '@tarojs/taro' ...@@ -49,6 +49,7 @@ import Taro from '@tarojs/taro'
49 import PlanFieldName from '../PlanFields/NameInput.vue' 49 import PlanFieldName from '../PlanFields/NameInput.vue'
50 import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue' 50 import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
51 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue' 51 import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
52 +import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
52 import PlanFieldRadio from '../PlanFields/RadioGroup.vue' 53 import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
53 import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue' 54 import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
54 import { useFieldDependencies } from '@/composables/useFieldDependencies' 55 import { useFieldDependencies } from '@/composables/useFieldDependencies'
...@@ -113,6 +114,7 @@ const fieldComponentMap = { ...@@ -113,6 +114,7 @@ const fieldComponentMap = {
113 name: PlanFieldName, 114 name: PlanFieldName,
114 radio: PlanFieldRadio, 115 radio: PlanFieldRadio,
115 date: PlanFieldDatePicker, 116 date: PlanFieldDatePicker,
117 + age: PlanFieldAgePicker,
116 amount: PlanFieldAmount, 118 amount: PlanFieldAmount,
117 payment_period: PaymentPeriodRadio 119 payment_period: PaymentPeriodRadio
118 } 120 }
...@@ -250,6 +252,35 @@ watch( ...@@ -250,6 +252,35 @@ watch(
250 ) 252 )
251 253
252 /** 254 /**
255 + * 年龄与出生年月日自动计算逻辑
256 + * - 填年龄 → 推算生日(默认当年1月1日)
257 + * - 填生日 → 计算年龄
258 + */
259 +watch(
260 + () => form.age,
261 + (newAge) => {
262 + if (!isEmptyValue(newAge) && isEmptyValue(form.birthday)) {
263 + // 填了年龄,推算生日(默认当年1月1日)
264 + const currentYear = new Date().getFullYear()
265 + const birthYear = currentYear - parseInt(newAge)
266 + form.birthday = `${birthYear}-01-01`
267 + }
268 + }
269 +)
270 +
271 +watch(
272 + () => form.birthday,
273 + (newBirthday) => {
274 + if (!isEmptyValue(newBirthday)) {
275 + // 填了生日,计算年龄
276 + const birthYear = new Date(newBirthday).getFullYear()
277 + const currentYear = new Date().getFullYear()
278 + form.age = currentYear - birthYear
279 + }
280 + }
281 +)
282 +
283 +/**
253 * 百分比输入清洗,避免非法字符 284 * 百分比输入清洗,避免非法字符
254 * @param {string|number} value - 输入值 285 * @param {string|number} value - 输入值
255 * @param {string} key - 目标字段 key 286 * @param {string} key - 目标字段 key
...@@ -313,11 +344,31 @@ const isFieldRequired = (field) => { ...@@ -313,11 +344,31 @@ const isFieldRequired = (field) => {
313 const validate = () => { 344 const validate = () => {
314 const fields = [...baseFields.value] 345 const fields = [...baseFields.value]
315 346
347 + // 年龄与出生年月日二选一校验
348 + const hasAge = !isEmptyValue(form.age)
349 + const hasBirthday = !isEmptyValue(form.birthday)
350 +
351 + if (!hasAge && !hasBirthday) {
352 + Taro.showToast({ title: '年龄与出生年月日至少填写一项', icon: 'none' })
353 + return false
354 + }
355 +
356 + // 如果都填写了,以生日为准,重新计算年龄
357 + if (hasAge && hasBirthday) {
358 + // 使用生日重新计算年龄
359 + const birthYear = new Date(form.birthday).getFullYear()
360 + const currentYear = new Date().getFullYear()
361 + form.age = String(currentYear - birthYear)
362 + }
363 +
316 for (const field of fields) { 364 for (const field of fields) {
317 if (!isFieldVisible(field.key)) { 365 if (!isFieldVisible(field.key)) {
318 continue 366 continue
319 } 367 }
320 368
369 + // 跳过年龄字段的单独校验(已和生日一起校验)
370 + if (field.key === 'age') continue
371 +
321 if (isFieldRequired(field)) { 372 if (isFieldRequired(field)) {
322 const value = form[field.key] 373 const value = form[field.key]
323 if (isEmptyValue(value)) { 374 if (isEmptyValue(value)) {
......
...@@ -332,6 +332,33 @@ watch( ...@@ -332,6 +332,33 @@ watch(
332 ) 332 )
333 333
334 /** 334 /**
335 + * 年龄与出生年月日自动计算逻辑
336 + * - 填年龄 → 推算生日(默认当年1月1日)
337 + * - 填生日 → 计算年龄
338 + */
339 +watch(
340 + () => form.age,
341 + (newAge) => {
342 + if (!isEmptyValue(newAge) && isEmptyValue(form.birthday)) {
343 + const currentYear = new Date().getFullYear()
344 + const birthYear = currentYear - parseInt(newAge)
345 + form.birthday = `${birthYear}-01-01`
346 + }
347 + }
348 +)
349 +
350 +watch(
351 + () => form.birthday,
352 + (newBirthday) => {
353 + if (!isEmptyValue(newBirthday)) {
354 + const birthYear = new Date(newBirthday).getFullYear()
355 + const currentYear = new Date().getFullYear()
356 + form.age = currentYear - birthYear
357 + }
358 + }
359 +)
360 +
361 +/**
335 * 提取年期选项(从配置读取) 362 * 提取年期选项(从配置读取)
336 * @type {ComputedRef<Array<string>>} 363 * @type {ComputedRef<Array<string>>}
337 */ 364 */
...@@ -409,11 +436,30 @@ const isFieldRequired = (field) => { ...@@ -409,11 +436,30 @@ const isFieldRequired = (field) => {
409 const validate = () => { 436 const validate = () => {
410 const fields = [...baseFields.value, ...(props.config.withdrawal_plan?.enabled ? withdrawalFields.value : [])] 437 const fields = [...baseFields.value, ...(props.config.withdrawal_plan?.enabled ? withdrawalFields.value : [])]
411 438
439 + // 年龄与出生年月日二选一校验
440 + const hasAge = !isEmptyValue(form.age)
441 + const hasBirthday = !isEmptyValue(form.birthday)
442 +
443 + if (!hasAge && !hasBirthday) {
444 + Taro.showToast({ title: '年龄与出生年月日至少填写一项', icon: 'none' })
445 + return false
446 + }
447 +
448 + // 如果都填写了,以生日为准,重新计算年龄
449 + if (hasAge && hasBirthday) {
450 + const birthYear = new Date(form.birthday).getFullYear()
451 + const currentYear = new Date().getFullYear()
452 + form.age = currentYear - birthYear
453 + }
454 +
412 for (const field of fields) { 455 for (const field of fields) {
413 if (!isFieldVisible(field.key)) { 456 if (!isFieldVisible(field.key)) {
414 continue 457 continue
415 } 458 }
416 459
460 + // 跳过年龄字段的单独校验(已和生日一起校验)
461 + if (field.key === 'age') continue
462 +
417 if (isFieldRequired(field)) { 463 if (isFieldRequired(field)) {
418 const value = form[field.key] 464 const value = form[field.key]
419 if (isEmptyValue(value)) { 465 if (isEmptyValue(value)) {
......
...@@ -38,6 +38,7 @@ ...@@ -38,6 +38,7 @@
38 const baseSubmitMapping = { 38 const baseSubmitMapping = {
39 customer_name: { api_field: 'customer_name' }, 39 customer_name: { api_field: 'customer_name' },
40 gender: { api_field: 'customer_gender' }, 40 gender: { api_field: 'customer_gender' },
41 + age: { api_field: 'customer_age' },
41 birthday: { api_field: 'customer_birthday' }, 42 birthday: { api_field: 'customer_birthday' },
42 smoker: { api_field: 'smoking_status' }, 43 smoker: { api_field: 'smoking_status' },
43 coverage: { api_field: 'annual_premium', transform: 'fen_to_yuan' }, 44 coverage: { api_field: 'annual_premium', transform: 'fen_to_yuan' },
...@@ -50,7 +51,9 @@ const protectionFormSchema = { ...@@ -50,7 +51,9 @@ const protectionFormSchema = {
50 base_fields: [ 51 base_fields: [
51 { id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true }, 52 { id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true },
52 { id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true }, 53 { id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true },
53 - { id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: true }, 54 + // 年龄与出生年月日二选一填写
55 + { id: 'age', key: 'age', type: 'age', label: '年龄', placeholder: '请输入年龄', input_label: '岁', required: false },
56 + { id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: false },
54 { id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true }, 57 { id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true },
55 { id: 'coverage', key: 'coverage', type: 'amount', label: '保额', placeholder: '请输入保额', input_label: '请输入保额金额', required: true, currency_from: 'currency' }, 58 { id: 'coverage', key: 'coverage', type: 'amount', label: '保额', placeholder: '请输入保额', input_label: '请输入保额金额', required: true, currency_from: 'currency' },
56 { id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' } 59 { id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' }
...@@ -73,12 +76,15 @@ const savingsSubmitMapping = { ...@@ -73,12 +76,15 @@ const savingsSubmitMapping = {
73 76
74 // 储蓄类表单 Schema(渲染 + 校验 + 联动的唯一入口) 77 // 储蓄类表单 Schema(渲染 + 校验 + 联动的唯一入口)
75 // @updated 2026-02-15 - 迁移到新的条件规则格式,使用 clear_when_hidden 替代 reset_map 78 // @updated 2026-02-15 - 迁移到新的条件规则格式,使用 clear_when_hidden 替代 reset_map
79 +// @updated 2026-02-25 - 年龄与出生年月日二选一填写
76 const savingsFormSchema = { 80 const savingsFormSchema = {
77 // 基础字段:非提取计划部分 81 // 基础字段:非提取计划部分
78 base_fields: [ 82 base_fields: [
79 { id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true }, 83 { id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true },
80 { id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true }, 84 { id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true },
81 - { id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: true }, 85 + // 年龄与出生年月日二选一填写
86 + { id: 'age', key: 'age', type: 'age', label: '年龄', placeholder: '请输入年龄', input_label: '岁', required: false },
87 + { id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: false },
82 { id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true }, 88 { id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true },
83 { id: 'coverage', key: 'coverage', type: 'amount', label: '年缴保费', placeholder: '请输入年缴保费', input_label: '请输入年缴保费金额', required: true, currency_from: 'currency' }, 89 { id: 'coverage', key: 'coverage', type: 'amount', label: '年缴保费', placeholder: '请输入年缴保费', input_label: '请输入年缴保费金额', required: true, currency_from: 'currency' },
84 { id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' } 90 { id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' }
......