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'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
import { useFieldDependencies } from '@/composables/useFieldDependencies'
......@@ -111,6 +112,7 @@ const fieldComponentMap = {
name: PlanFieldName,
radio: PlanFieldRadio,
date: PlanFieldDatePicker,
age: PlanFieldAgePicker,
amount: PlanFieldAmount,
payment_period: PaymentPeriodRadio
}
......@@ -248,6 +250,33 @@ watch(
)
/**
* 年龄与出生年月日自动计算逻辑
* - 填年龄 → 推算生日(默认当年1月1日)
* - 填生日 → 计算年龄
*/
watch(
() => form.age,
(newAge) => {
if (!isEmptyValue(newAge) && isEmptyValue(form.birthday)) {
const currentYear = new Date().getFullYear()
const birthYear = currentYear - parseInt(newAge)
form.birthday = `${birthYear}-01-01`
}
}
)
watch(
() => form.birthday,
(newBirthday) => {
if (!isEmptyValue(newBirthday)) {
const birthYear = new Date(newBirthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = currentYear - birthYear
}
}
)
/**
* 百分比输入清洗,避免非法字符
* @param {string|number} value - 输入值
* @param {string} key - 目标字段 key
......@@ -311,11 +340,30 @@ const isFieldRequired = (field) => {
const validate = () => {
const fields = [...baseFields.value]
// 年龄与出生年月日二选一校验
const hasAge = !isEmptyValue(form.age)
const hasBirthday = !isEmptyValue(form.birthday)
if (!hasAge && !hasBirthday) {
Taro.showToast({ title: '年龄与出生年月日至少填写一项', icon: 'none' })
return false
}
// 如果都填写了,以生日为准,重新计算年龄
if (hasAge && hasBirthday) {
const birthYear = new Date(form.birthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = currentYear - birthYear
}
for (const field of fields) {
if (!isFieldVisible(field.key)) {
continue
}
// 跳过年龄字段的单独校验(已和生日一起校验)
if (field.key === 'age') continue
if (isFieldRequired(field)) {
const value = form[field.key]
if (isEmptyValue(value)) {
......
......@@ -49,6 +49,7 @@ import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
import { useFieldDependencies } from '@/composables/useFieldDependencies'
......@@ -113,6 +114,7 @@ const fieldComponentMap = {
name: PlanFieldName,
radio: PlanFieldRadio,
date: PlanFieldDatePicker,
age: PlanFieldAgePicker,
amount: PlanFieldAmount,
payment_period: PaymentPeriodRadio
}
......@@ -250,6 +252,35 @@ watch(
)
/**
* 年龄与出生年月日自动计算逻辑
* - 填年龄 → 推算生日(默认当年1月1日)
* - 填生日 → 计算年龄
*/
watch(
() => form.age,
(newAge) => {
if (!isEmptyValue(newAge) && isEmptyValue(form.birthday)) {
// 填了年龄,推算生日(默认当年1月1日)
const currentYear = new Date().getFullYear()
const birthYear = currentYear - parseInt(newAge)
form.birthday = `${birthYear}-01-01`
}
}
)
watch(
() => form.birthday,
(newBirthday) => {
if (!isEmptyValue(newBirthday)) {
// 填了生日,计算年龄
const birthYear = new Date(newBirthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = currentYear - birthYear
}
}
)
/**
* 百分比输入清洗,避免非法字符
* @param {string|number} value - 输入值
* @param {string} key - 目标字段 key
......@@ -313,11 +344,31 @@ const isFieldRequired = (field) => {
const validate = () => {
const fields = [...baseFields.value]
// 年龄与出生年月日二选一校验
const hasAge = !isEmptyValue(form.age)
const hasBirthday = !isEmptyValue(form.birthday)
if (!hasAge && !hasBirthday) {
Taro.showToast({ title: '年龄与出生年月日至少填写一项', icon: 'none' })
return false
}
// 如果都填写了,以生日为准,重新计算年龄
if (hasAge && hasBirthday) {
// 使用生日重新计算年龄
const birthYear = new Date(form.birthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = String(currentYear - birthYear)
}
for (const field of fields) {
if (!isFieldVisible(field.key)) {
continue
}
// 跳过年龄字段的单独校验(已和生日一起校验)
if (field.key === 'age') continue
if (isFieldRequired(field)) {
const value = form[field.key]
if (isEmptyValue(value)) {
......
......@@ -332,6 +332,33 @@ watch(
)
/**
* 年龄与出生年月日自动计算逻辑
* - 填年龄 → 推算生日(默认当年1月1日)
* - 填生日 → 计算年龄
*/
watch(
() => form.age,
(newAge) => {
if (!isEmptyValue(newAge) && isEmptyValue(form.birthday)) {
const currentYear = new Date().getFullYear()
const birthYear = currentYear - parseInt(newAge)
form.birthday = `${birthYear}-01-01`
}
}
)
watch(
() => form.birthday,
(newBirthday) => {
if (!isEmptyValue(newBirthday)) {
const birthYear = new Date(newBirthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = currentYear - birthYear
}
}
)
/**
* 提取年期选项(从配置读取)
* @type {ComputedRef<Array<string>>}
*/
......@@ -409,11 +436,30 @@ const isFieldRequired = (field) => {
const validate = () => {
const fields = [...baseFields.value, ...(props.config.withdrawal_plan?.enabled ? withdrawalFields.value : [])]
// 年龄与出生年月日二选一校验
const hasAge = !isEmptyValue(form.age)
const hasBirthday = !isEmptyValue(form.birthday)
if (!hasAge && !hasBirthday) {
Taro.showToast({ title: '年龄与出生年月日至少填写一项', icon: 'none' })
return false
}
// 如果都填写了,以生日为准,重新计算年龄
if (hasAge && hasBirthday) {
const birthYear = new Date(form.birthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = currentYear - birthYear
}
for (const field of fields) {
if (!isFieldVisible(field.key)) {
continue
}
// 跳过年龄字段的单独校验(已和生日一起校验)
if (field.key === 'age') continue
if (isFieldRequired(field)) {
const value = form[field.key]
if (isEmptyValue(value)) {
......
......@@ -38,6 +38,7 @@
const baseSubmitMapping = {
customer_name: { api_field: 'customer_name' },
gender: { api_field: 'customer_gender' },
age: { api_field: 'customer_age' },
birthday: { api_field: 'customer_birthday' },
smoker: { api_field: 'smoking_status' },
coverage: { api_field: 'annual_premium', transform: 'fen_to_yuan' },
......@@ -50,7 +51,9 @@ 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: 'age', key: 'age', type: 'age', label: '年龄', placeholder: '请输入年龄', input_label: '岁', required: false },
{ id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: false },
{ 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' }
......@@ -73,12 +76,15 @@ const savingsSubmitMapping = {
// 储蓄类表单 Schema(渲染 + 校验 + 联动的唯一入口)
// @updated 2026-02-15 - 迁移到新的条件规则格式,使用 clear_when_hidden 替代 reset_map
// @updated 2026-02-25 - 年龄与出生年月日二选一填写
const savingsFormSchema = {
// 基础字段:非提取计划部分
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: 'age', key: 'age', type: 'age', label: '年龄', placeholder: '请输入年龄', input_label: '岁', required: false },
{ id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: false },
{ 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' }
......