PlanFormContainer.vue 10.2 KB
<template>
  <!-- 使用 PlanPopupNew 容器组件(支持全局弹窗管理器) -->
  <PlanPopupNew
    :visible="props.visible"
    :title="templateConfig?.name || '计划书'"
    :has-template="hasTemplate"
    @close="close"
    @submit="submit"
  >
    <!-- 动态加载模版组件 -->
    <component
      :is="currentTemplateComponent"
      ref="templateRef"
      v-model="formData"
      :config="templateConfig?.config"
      v-if="currentTemplateComponent && templateConfig?.config"
    />

    <!-- 错误提示 -->
    <div v-else class="text-center text-gray-500 py-10">
      <p>⚠️ 未找到对应的计划书模版</p>
      <p class="text-sm mt-2">form_sn: {{ product?.form_sn }}</p>
    </div>
  </PlanPopupNew>
</template>

<script setup>
/**
 * 计划书表单容器
 *
 * @description 根据产品的 form_sn 动态加载对应的计划书模版组件
 *              - 自动识别产品并加载模版
 *              - 支持后端 plan_config 动态配置
 *              - 统一的表单提交处理
 * @author Claude Code
 * @example
 * <PlanFormContainer
 *   v-model:visible="showPlanPopup"
 *   :product="selectedProduct"
 *   @close="handleClose"
 *   @submit="handleSubmit"
 * />
 */
