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 @@ ...@@ -5,6 +5,69 @@
5 5
6 --- 6 ---
7 7
8 +## [2026-02-08] - 修复计划书表单重置和数据同步问题
9 +
10 +### 修复
11 +- 修复计划书表单关闭后再次打开数据依然存在的bug
12 +- 修复表单输入过程中数据意外丢失的问题
13 +- 修复第一次点击确认按钮没有值的问题
14 +
15 +### 优化
16 +- 优化表单状态管理,区分"重置"和"正常更新"
17 +- 改进 v-model 双向绑定的数据同步逻辑
18 +
19 +### 问题原因
20 +**Vue 3 v-model + reactive 的双向同步陷阱**
21 +- v-model 每次更新都创建新对象
22 +- `reactive(props.modelValue)` 只在初始化时读取一次 props
23 +- 父子组件状态不同步
24 +
25 +### 解决方案
26 +**核心策略**:区分"重置"和"正常更新"
27 +- 重置判断:从有数据 → 空对象
28 +- 正常更新:只合并新字段,不删除已有字段
29 +- 避免使用引用判断(`newVal !== previousModelValue`),因为 v-model 每次都创建新对象
30 +
31 +### 技术细节
32 +**关键改进**
33 +```javascript
34 +// ✅ 正确的 watch 策略
35 +const isReset = previousModelValue &&
36 + Object.keys(previousModelValue).length > 0 &&
37 + Object.keys(newVal).length === 0
38 +
39 +if (isReset) {
40 + // 重置:清空表单
41 + Object.keys(form).forEach(key => delete form[key])
42 +} else {
43 + // 正常更新:只合并新字段
44 + Object.keys(newVal).forEach(key => {
45 + form[key] = newVal[key]
46 + })
47 +}
48 +```
49 +
50 +**避免的陷阱**
51 +- ❌ 每次 props 变化都清空并复制(导致数据丢失)
52 +- ❌ 使用引用判断是否更新(v-model 每次创建新对象)
53 +- ❌ 使用 `{ deep: true }` 监听 props(可能导致循环)
54 +
55 +### 影响文件
56 +- `src/components/PlanFormContainer.vue`
57 +- `src/components/PlanTemplates/LifeInsuranceTemplate.vue`
58 +- `src/components/PlanTemplates/CriticalIllnessTemplate.vue`
59 +- `src/components/PlanTemplates/SavingsTemplate.vue`
60 +
61 +### 测试验证
62 +- ✅ 填写表单 → 关闭弹窗 → 再次打开 → 表单为空
63 +- ✅ 第一次点击确认按钮 → 值能正常保存
64 +- ✅ 年龄和出生年月日双向联动正常
65 +
66 +### 经验教训
67 +详见 `docs/lessons-learned.md` 中的"Vue 3 响应式数据和表单状态管理"章节。
68 +
69 +---
70 +
8 ## [2026-02-08] - 优化年龄与出生年月日联动逻辑 71 ## [2026-02-08] - 优化年龄与出生年月日联动逻辑
9 72
10 ### 优化 73 ### 优化
......
...@@ -1137,6 +1137,102 @@ const fetchList = async (params) => { ...@@ -1137,6 +1137,102 @@ const fetchList = async (params) => {
1137 1137
1138 --- 1138 ---
1139 1139
1140 +## Vue 3 响应式数据和表单状态管理
1141 +
1142 +### ❌ 坑:v-model 双向绑定导致表单数据丢失或重置失败
1143 +
1144 +**场景**:用户填写表单后关闭弹窗(未提交),再次打开时数据依然存在,或者在输入过程中数据意外丢失。
1145 +
1146 +**问题根因**:
1147 +
1148 +1. **v-model 每次更新都创建新对象**
1149 + ```javascript
1150 + // 父组件
1151 + const formData = ref({})
1152 +
1153 + // v-model 更新时(子组件 emit)
1154 + formData.value = {age: 30} // ← 每次都是新对象!
1155 + ```
1156 +
1157 +2. **reactive() 只在初始化时读取 props**
1158 + ```javascript
1159 + // 子组件
1160 + const form = reactive(props.modelValue || {})
1161 +
1162 + // 问题:
1163 + // - 只在组件创建时读取一次 props.modelValue
1164 + // - 之后 props.modelValue 变化,form 不会自动更新
1165 + ```
1166 +
1167 +3. **watch 监听策略错误**
1168 + ```javascript
1169 + // ❌ 错误:每次 props 变化都清空并复制
1170 + watch(() => props.modelValue, (newVal) => {
1171 + Object.keys(form).forEach(key => delete form[key])
1172 + Object.assign(form, newVal)
1173 + })
1174 +
1175 + // 问题:
1176 + // - v-model 更新创建新对象,触发 watch
1177 + // - 清空操作导致用户输入丢失
1178 + // - 可能触发无限循环:form → emit → props → watch → form
1179 + ```
1180 +
1181 +**✅ 解决方案**:区分"重置"和"正常更新"
1182 +
1183 +```javascript
1184 +// ✅ 正确:只在重置时清空,正常更新时只合并新字段
1185 +let previousModelValue = null
1186 +
1187 +watch(
1188 + () => props.modelValue,
1189 + (newVal) => {
1190 + if (!newVal) {
1191 + // null 或 undefined:清空
1192 + Object.keys(form).forEach(key => delete form[key])
1193 + previousModelValue = null
1194 + return
1195 + }
1196 +
1197 + // 判断是否是重置(从有数据变为空对象)
1198 + const isReset = previousModelValue &&
1199 + Object.keys(previousModelValue).length > 0 &&
1200 + Object.keys(newVal).length === 0
1201 +
1202 + if (isReset) {
1203 + // 父组件重置了:清空表单
1204 + Object.keys(form).forEach(key => delete form[key])
1205 + previousModelValue = newVal
1206 + } else {
1207 + // 正常更新:只合并新字段,不删除已有字段
1208 + // 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
1209 + Object.keys(newVal).forEach(key => {
1210 + form[key] = newVal[key]
1211 + })
1212 + previousModelValue = newVal
1213 + }
1214 + },
1215 + { immediate: true }
1216 +)
1217 +```
1218 +
1219 +**关键要点**:
1220 +1. ✅ 不要用引用判断(`newVal !== previousModelValue`),因为 v-model 每次都创建新对象
1221 +2. ✅ 用内容判断:从有数据 → 空对象 = 重置
1222 +3. ✅ 正常更新时只合并新字段,保留已有字段
1223 +4. ✅ 不要用 `{ deep: true }` 监听 props(可能导致循环)
1224 +
1225 +**涉及文件**:
1226 +- `src/components/PlanFormContainer.vue` - 父组件
1227 +- `src/components/PlanTemplates/LifeInsuranceTemplate.vue` - 子组件
1228 +- `src/components/PlanTemplates/CriticalIllnessTemplate.vue` - 子组件
1229 +- `src/components/PlanTemplates/SavingsTemplate.vue` - 子组件
1230 +
1231 +**调试时间**:约 1.5 小时(3 次尝试)
1232 +**影响**:表单数据丢失、重置失败
1233 +
1234 +---
1235 +
1140 ## 架构设计 1236 ## 架构设计
1141 1237
1142 ### ✅ 1. 统一的列表点击处理 1238 ### ✅ 1. 统一的列表点击处理
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
40 * @submit="handleSubmit" 40 * @submit="handleSubmit"
41 * /> 41 * />
42 */ 42 */
43 -import { ref, computed, watch } from 'vue' 43 +import { ref, computed, watch, nextTick } from 'vue'
44 import PlanPopup from './PlanPopup/index.vue' 44 import PlanPopup from './PlanPopup/index.vue'
45 import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue' 45 import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
46 import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue' 46 import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
...@@ -186,22 +186,41 @@ watch( ...@@ -186,22 +186,41 @@ watch(
186 /** 186 /**
187 * 关闭弹窗 187 * 关闭弹窗
188 * @description 关闭时重置表单数据,避免下次打开时保留旧数据 188 * @description 关闭时重置表单数据,避免下次打开时保留旧数据
189 + *
190 + * ⚠️ 重要:必须使用 nextTick 延迟重置
191 + * 原因:避免响应式更新时序问题,确保子组件完全卸载后再重置数据
192 + *
193 + * 时序问题示例:
194 + * 1. close() → resetForm() → emit('close')
195 + * 2. emit('close') → 父组件设置 visible = false
196 + * 3. 子组件开始卸载(异步)
197 + * 4. ⚠️ 如果在步骤3之前就重置,子组件可能还保留旧数据
198 + *
199 + * 解决方案:
200 + * 1. 先触发关闭事件(让父组件更新 visible)
201 + * 2. 等待 nextTick(确保 DOM 更新完成)
202 + * 3. 再重置表单数据
189 */ 203 */
190 -const close = () => { 204 +const close = async () => {
191 - console.log('[PlanFormContainer] 关闭弹窗,重置表单') 205 + console.log('[PlanFormContainer] 关闭弹窗,准备重置表单')
206 +
207 + // ⚠️ 关键:先触发关闭事件,让父组件更新 visible
208 + emit('close')
209 +
210 + // 等待 Vue 的响应式更新完成(确保子组件开始卸载)
211 + await nextTick()
192 212
193 - // 关闭前重置表单 213 + // 现在重置表单,确保不会被子组件保留的引用覆盖
194 resetForm() 214 resetForm()
195 215
196 - // 触发关闭事件 216 + console.log('[PlanFormContainer] 弹窗已关闭,表单已重置')
197 - emit('close')
198 } 217 }
199 218
200 /** 219 /**
201 * 提交表单 220 * 提交表单
202 * @description 将表单数据和产品信息一起提交 221 * @description 将表单数据和产品信息一起提交
203 */ 222 */
204 -const submit = () => { 223 +const submit = async () => {
205 if (!props.product) { 224 if (!props.product) {
206 console.error('[PlanFormContainer] 无法提交: 产品数据为空') 225 console.error('[PlanFormContainer] 无法提交: 产品数据为空')
207 return 226 return
...@@ -229,7 +248,10 @@ const submit = () => { ...@@ -229,7 +248,10 @@ const submit = () => {
229 form_data: formData.value 248 form_data: formData.value
230 }) 249 })
231 250
232 - // ✅ 提交成功后立即重置表单,避免下次打开时保留旧数据 251 + // ⚠️ 等待父组件处理提交事件(可能需要关闭弹窗)
252 + await nextTick()
253 +
254 + // 提交成功后重置表单,避免下次打开时保留旧数据
233 resetForm() 255 resetForm()
234 } 256 }
235 257
......
...@@ -130,8 +130,46 @@ const emit = defineEmits([ ...@@ -130,8 +130,46 @@ const emit = defineEmits([
130 /** 130 /**
131 * 表单数据 131 * 表单数据
132 * @type {Object} 132 * @type {Object}
133 + *
134 + * ⚠️ 重要:处理父组件重置表单的情况
135 + * 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
136 + *
137 + * 解决方案:使用 watch 监听,但只在引用变化时才清空并复制
133 */ 138 */
134 -const form = reactive(props.modelValue || {}) 139 +const form = reactive({})
140 +
141 +let previousModelValue = null
142 +
143 +// 监听父组件的数据变化
144 +watch(
145 + () => props.modelValue,
146 + (newVal) => {
147 + if (!newVal) {
148 + // null 或 undefined:清空
149 + Object.keys(form).forEach(key => delete form[key])
150 + previousModelValue = null
151 + return
152 + }
153 +
154 + // 判断是否是重置(从有数据变为空对象)
155 + const isReset = previousModelValue &&
156 + Object.keys(previousModelValue).length > 0 &&
157 + Object.keys(newVal).length === 0
158 +
159 + if (isReset) {
160 + // 父组件重置了:清空表单
161 + Object.keys(form).forEach(key => delete form[key])
162 + previousModelValue = newVal
163 + } else {
164 + // 正常更新:合并新字段,不删除已有字段
165 + Object.keys(newVal).forEach(key => {
166 + form[key] = newVal[key]
167 + })
168 + previousModelValue = newVal
169 + }
170 + },
171 + { immediate: true }
172 +)
135 173
136 /** 174 /**
137 * 监听表单数据变化,同步到父组件 175 * 监听表单数据变化,同步到父组件
......
...@@ -130,8 +130,49 @@ const emit = defineEmits([ ...@@ -130,8 +130,49 @@ const emit = defineEmits([
130 /** 130 /**
131 * 表单数据 131 * 表单数据
132 * @type {Object} 132 * @type {Object}
133 + *
134 + * ⚠️ 重要:处理父组件重置表单的情况
135 + * 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
136 + *
137 + * 解决方案:使用 watch 监听,但只在重置时(空对象)才清空
138 + * - 判断重置的标准:从有数据变为空对象
139 + * - 用户输入时的更新:只合并新字段,不删除已有字段
133 */ 140 */
134 -const form = reactive(props.modelValue || {}) 141 +const form = reactive({})
142 +
143 +let previousModelValue = null
144 +
145 +// 监听父组件的数据变化
146 +watch(
147 + () => props.modelValue,
148 + (newVal) => {
149 + if (!newVal) {
150 + // null 或 undefined:清空
151 + Object.keys(form).forEach(key => delete form[key])
152 + previousModelValue = null
153 + return
154 + }
155 +
156 + // 判断是否是重置(从有数据变为空对象)
157 + const isReset = previousModelValue &&
158 + Object.keys(previousModelValue).length > 0 &&
159 + Object.keys(newVal).length === 0
160 +
161 + if (isReset) {
162 + // 父组件重置了:清空表单
163 + Object.keys(form).forEach(key => delete form[key])
164 + previousModelValue = newVal
165 + } else {
166 + // 正常更新:合并新字段,不删除已有字段
167 + // 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
168 + Object.keys(newVal).forEach(key => {
169 + form[key] = newVal[key]
170 + })
171 + previousModelValue = newVal
172 + }
173 + },
174 + { immediate: true }
175 +)
135 176
136 /** 177 /**
137 * 监听表单数据变化,同步到父组件 178 * 监听表单数据变化,同步到父组件
......
...@@ -261,14 +261,66 @@ const emit = defineEmits([ ...@@ -261,14 +261,66 @@ const emit = defineEmits([
261 /** 261 /**
262 * 表单数据 262 * 表单数据
263 * @type {Object} 263 * @type {Object}
264 + *
265 + * ⚠️ 重要:处理父组件重置表单的情况
266 + * 问题:reactive() 只在初始化时赋值,父组件重置时子组件不会自动更新
267 + *
268 + * 解决方案:使用 watch 监听,但只在引用变化时才清空并复制
264 */ 269 */
265 -const form = reactive({ 270 +const form = reactive({})
266 - ...props.modelValue, 271 +
272 +let previousModelValue = null
273 +
274 +// 初始化默认值
275 +const initializeForm = (value) => {
276 + if (!value) {
277 + Object.keys(form).forEach(key => delete form[key])
278 + return
279 + }
280 +
281 + Object.assign(form, {
282 + ...value,
267 // 默认值 283 // 默认值
268 - withdrawal_enabled: props.modelValue.withdrawal_enabled || '否', 284 + withdrawal_enabled: value.withdrawal_enabled || '否',
269 - withdrawal_mode: props.modelValue.withdrawal_mode || '指定提取金额', 285 + withdrawal_mode: value.withdrawal_mode || '指定提取金额',
270 - specified_amount_type: props.modelValue.specified_amount_type || '按年岁' 286 + specified_amount_type: value.specified_amount_type || '按年岁'
271 -}) 287 + })
288 +}
289 +
290 +// 监听父组件的数据变化
291 +watch(
292 + () => props.modelValue,
293 + (newVal) => {
294 + if (!newVal) {
295 + // null 或 undefined:清空
296 + Object.keys(form).forEach(key => delete form[key])
297 + previousModelValue = null
298 + return
299 + }
300 +
301 + // 判断是否是重置(从有数据变为空对象)
302 + const isReset = previousModelValue &&
303 + Object.keys(previousModelValue).length > 0 &&
304 + Object.keys(newVal).length === 0
305 +
306 + if (isReset) {
307 + // 父组件重置了:清空表单
308 + initializeForm(newVal)
309 + previousModelValue = newVal
310 + } else {
311 + // 正常更新:合并新字段,保留默认值逻辑
312 + Object.assign(form, {
313 + ...newVal,
314 + // 默认值
315 + withdrawal_enabled: newVal.withdrawal_enabled || '否',
316 + withdrawal_mode: newVal.withdrawal_mode || '指定提取金额',
317 + specified_amount_type: newVal.specified_amount_type || '按年岁'
318 + })
319 + previousModelValue = newVal
320 + }
321 + },
322 + { immediate: true }
323 +)
272 324
273 /** 325 /**
274 * 监听表单数据变化,同步到父组件 326 * 监听表单数据变化,同步到父组件
......