hookehuyr

feat(plan): 添加客户姓名输入字段到所有保险计划模板

- 新增 PlanFieldName(NameInput)组件用于输入客户姓名
- 在重疾险、寿险、储蓄险三个模板中集成客户姓名字段
- 添加客户姓名必填校验逻辑
- 优化 NameInput 组件样式,使用 Tailwind CSS 和圆角边框
- 重构计划书页面代码,减少代码重复

影响文件:
- src/components/plan/PlanFields/NameInput.vue (新增)
- src/components/plan/PlanTemplates/*.vue (修改)
- src/components/plan/PlanFormContainer.vue (重构)
- src/pages/plan/index.vue (重构)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -18,6 +18,7 @@ declare module 'vue' {
ListItemActions: typeof import('./src/components/list/ListItemActions/index.vue')['default']
LoadMoreList: typeof import('./src/components/list/LoadMoreList/index.vue')['default']
MaterialCard: typeof import('./src/components/cards/MaterialCard.vue')['default']
NameInput: typeof import('./src/components/plan/PlanFields/NameInput.vue')['default']
NavHeader: typeof import('./src/components/navigation/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
......
......@@ -5,6 +5,24 @@
---
## [2026-02-10] - 优化 NameInput 组件样式
### 优化
- **样式重构**:将 NameInput 组件从 Less 样式迁移到 Tailwind CSS
- 遵循项目 Tailwind 优先的开发规范
- 移除冗余的 Less 代码
- **UI 改进**
- 添加可见的圆角边框(border-gray-200, rounded-[12rpx]
- 统一输入框的视觉风格
**详细信息**
- **影响文件**: src/components/plan/PlanFields/NameInput.vue
- **技术栈**: Vue 3, Tailwind CSS
- **测试状态**: 待验证
- **备注**: 响应用户需求,增强输入框视觉反馈
---
## [2026-02-10] - 重构 API 接口层代码
### 重构
......
<!--
* @Date: 2026-02-10 14:06:03
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-02-10 14:18:58
* @FilePath: /manulife-weapp/src/components/plan/PlanFields/NameInput.vue
* @Description: 文件描述
-->
<template>
<!-- 姓名输入框 -->
<div class="mb-5">
<label v-if="label" class="block mb-[16rpx] text-sm text-gray-600">
<text v-if="required" class="text-red-500">*</text>
{{ label }}
</label>
<input
:value="modelValue"
:placeholder="placeholder"
type="text"
class="w-full h-[80rpx] px-[24rpx] text-[28rpx] text-[#333] rounded-[12rpx] border border-solid border-gray-200 box-border transition-all duration-300 focus:bg-white focus:border-[#4caf50] focus:outline-none placeholder:text-[#999]"
:class="{ 'text-[#333]': modelValue }"
@input="handleInput"
/>
</div>
</template>
<script setup>
/**
* 姓名输入框组件
*
* @description 计划书表单的姓名输入字段
* - 支持双向绑定
* - 支持必填标识
* - 清晰的视觉反馈
* @author Claude Code
* @example
* <PlanFieldName
* v-model="form.customer_name"
* label="客户姓名"
* placeholder="请输入客户姓名"
* :required="true"
* />
*/
const props = defineProps({
/**
* 字段值
* @type {string}
*/
modelValue: {
type: String,
default: ''
},
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请输入姓名'
},
/**
* 是否必填
* @type {boolean}
*/
required: {
type: Boolean,
default: false
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {string} value - 输入的值
*/
'update:modelValue'
])
/**
* 处理输入事件
* @param {Event} e - 输入事件对象
*/
const handleInput = (e) => {
const value = e.detail.value
emit('update:modelValue', value)
}
</script>
......@@ -42,11 +42,13 @@
* />
*/
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'
/**
* 组件属性
......@@ -251,44 +253,128 @@ const formatAmounts = (data) => {
/**
* 提交表单
* @description 将表单数据和产品信息一起提交
* @description 将表单数据和产品信息提交到后端 API
*
* ⚠️ 注意:接口未完成,当前仅为前端测试
* TODO: 后端接口准备就绪后,需要调用 submitPlanAPI 提交表单数据
* @returns {Promise<boolean>} 是否提交成功
*/
const submit = async () => {
if (!props.product) {
console.error('[PlanFormContainer] 无法提交: 产品数据为空')
return
Taro.showToast({
title: '产品数据为空',
icon: 'none',
duration: 2000
})
return false
}
// 调用模版组件的校验方法
if (templateRef.value && templateRef.value.validate) {
const isValid = templateRef.value.validate()
if (!isValid) {
return
return false
}
}
// 格式化金额数据(便于调试查看)
const formattedData = formatAmounts(formData.value)
// 显示加载提示
Taro.showLoading({
title: '提交中...',
mask: true
})
console.log('[PlanFormContainer] 提交计划书:', {
product_id: props.product.id,
product_name: props.product.product_name,
form_sn: props.product.form_sn,
form_data: formattedData // ← 打印格式化后的数据(元)
try {
// 字段名映射:将表单字段名映射为 API 期望的字段名
const fieldMapping = {
customer_name: 'customer_name', // 客户姓名(已直接使用)
gender: 'customer_gender', // 性别 → customer_gender
age: 'customer_age', // 年龄 → customer_age
birthday: 'customer_birthday', // 出生年月日 → customer_birthday
smoker: 'smoker', // 是否吸烟(保持不变)
coverage: 'annual_premium', // 保额/年缴保费 → annual_premium
payment_period: 'payment_years', // 缴费年期 → payment_years
withdrawal_enabled: 'allow_reduce_amount', // 是否容许减少名义金额
withdrawal_mode: 'withdrawal_option', // 提取选项
specified_amount_type: null, // 提取方式(后端可能不需要)
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]
}
})
console.log('[PlanFormContainer] 原始数据(分):', formData.value)
// 添加币种类型(如果有配置)
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)
if (res.code === 1) {
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,
form_data: formData.value // ← 发送原始数据(分),接口需要
form_sn: props.product.form_sn
})
return true
} else {
Taro.hideLoading()
Taro.showToast({
title: res.msg || '提交失败',
icon: 'none',
duration: 2000
})
return false
}
} catch (error) {
Taro.hideLoading()
console.error('[PlanFormContainer] 提交计划书失败:', error)
Taro.showToast({
title: '网络异常,请重试',
icon: 'none',
duration: 2000
})
return false
}
// ✅ 不在这里重置表单,让父组件先处理数据
// 重置逻辑交给 close() 函数处理(关闭弹窗时自动清空)
}
......
<template>
<div v-if="config">
<!-- 客户姓名 -->
<PlanFieldName
v-model="form.customer_name"
label="客户姓名"
placeholder="请输入客户姓名"
:required="true"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
......@@ -82,6 +91,7 @@
*/
import { reactive, watch } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
......@@ -228,6 +238,10 @@ const onBirthdayChange = (birthday) => {
* @returns {boolean} 是否通过校验
*/
const validate = () => {
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入客户姓名', icon: 'none' })
return false
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
return false
......
<template>
<div v-if="config">
<!-- 客户姓名 -->
<PlanFieldName
v-model="form.customer_name"
label="客户姓名"
placeholder="请输入客户姓名"
:required="true"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
......@@ -82,6 +91,7 @@
*/
import { reactive, watch, toRefs } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
......@@ -231,6 +241,10 @@ const onBirthdayChange = (birthday) => {
* @returns {boolean} 是否通过校验
*/
const validate = () => {
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入客户姓名', icon: 'none' })
return false
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
return false
......
<template>
<div v-if="config">
<!-- 客户姓名 -->
<PlanFieldName
v-model="form.customer_name"
label="客户姓名"
placeholder="请输入客户姓名"
:required="true"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
......@@ -207,6 +216,7 @@
*/
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAgePicker from '../PlanFields/AgePickerGlobal.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
......@@ -457,6 +467,10 @@ watch(
*/
const validate = () => {
// 基础字段校验
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入客户姓名', icon: 'none' })
return false
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
return false
......
......@@ -121,7 +121,6 @@ import MaterialCard from '@/components/cards/MaterialCard.vue';
import { listAPI } from '@/api/get_product';
import { weekHotAPI } from '@/api/file';
import { homeIconAPI } from '@/api/home';
import { mockHotProductsListAPI } from '@/api/mock/hotProducts';
// User Store
......@@ -246,41 +245,29 @@ const fetchHomeIcons = async () => {
const hotProducts = ref([]);
/**
* Mock 数据开关
* @description 开发环境默认使用 mock 数据测试计划书功能
* 生产环境必须设置为 false
*/
const USE_MOCK_DATA = process.env.NODE_ENV === 'development'; // 开发环境默认使用 mock 数据
/**
* 获取热卖产品列表
*
* @description 根据 USE_MOCK_DATA 开关决定使用 mock 数据还是调用 API
* - 开发环境使用 mock 数据测试计划书功能
* - 生产环境调用 listAPI 获取真实数据
* @description 调用 listAPI 获取真实的热卖产品数据
*/
const fetchHotProducts = async () => {
try {
// 开发测试环境:使用 mock 数据
if (USE_MOCK_DATA) {
console.log('[Index] 使用 mock 数据获取热卖产品');
const res = await mockHotProductsListAPI({ recommend: 'hot' });
if (res.code === 1 && res.data && res.data.list) {
hotProducts.value = res.data.list;
console.log('[Index] Mock 数据加载成功,产品数量:', res.data.list.length);
}
return;
}
console.log('[Index] 获取热卖产品');
// 生产环境:调用真实 API
// 调用真实 API
const res = await listAPI({
recommend: 'hot'
});
if (res.code === 1 && res.data && res.data.list) {
hotProducts.value = res.data.list;
console.log('[Index] 热卖产品加载成功,数量:', res.data.list.length);
} else {
console.warn('[Index] 热卖产品返回数据格式不正确:', res);
hotProducts.value = [];
}
} catch (err) {
console.error('获取热卖产品失败:', err);
console.error('[Index] 获取热卖产品失败:', err);
hotProducts.value = [];
}
};
......
This diff is collapsed. Click to expand it.