hookehuyr

fix(plan): 修复计划书表单数据同步和重置问题

修复问题:
- 表单关闭后再次打开数据依然存在
- 第一次点击确认按钮没有值
- 输入过程中数据意外丢失

问题根因:
- Vue 3 v-model 每次更新都创建新对象
- reactive() 只在初始化时读取 props
- watch 监听策略不当导致数据丢失

解决方案:
- 区分"重置"和"正常更新"
- 重置判断:从有数据 → 空对象
- 正常更新:只合并新字段,不删除已有字段

影响文件:
- PlanFormContainer.vue - 父组件,使用 nextTick 延迟重置
- LifeInsuranceTemplate.vue - 子组件,优化 watch 策略
- CriticalIllnessTemplate.vue - 子组件,优化 watch 策略
- SavingsTemplate.vue - 子组件,优化 watch 策略

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -5,6 +5,69 @@
---
## [2026-02-08] - 修复计划书表单重置和数据同步问题
### 修复
- 修复计划书表单关闭后再次打开数据依然存在的bug
- 修复表单输入过程中数据意外丢失的问题
- 修复第一次点击确认按钮没有值的问题
### 优化
- 优化表单状态管理,区分"重置"和"正常更新"
- 改进 v-model 双向绑定的数据同步逻辑
### 问题原因
**Vue 3 v-model + reactive 的双向同步陷阱**
- v-model 每次更新都创建新对象
- `reactive(props.modelValue)` 只在初始化时读取一次 props
- 父子组件状态不同步
### 解决方案
**核心策略**:区分"重置"和"正常更新"
- 重置判断:从有数据 → 空对象
- 正常更新:只合并新字段,不删除已有字段
- 避免使用引用判断(`newVal !== previousModelValue`),因为 v-model 每次都创建新对象
### 技术细节
**关键改进**
```javascript
// ✅ 正确的 watch 策略
const isReset = previousModelValue &&
Object.keys(previousModelValue).length > 0 &&
Object.keys(newVal).length === 0
if (isReset) {
// 重置:清空表单
Object.keys(form).forEach(key => delete form[key])
} else {
// 正常更新:只合并新字段
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
})
}
```
**避免的陷阱**
- ❌ 每次 props 变化都清空并复制(导致数据丢失)
- ❌ 使用引用判断是否更新(v-model 每次创建新对象)
- ❌ 使用 `{ deep: true }` 监听 props(可能导致循环)
### 影响文件
- `src/components/PlanFormContainer.vue`
- `src/components/PlanTemplates/LifeInsuranceTemplate.vue`
- `src/components/PlanTemplates/CriticalIllnessTemplate.vue`
- `src/components/PlanTemplates/SavingsTemplate.vue`
### 测试验证
- ✅ 填写表单 → 关闭弹窗 → 再次打开 → 表单为空
- ✅ 第一次点击确认按钮 → 值能正常保存
- ✅ 年龄和出生年月日双向联动正常
### 经验教训
详见 `docs/lessons-learned.md` 中的"Vue 3 响应式数据和表单状态管理"章节。
---
## [2026-02-08] - 优化年龄与出生年月日联动逻辑
### 优化
......
......@@ -1137,6 +1137,102 @@ const fetchList = async (params) => {
---
## Vue 3 响应式数据和表单状态管理
### ❌ 坑:v-model 双向绑定导致表单数据丢失或重置失败
**场景**:用户填写表单后关闭弹窗(未提交),再次打开时数据依然存在,或者在输入过程中数据意外丢失。
**问题根因**:
1. **v-model 每次更新都创建新对象**
```javascript
// 父组件
const formData = ref({})
// v-model 更新时(子组件 emit)
formData.value = {age: 30} // ← 每次都是新对象!
```
2. **reactive() 只在初始化时读取 props**
```javascript
// 子组件
const form = reactive(props.modelValue || {})
// 问题:
// - 只在组件创建时读取一次 props.modelValue
// - 之后 props.modelValue 变化,form 不会自动更新
```
3. **watch 监听策略错误**
```javascript
// ❌ 错误:每次 props 变化都清空并复制
watch(() => props.modelValue, (newVal) => {
Object.keys(form).forEach(key => delete form[key])
Object.assign(form, newVal)
})
// 问题:
// - v-model 更新创建新对象,触发 watch
// - 清空操作导致用户输入丢失
// - 可能触发无限循环:form → emit → props → watch → form
```
**✅ 解决方案**:区分"重置"和"正常更新"
```javascript
// ✅ 正确:只在重置时清空,正常更新时只合并新字段
let previousModelValue = null
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) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
previousModelValue = newVal
} else {
// 正常更新:只合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
})
previousModelValue = newVal
}
},
{ immediate: true }
)
```
**关键要点**:
1. ✅ 不要用引用判断(`newVal !== previousModelValue`),因为 v-model 每次都创建新对象
2. ✅ 用内容判断:从有数据 → 空对象 = 重置
3. ✅ 正常更新时只合并新字段,保留已有字段
4. ✅ 不要用 `{ deep: true }` 监听 props(可能导致循环)
**涉及文件**:
- `src/components/PlanFormContainer.vue` - 父组件
- `src/components/PlanTemplates/LifeInsuranceTemplate.vue` - 子组件
- `src/components/PlanTemplates/CriticalIllnessTemplate.vue` - 子组件
- `src/components/PlanTemplates/SavingsTemplate.vue` - 子组件
**调试时间**:约 1.5 小时(3 次尝试)
**影响**:表单数据丢失、重置失败
---
## 架构设计
### ✅ 1. 统一的列表点击处理
......
......@@ -40,7 +40,7 @@
* @submit="handleSubmit"
* />
*/
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import PlanPopup from './PlanPopup/index.vue'
import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
......@@ -186,22 +186,41 @@ watch(
/**
* 关闭弹窗
* @description 关闭时重置表单数据,避免下次打开时保留旧数据
*
* ⚠️ 重要:必须使用 nextTick 延迟重置
* 原因:避免响应式更新时序问题,确保子组件完全卸载后再重置数据
*
* 时序问题示例:
* 1. close() → resetForm() → emit('close')
* 2. emit('close') → 父组件设置 visible = false
* 3. 子组件开始卸载(异步)
* 4. ⚠️ 如果在步骤3之前就重置,子组件可能还保留旧数据
*
* 解决方案:
* 1. 先触发关闭事件(让父组件更新 visible)
* 2. 等待 nextTick(确保 DOM 更新完成)
* 3. 再重置表单数据
*/
const close = () => {
console.log('[PlanFormContainer] 关闭弹窗,重置表单')
const close = async () => {
console.log('[PlanFormContainer] 关闭弹窗,准备重置表单')
// ⚠️ 关键:先触发关闭事件,让父组件更新 visible
emit('close')
// 等待 Vue 的响应式更新完成(确保子组件开始卸载)
await nextTick()
// 关闭前重置表单
// 现在重置表单,确保不会被子组件保留的引用覆盖
resetForm()
// 触发关闭事件
emit('close')
console.log('[PlanFormContainer] 弹窗已关闭,表单已重置')
}
/**
* 提交表单
* @description 将表单数据和产品信息一起提交
*/
const submit = () => {
const submit = async () => {
if (!props.product) {
console.error('[PlanFormContainer] 无法提交: 产品数据为空')
return
......@@ -229,7 +248,10 @@ const submit = () => {
form_data: formData.value
})
// ✅ 提交成功后立即重置表单,避免下次打开时保留旧数据
// ⚠️ 等待父组件处理提交事件(可能需要关闭弹窗)
await nextTick()
// 提交成功后重置表单,避免下次打开时保留旧数据
resetForm()
}
......
......@@ -130,8 +130,46 @@ const emit = defineEmits([
/**
* 表单数据
* @type {Object}
*
* ⚠️ 重要:处理父组件重置表单的情况
* 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
*
* 解决方案:使用 watch 监听,但只在引用变化时才清空并复制
*/
const form = reactive(props.modelValue || {})
const form = reactive({})
let previousModelValue = null
// 监听父组件的数据变化
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) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
previousModelValue = newVal
} else {
// 正常更新:合并新字段,不删除已有字段
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
})
previousModelValue = newVal
}
},
{ immediate: true }
)
/**
* 监听表单数据变化,同步到父组件
......
......@@ -130,8 +130,49 @@ const emit = defineEmits([
/**
* 表单数据
* @type {Object}
*
* ⚠️ 重要:处理父组件重置表单的情况
* 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
*
* 解决方案:使用 watch 监听,但只在重置时(空对象)才清空
* - 判断重置的标准:从有数据变为空对象
* - 用户输入时的更新:只合并新字段,不删除已有字段
*/
const form = reactive(props.modelValue || {})
const form = reactive({})
let previousModelValue = null
// 监听父组件的数据变化
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) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
previousModelValue = newVal
} else {
// 正常更新:合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
})
previousModelValue = newVal
}
},
{ immediate: true }
)
/**
* 监听表单数据变化,同步到父组件
......
......@@ -261,14 +261,66 @@ const emit = defineEmits([
/**
* 表单数据
* @type {Object}
*
* ⚠️ 重要:处理父组件重置表单的情况
* 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
*
* 解决方案:使用 watch 监听,但只在引用变化时才清空并复制
*/
const form = reactive({
...props.modelValue,
// 默认值
withdrawal_enabled: props.modelValue.withdrawal_enabled || '否',
withdrawal_mode: props.modelValue.withdrawal_mode || '指定提取金额',
specified_amount_type: props.modelValue.specified_amount_type || '按年岁'
})
const form = reactive({})
let previousModelValue = null
// 初始化默认值
const initializeForm = (value) => {
if (!value) {
Object.keys(form).forEach(key => delete form[key])
return
}
Object.assign(form, {
...value,
// 默认值
withdrawal_enabled: value.withdrawal_enabled || '否',
withdrawal_mode: value.withdrawal_mode || '指定提取金额',
specified_amount_type: value.specified_amount_type || '按年岁'
})
}
// 监听父组件的数据变化
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 {
// 正常更新:合并新字段,保留默认值逻辑
Object.assign(form, {
...newVal,
// 默认值
withdrawal_enabled: newVal.withdrawal_enabled || '否',
withdrawal_mode: newVal.withdrawal_mode || '指定提取金额',
specified_amount_type: newVal.specified_amount_type || '按年岁'
})
previousModelValue = newVal
}
},
{ immediate: true }
)
/**
* 监听表单数据变化,同步到父组件
......