import { ref, computed, watch, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import PlanPopupNew from './PlanPopupNew.vue'
import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'
import { PLAN_TEMPLATES } from '@/config/plan-templates'
import { addAPI } from '@/api/plan'

/**
 * 组件属性
 */
const props = defineProps({
  /**
   * 是否显示弹窗
   * @type {boolean}
   */
  visible: {
    type: Boolean,
    default: false
  },

  /**
   * 产品对象
   * @type {Object}
   * @property {number} id - 产品 ID
   * @property {string} product_name - 产品名称
   * @property {string} form_sn - 模版标识(必需)
   * @property {Object} plan_config - 模版配置(可选,后端返回)
   */
  product: {
    type: Object,
    required: false,
    default: null
  }
})

/**
 * 组件事件
 */
const emit = defineEmits([
  /**
   * 更新显示状态事件
   * @event update:visible
   * @param {boolean} value - 显示状态
   */
  'update:visible',

  /**
   * 关闭事件
   * @event close
   */
  'close',

  /**
   * 提交事件
   * @event submit
   * @param {Object} formData - 表单数据
   */
  'submit'
])

/**
 * 当前模版配置
 * @description 根据 form_sn 从配置文件中查找,并合并后端 plan_config
 *
 * @example
 * // product.form_sn = 'life-insurance-wiop3e'
 * // templateConfig() 返回: {
 * //   name: 'WIOP3E...',
 * //   component: 'LifeInsuranceTemplate',
 * //   config: { currency: 'USD', ... }
 * // }
 */
const templateConfig = computed(() => {
  if (!props.product) {
    return null
  }

  if (!props.product.form_sn) {
    console.warn('[PlanFormContainer] 产品缺少 form_sn 字段', props.product)
    return null
  }

  // 从配置文件中查找模版
  const config = PLAN_TEMPLATES[props.product.form_sn]
  if (!config) {
    console.error(`[PlanFormContainer] 未找到模版配置: ${props.product.form_sn}`)
    return null
  }

  // 合并配置:优先使用后端返回的 plan_config,否则使用配置文件中的默认配置
  return {
    ...config,
    config: {
      ...config.config,
      ...(props.product.plan_config || {})
    }
  }
})

/**
 * 当前模版组件
 * @description 根据 component 名称动态加载对应的组件
 */
const currentTemplateComponent = computed(() => {
  if (!templateConfig.value) {
    return null
  }

  const componentMap = {
    'LifeInsuranceTemplate': LifeInsuranceTemplate,
    'CriticalIllnessTemplate': CriticalIllnessTemplate,
    'SavingsTemplate': SavingsTemplate
  }

  const componentName = templateConfig.value.component
  return componentMap[componentName] || null
})

/**
 * 是否找到模板
 * @description 用于控制底部按钮显示(找到模板:取消/生成计划书;未找到:关闭)
 */
const hasTemplate = computed(() => {
  return currentTemplateComponent.value !== null && templateConfig.value?.config !== undefined
})

/**
 * 表单数据
 */
const formData = ref({})

/**
 * 模版组件引用
 */
const templateRef = ref(null)

/**
 * 监听显示状态变化,弹窗打开时确保表单是干净的
 * @description 这是最后的安全网,确保弹窗打开时表单一定是空的
 */
watch(
  () => props.visible,
  (newVal) => {
    if (newVal && Object.keys(formData.value).length > 0) {
      // 弹窗打开且表单有数据时,强制重置
      console.log('[PlanFormContainer] 弹窗打开时检测到残留数据,强制重置')
      resetForm()
    }
  }
)

/**
 * 关闭弹窗
 * @description 关闭时重置表单数据,避免下次打开时保留旧数据
 *
 * ⚠️ 重要:必须使用 nextTick 延迟重置
 * 原因:避免响应式更新时序问题,确保子组件完全卸载后再重置数据
 *
 * 时序问题示例:
 * 1. close() → resetForm() → emit('close')
 * 2. emit('close') → 父组件设置 visible = false
 * 3. 子组件开始卸载(异步)
 * 4. ⚠️ 如果在步骤3之前就重置,子组件可能还保留旧数据
 *
 * 解决方案:
 * 1. 先触发关闭事件(让父组件更新 visible)
 * 2. 等待 nextTick(确保 DOM 更新完成)
 * 3. 再重置表单数据
 */
const close = async () => {
  console.log('[PlanFormContainer] 关闭弹窗,准备重置表单')

  // ⚠️ 关键:先触发关闭事件,让父组件更新 visible
  emit('close')

  // 等待 Vue 的响应式更新完成(确保子组件开始卸载)
  await nextTick()

  // 现在重置表单,确保不会被子组件保留的引用覆盖
  resetForm()

  console.log('[PlanFormContainer] 弹窗已关闭,表单已重置')
}

// 提交表单 - 将表单数据和产品信息提交到后端 API
const submit = async () => {
  if (!props.product) {
    console.error('[PlanFormContainer] 无法提交: 产品数据为空')
    Taro.showToast({
      title: '产品数据为空',
      icon: 'none',
      duration: 2000
    })
    return false
  }

  // 调用模版组件的校验方法
  if (templateRef.value && templateRef.value.validate) {
    const isValid = templateRef.value.validate()
    if (!isValid) {
      return false
    }
  }

  // 显示加载提示
  Taro.showLoading({
    title: '提交中...',
    mask: true
  })

  try {
    // 字段名映射:将表单字段名映射为 API 期望的字段名
    // 根据 API 文档 (docs/api-specs/plan/add.md) 定义
    const fieldMapping = {
      customer_name: 'customer_name',        // 申请人(已直接使用)
      gender: 'customer_gender',             // 性别 → customer_gender
      birthday: 'customer_birthday',         // 出生年月日 → customer_birthday
      smoker: 'smoking_status',             // 是否吸烟 → smoking_status
      coverage: 'annual_premium',            // 保额/年缴保费 → annual_premium
      payment_period: 'payment_years',       // 缴费年期 → payment_years
      withdrawal_enabled: 'allow_reduce_amount',  // 是否容许减少名义金额
      withdrawal_mode: 'withdrawal_option',  // 提取选项
      withdrawal_start_age: 'withdrawal_start_age',  // 提取开始年龄
      withdrawal_period: 'withdrawal_period',       // 提取期
      currency_type: 'currency_type'        // 币种类型
    }

    // 构建请求数据
    const requestData = {
      product_id: props.product.id
    }

    // 映射表单字段到 API 字段
    Object.keys(formData.value).forEach(key => {
      const apiField = fieldMapping[key]

      if (apiField) {
        // 有映射:使用映射后的字段名
        requestData[apiField] = formData.value[key]
      } else if (key === 'total_amount') {
        // 特殊处理:总保费(分 → 元)
        requestData.total_premium = (formData.value[key] / 100).toFixed(2)
      } else {
        // 无映射:保持原字段名
        requestData[key] = formData.value[key]
      }
    })

    // 添加币种类型(如果有配置)
    if (templateConfig.value?.config?.currency) {
      requestData.currency_type = templateConfig.value.config.currency
    }

    console.log('[PlanFormContainer] 提交计划书请求数据:', requestData)
    console.log('[PlanFormContainer] 字段映射:', fieldMapping)

    // 调用 API
    const res = await addAPI(requestData)

    // 判断成功:既要 code === 1,也要有 order_id
    const isSuccess = res.code === 1 && res.data?.order_id

    if (isSuccess) {
      Taro.hideLoading()

      Taro.showToast({
        title: '提交成功',
        icon: 'success',
        duration: 2000
      })

      // 发送提交成功事件(携带 order_id)
      emit('submit', {
        success: true,
        order_id: res.data.order_id,
        product_id: props.product.id,
        form_sn: props.product.form_sn
      })

      return true
    } else {
      Taro.hideLoading()

      // 失败时,尝试从 res.data 或 res.msg 中获取错误信息
      const errorMsg = res.data?.msg || res.msg || '提交失败,请稍后重试'

      Taro.showToast({
        title: errorMsg,
        icon: 'none',
        duration: 2000
      })

      // 返回失败结果(不包含 order_id)
      emit('submit', {
        success: false
      })

      return false
    }
  } catch (error) {
    Taro.hideLoading()

    console.error('[PlanFormContainer] 提交计划书失败:', error)

    Taro.showToast({
      title: '网络异常,请重试',
      icon: 'none',
      duration: 2000
    })

    return false
  }

  // ✅ 不在这里重置表单,让父组件先处理数据
  // 重置逻辑交给 close() 函数处理(关闭弹窗时自动清空)
}

/**
 * 重置表单数据
 * @description 清空表单数据和错误状态
 */
const resetForm = () => {
  console.log('[PlanFormContainer] 重置表单数据')

  // 重置表单数据
  formData.value = {}

  // 重置子组件的验证状态(如果有)
  if (templateRef.value && templateRef.value.clearErrors) {
    templateRef.value.clearErrors()
  }
}
</script>

<style lang="less">
/* 容器样式 */
</style>