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>
Showing
6 changed files
with
328 additions
and
16 deletions
| ... | @@ -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 | * 监听表单数据变化,同步到父组件 | ... | ... |
-
Please register or login to post a comment