CriticalIllnessTemplate.vue 8.4 KB
<template>
  <div v-if="config">
    <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"
      />
      <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">
    <p>⚠️ 模板配置未找到</p>
    <p class="text-sm mt-2">请检查产品配置或联系开发人员</p>
  </div>
</template>

<script setup>
/**
 * 重疾保险计划书模板
 *
 * @description MPC/MBC PRO/MBC2 等重疾保险产品的计划书录入表单
 *              - 表单字段:性别、出生年月日、是否吸烟、保额、缴费年期
 * @author Claude Code
 * @example
 * <CriticalIllnessTemplate
 *   v-model="formData"
 *   :config="templateConfig"
 * />
 */
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'

/**
 * 组件属性
 */
const props = defineProps({
  /**
   * 表单数据对象
   * @type {Object}
   */
  modelValue: {
    type: Object,
    default: () => ({})
  },

  /**
   * 模板配置
   * @type {Object}
   * @property {string} currency - 币种代码
   * @property {Array<string>} payment_periods - 缴费年期选项
   * @property {Object} age_range - 年龄范围 { min, max }
   * @property {string} insurance_period - 保险期间
   * @property {Object} form_schema - 表单 Schema
   */
  config: {
    type: Object,
    required: true
  }
})

/**
 * 组件事件
 */
const emit = defineEmits([
  /**
   * 更新表单数据事件
   * @event update:modelValue
   * @param {Object} value - 表单数据
   */
  'update:modelValue'
])

/**
 * 表单数据
 * @type {Object}
 *
 * ⚠️ 重要:处理父组件重置表单的情况
 * 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
 *
 * 解决方案:使用 watch 监听,但只在引用变化时才清空并复制
 */
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,
  (newVal) => {
    if (!newVal) {
      // null 或 undefined:清空
      Object.keys(form).forEach(key => delete form[key])
      previousModelValue = null
      return
    }

    // 判断是否是重置(从有数据变为空对象)
    const isReset = previousModelValue &&
                      Object.keys(previousModelValue).length > 0 &&
                      Object.keys(newVal).length === 0

    if (isReset) {
      // 父组件重置了:清空表单
      initializeForm(newVal)
      previousModelValue = newVal
    } else {
      // 正常更新:合并新字段,保留默认值逻辑
      const defaults = getSchemaDefaults(newVal)
      Object.assign(form, {
        ...newVal,
        ...defaults
      })
      previousModelValue = newVal
    }
  },
  { immediate: true, deep: true }
)

/**
 * 监听表单数据变化,同步到父组件
 */
// 监听表单数据变化,同步到父组件
watch(
  form,
  (newVal) => emit('update:modelValue', { ...newVal }),
  { deep: true }
)

/**
 * 百分比输入清洗,避免非法字符
 * @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 = () => {
  const fields = [...baseFields.value]

  for (const field of fields) {
    if (!isFieldVisible(field)) {
      continue
    }

    if (field.required) {
      const value = form[field.key]
      if (value === undefined || value === null || value === '') {
        Taro.showToast({ title: field.label || '请完善必填信息', icon: 'none' })
        return false
      }
    }

    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
      }
    }
  }

  return true
}

/**
 * 清除验证错误
 * @description 由于使用 Toast 显示错误,无需清除状态
 *              保留此方法以保持接口一致性
 */
const clearErrors = () => {
  // 当前使用 Toast 显示错误,无需清除错误状态
  // 如果将来改用内联错误提示,可以在这里清除错误状态
}

defineExpose({
  validate,
  clearErrors
})
</script>

<style lang="less" scoped>
/* 模板样式 */
</style>