docs(plan): 创建计划书模块完整文档和经验教训总结
- 创建计划书模块架构和经验教训总结文档 - 创建快速使用指南(5分钟快速集成) - 更新架构文档,明确提取计划三层结构 - 添加 SavingsTemplate 组件(储蓄型产品模板) - 修复 PlanFormContainer 中的模板导入路径 影响文件: - docs/lessons-learned/plan-entry-module-summary.md (新增) - docs/plan/plan-entry-quick-guide.md (新增) - docs/CHANGELOG.md (更新) - docs/plan/plan-entry-architecture.md (更新) - src/components/PlanTemplates/SavingsTemplate.vue (新增) - src/components/PlanFormContainer.vue (修复)
Showing
7 changed files
with
1680 additions
and
89 deletions
| ... | @@ -5,6 +5,76 @@ | ... | @@ -5,6 +5,76 @@ |
| 5 | 5 | ||
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| 8 | +## [2026-02-06] - 计划书生成模块架构与经验教训总结 | ||
| 9 | + | ||
| 10 | +### 文档 | ||
| 11 | +- 创建完整的计划书生成模块架构文档 | ||
| 12 | +- 总结所有历史问题和解决方案 | ||
| 13 | +- 整理最佳实践和常见陷阱 | ||
| 14 | + | ||
| 15 | +### 核心要点 | ||
| 16 | +- **架构模式**:配置驱动 + 动态组件加载 | ||
| 17 | +- **关键经验**:响应式表单、字段联动、事件处理 | ||
| 18 | +- **常见问题**:AmountInput 报错、提取计划字段结构、模板导入路径 | ||
| 19 | + | ||
| 20 | +--- | ||
| 21 | + | ||
| 22 | +## [2026-02-06] - 新增储蓄型保险计划书模板并修正提取计划逻辑 | ||
| 23 | + | ||
| 24 | +### 新增 | ||
| 25 | +- 创建 `SavingsTemplate.vue` 组件,支持 GS/GC/FA/LV2 等储蓄型产品 | ||
| 26 | +- 实现提取计划配置功能(完全符合产品需求) | ||
| 27 | + | ||
| 28 | +### 修正 | ||
| 29 | +- 修正提取计划的字段结构,完全符合需求文档 | ||
| 30 | +- 修正提取计划的层级关系和字段联动逻辑 | ||
| 31 | + | ||
| 32 | +### 功能特性 | ||
| 33 | +- **基础字段**:性别、出生年月日、年龄、是否吸烟、年缴保费、缴费年期 | ||
| 34 | +- **提取计划**(完全符合小程序端需求): | ||
| 35 | + 1. **第一层确认**:是否希望生成一份容许减少名义金额的提取说明?(是/否) | ||
| 36 | + 2. **提取选项**(仅当选择"是"时显示): | ||
| 37 | + - **指定提取金额**: | ||
| 38 | + - **按年岁**:由几岁开始、提取期(年)、每年递增提取之百分比(%) | ||
| 39 | + - **按保单年度**:由几岁开始、提取期(年) | ||
| 40 | + - **最高固定提取金额**: | ||
| 41 | + - **按年岁**:由几岁开始、提取期(年) | ||
| 42 | + - **小程序端币种固定**:使用配置中的默认币种(HKD),不需要用户选择 | ||
| 43 | + | ||
| 44 | +### 字段说明 | ||
| 45 | +- `withdrawal_enabled`: 是否启用提取计划(是/否) | ||
| 46 | +- `withdrawal_mode`: 提取选项(指定提取金额/最高固定提取金额) | ||
| 47 | +- `specified_amount_type`: 指定提取金额的方式(按年岁/按保单年度) | ||
| 48 | +- `withdrawal_start_age`: 开始提取年龄 | ||
| 49 | +- `withdrawal_period`: 提取年期 | ||
| 50 | +- `increase_rate`: 每年递增提取之百分比(%) | ||
| 51 | +- `annual_amount`: 每年提取金额(小程序端不需要此字段) | ||
| 52 | + | ||
| 53 | +### 技术实现 | ||
| 54 | +- 组件路径:`src/components/PlanTemplates/SavingsTemplate.vue` | ||
| 55 | +- 使用 `computed` 动态读取提取计划配置 | ||
| 56 | +- 实现多层条件渲染:根据"是否启用"→"提取选项"→"提取方式"动态显示字段 | ||
| 57 | +- 使用 `watch` 监听字段变化,自动清理不相关数据 | ||
| 58 | +- 修复 `PlanFormContainer.vue` 中的 `SavingsTemplate` 导入路径 | ||
| 59 | + | ||
| 60 | +### 影响范围 | ||
| 61 | +- 新增文件:`src/components/PlanTemplates/SavingsTemplate.vue` | ||
| 62 | +- 修改文件:`src/components/PlanFormContainer.vue`(修复导入路径) | ||
| 63 | + | ||
| 64 | +### 修正原因 | ||
| 65 | +- 原实现缺少第一层确认("是否希望生成提取说明") | ||
| 66 | +- 原实现缺少"按年岁/按保单年度"的子选项 | ||
| 67 | +- 原实现缺少"每年递增提取之百分比"字段 | ||
| 68 | +- 原实现将"提取币种"作为独立字段,实际小程序端不需要此字段(币种固定使用配置中的默认币种) | ||
| 69 | +- 原实现字段分配错误:"按保单年度"在需求中只需要两个字段,不是四个字段 | ||
| 70 | + | ||
| 71 | +### 最终字段分配(小程序端) | ||
| 72 | +- **按年岁**:由几岁开始、提取期(年)、每年递增提取之百分比(3个字段) | ||
| 73 | +- **按保单年度**:由几岁开始、提取期(年)(2个字段) | ||
| 74 | +- **最高固定提取金额**:按年岁:由几岁开始、提取期(年)(2个字段) | ||
| 75 | + | ||
| 76 | +--- | ||
| 77 | + | ||
| 8 | ## [2026-02-06] - 修复保额输入组件输入报错及体验问题 | 78 | ## [2026-02-06] - 修复保额输入组件输入报错及体验问题 |
| 9 | 79 | ||
| 10 | ### 修复 | 80 | ### 修复 | ... | ... |
| ... | @@ -4,10 +4,11 @@ | ... | @@ -4,10 +4,11 @@ |
| 4 | **设计师**: Claude Code | 4 | **设计师**: Claude Code |
| 5 | **状态**: ✅ 已批准 | 5 | **状态**: ✅ 已批准 |
| 6 | **批准时间**: 2026-02-06 | 6 | **批准时间**: 2026-02-06 |
| 7 | -**版本**: v2.0 | 7 | +**版本**: v2.1 |
| 8 | **最后更新**: 2026-02-06 | 8 | **最后更新**: 2026-02-06 |
| 9 | 9 | ||
| 10 | **更新记录**: | 10 | **更新记录**: |
| 11 | +- v2.1 (2026-02-06): 更新储蓄型产品提取计划逻辑,明确三层结构和字段分布 | ||
| 11 | - v2.0 (2026-02-06): 整合储蓄型产品(GS/GC/FA/LV2),新增提取计划功能 | 12 | - v2.0 (2026-02-06): 整合储蓄型产品(GS/GC/FA/LV2),新增提取计划功能 |
| 12 | - v1.0 (2026-02-06): 初始版本,支持人寿保险和重疾保险产品 | 13 | - v1.0 (2026-02-06): 初始版本,支持人寿保险和重疾保险产品 |
| 13 | 14 | ||
| ... | @@ -97,19 +98,48 @@ | ... | @@ -97,19 +98,48 @@ |
| 97 | - FA(宏浚傳承保障計劃) | 98 | - FA(宏浚傳承保障計劃) |
| 98 | - LV2(赤霞珠終身壽險計劃2) | 99 | - LV2(赤霞珠終身壽險計劃2) |
| 99 | - 核心信息:性别、年龄、出生年月日、是否吸烟 | 100 | - 核心信息:性别、年龄、出生年月日、是否吸烟 |
| 100 | -- 保额 | 101 | +- 保额(年缴保费) |
| 101 | - 缴费年期:各产品不同(详见配置文件) | 102 | - 缴费年期:各产品不同(详见配置文件) |
| 102 | - **提取计划功能**(所有储蓄产品通用): | 103 | - **提取计划功能**(所有储蓄产品通用): |
| 103 | - - 方式 1:年龄指定金额(按年龄提取) | 104 | + |
| 104 | - - 开始年龄(start_age) | 105 | + **三层结构**: |
| 105 | - - 提取年期(withdrawal_period) | 106 | + |
| 106 | - - 每年提取金额(annual_amount) | 107 | + **第一层**:是否希望生成一份容许减少名义金额的提取说明?(是/否) |
| 107 | - - 币种(currency):支持 HKD、USD、CNY | 108 | + |
| 108 | - - 增加率(increase_rate):每年提取金额的增长百分比 | 109 | + **第二层**(选择"是"时显示): |
| 109 | - - 方式 2:最高固定金额(按年龄提取) | 110 | + - 提取选项(二选一): |
| 110 | - - 开始年龄(start_age) | 111 | + 1. 指定提取金额 |
| 111 | - - 提取年期(withdrawal_period) | 112 | + 2. 最高固定提取金额 |
| 112 | -- **多币种支持**:提取金额支持多币种(HKD、USD、CNY) | 113 | + |
| 114 | + **第三层**(根据第二层选择显示不同字段): | ||
| 115 | + | ||
| 116 | + **A. 指定提取金额模式**: | ||
| 117 | + - 提取方式(二选一): | ||
| 118 | + 1. 按年岁 | ||
| 119 | + 2. 按保单年度 | ||
| 120 | + | ||
| 121 | + - **按年岁**字段(3个): | ||
| 122 | + - 由几岁开始(withdrawal_start_age) | ||
| 123 | + - 提取期(年)(withdrawal_period) | ||
| 124 | + - 每年递增提取之百分比(%)(increase_rate) | ||
| 125 | + | ||
| 126 | + - **按保单年度**字段(2个): | ||
| 127 | + - 由几岁开始(withdrawal_start_age) | ||
| 128 | + - 提取期(年)(withdrawal_period) | ||
| 129 | + | ||
| 130 | + **B. 最高固定提取金额模式**(2个字段): | ||
| 131 | + - 按年岁:由几岁开始(withdrawal_start_age) | ||
| 132 | + - 提取期(年)(withdrawal_period) | ||
| 133 | + | ||
| 134 | + **⚠️ 小程序端特别说明**: | ||
| 135 | + - 币种固定(使用配置中的默认币种,如 HKD) | ||
| 136 | + - 无需手动选择币种字段 | ||
| 137 | + - 无需"每年提取金额"字段(由后端计算) | ||
| 138 | + | ||
| 139 | + **字段清理逻辑**: | ||
| 140 | + - 切换提取方式时,自动清除不相关字段 | ||
| 141 | + - 切换"按年岁"和"按保单年度"时,清除 annual_amount 和 increase_rate | ||
| 142 | + - 选择"否"(不启用提取计划)时,清除所有提取计划相关字段 | ||
| 113 | 143 | ||
| 114 | --- | 144 | --- |
| 115 | 145 | ||
| ... | @@ -357,100 +387,197 @@ src/ | ... | @@ -357,100 +387,197 @@ src/ |
| 357 | 387 | ||
| 358 | **业务场景**:储蓄型产品(GS/GC/FA/LV2)支持提取计划功能 | 388 | **业务场景**:储蓄型产品(GS/GC/FA/LV2)支持提取计划功能 |
| 359 | 389 | ||
| 360 | -**两种提取方式**: | 390 | +**三层结构**: |
| 391 | + | ||
| 392 | +#### 第一层:启用确认 | ||
| 393 | + | ||
| 394 | +**问题**:是否希望生成一份容许减少名义金额的提取说明? | ||
| 395 | + | ||
| 396 | +**选项**:是 / 否(默认:否) | ||
| 361 | 397 | ||
| 362 | -#### 方式 1:年龄指定金额(按年龄提取) | 398 | +#### 第二层:提取选项(第一层选择"是"时显示) |
| 363 | 399 | ||
| 364 | -**字段**: | 400 | +**问题**:提取选项 |
| 365 | -- `start_age`:开始提取年龄(数字) | ||
| 366 | -- `withdrawal_period`:提取年期(数字,单位:年) | ||
| 367 | -- `annual_amount`:每年提取金额(数字,单位:分) | ||
| 368 | -- `currency`:币种(HKD/USD/CNY) | ||
| 369 | -- `increase_rate`:增加率(百分比,如 5 表示 5%) | ||
| 370 | 401 | ||
| 371 | -**示例**: | 402 | +**选项**: |
| 403 | +1. 指定提取金额 | ||
| 404 | +2. 最高固定提取金额 | ||
| 405 | + | ||
| 406 | +#### 第三层:具体字段(根据第二层选择显示不同字段) | ||
| 407 | + | ||
| 408 | +##### A. 指定提取金额模式 | ||
| 409 | + | ||
| 410 | +**子选项**:提取方式 | ||
| 411 | + | ||
| 412 | +**选项**: | ||
| 413 | +1. 按年岁 | ||
| 414 | +2. 按保单年度 | ||
| 415 | + | ||
| 416 | +**按年岁字段**(3个): | ||
| 372 | ```javascript | 417 | ```javascript |
| 373 | { | 418 | { |
| 374 | - mode: 'specified_amount', | 419 | + withdrawal_enabled: '是', |
| 375 | - start_age: 60, | 420 | + withdrawal_mode: '指定提取金额', |
| 376 | - withdrawal_period: 10, | 421 | + specified_amount_type: '按年岁', |
| 377 | - annual_amount: 5000000, // 50,000.00 HKD | 422 | + withdrawal_start_age: 60, // 由几岁开始 |
| 378 | - currency: 'HKD', | 423 | + withdrawal_period: '10年', // 提取期(年) |
| 379 | - increase_rate: 5 // 每年增长 5% | 424 | + increase_rate: '5' // 每年递增提取之百分比(%) |
| 380 | } | 425 | } |
| 381 | ``` | 426 | ``` |
| 382 | 427 | ||
| 383 | -#### 方式 2:最高固定金额(按年龄提取) | 428 | +**按保单年度字段**(2个): |
| 429 | +```javascript | ||
| 430 | +{ | ||
| 431 | + withdrawal_enabled: '是', | ||
| 432 | + withdrawal_mode: '指定提取金额', | ||
| 433 | + specified_amount_type: '按保单年度', | ||
| 434 | + withdrawal_start_age: 60, // 由几岁开始 | ||
| 435 | + withdrawal_period: '10年' // 提取期(年) | ||
| 436 | +} | ||
| 437 | +``` | ||
| 384 | 438 | ||
| 385 | -**字段**: | 439 | +##### B. 最高固定提取金额模式(2个字段) |
| 386 | -- `start_age`:开始提取年龄(数字) | ||
| 387 | -- `withdrawal_period`:提取年期(数字,单位:年) | ||
| 388 | 440 | ||
| 389 | -**示例**: | ||
| 390 | ```javascript | 441 | ```javascript |
| 391 | { | 442 | { |
| 392 | - mode: 'fixed_amount', | 443 | + withdrawal_enabled: '是', |
| 393 | - start_age: 60, | 444 | + withdrawal_mode: '最高固定提取金额', |
| 394 | - withdrawal_period: 10 | 445 | + withdrawal_start_age: 60, // 按年岁:由几岁开始 |
| 446 | + withdrawal_period: '10年' // 提取期(年) | ||
| 395 | } | 447 | } |
| 396 | ``` | 448 | ``` |
| 397 | 449 | ||
| 398 | -**组件设计**: | 450 | +**⚠️ 小程序端特别说明**: |
| 451 | +- 币种固定(从配置文件读取,如 HKD),无需用户选择 | ||
| 452 | +- 无需"每年提取金额"字段(小程序端不需要) | ||
| 453 | +- 字段清理逻辑:切换模式时自动清除不相关字段 | ||
| 454 | + | ||
| 455 | +**组件设计(三层结构)**: | ||
| 399 | 456 | ||
| 400 | ```vue | 457 | ```vue |
| 401 | <template> | 458 | <template> |
| 402 | <div> | 459 | <div> |
| 403 | - <!-- 提取方式选择 --> | 460 | + <!-- 第一层:启用确认 --> |
| 404 | <PlanFieldRadio | 461 | <PlanFieldRadio |
| 405 | - v-model="withdrawalPlan.mode" | 462 | + v-model="form.withdrawal_enabled" |
| 406 | - label="提取方式" | 463 | + label="是否希望生成一份容许减少名义金额的提取说明?" |
| 407 | - :options="['年龄指定金额', '最高固定金额']" | 464 | + :options="['是', '否']" |
| 408 | /> | 465 | /> |
| 409 | 466 | ||
| 410 | - <!-- 开始年龄 --> | 467 | + <!-- 第二层 + 第三层:仅当选择"是"时显示 --> |
| 411 | - <PlanFieldAgePicker | 468 | + <template v-if="form.withdrawal_enabled === '是'"> |
| 412 | - v-model="withdrawalPlan.start_age" | 469 | + <h3>款项提取(容许减少名义金额)</h3> |
| 413 | - label="开始年龄" | 470 | + |
| 414 | - placeholder="请选择开始提取年龄" | 471 | + <!-- 第二层:提取选项 --> |
| 472 | + <PlanFieldRadio | ||
| 473 | + v-model="form.withdrawal_mode" | ||
| 474 | + label="提取选项" | ||
| 475 | + :options="['指定提取金额', '最高固定提取金额']" | ||
| 476 | + @change="onWithdrawalModeChange" | ||
| 415 | /> | 477 | /> |
| 416 | 478 | ||
| 417 | - <!-- 提取年期 --> | 479 | + <!-- 第三层 A:指定提取金额模式 --> |
| 418 | - <PlanFieldSelect | 480 | + <template v-if="form.withdrawal_mode === '指定提取金额'"> |
| 419 | - v-model="withdrawalPlan.withdrawal_period" | 481 | + <!-- 子选项:提取方式 --> |
| 420 | - label="提取年期" | 482 | + <PlanFieldRadio |
| 421 | - placeholder="请选择提取年期" | 483 | + v-model="form.specified_amount_type" |
| 422 | - :options="withdrawalPeriodOptions" | 484 | + label="提取方式" |
| 485 | + :options="['按年岁', '按保单年度']" | ||
| 423 | /> | 486 | /> |
| 424 | 487 | ||
| 425 | - <!-- 方式1:年龄指定金额 - 额外字段 --> | 488 | + <!-- 按年岁字段 --> |
| 426 | - <template v-if="withdrawalPlan.mode === '年龄指定金额'"> | 489 | + <template v-if="form.specified_amount_type === '按年岁'"> |
| 427 | - <!-- 每年提取金额 --> | 490 | + <PlanFieldAgePicker |
| 428 | - <PlanFieldAmount | 491 | + v-model="form.withdrawal_start_age" |
| 429 | - v-model="withdrawalPlan.annual_amount" | 492 | + label="由几岁开始" |
| 430 | - label="每年提取金额" | 493 | + placeholder="请输入开始提取年龄" |
| 431 | - placeholder="请输入金额" | ||
| 432 | - :currency="withdrawalPlan.currency" | ||
| 433 | /> | 494 | /> |
| 434 | 495 | ||
| 435 | - <!-- 币种 --> | 496 | + <PlanFieldSelect |
| 436 | - <CurrencySelector | 497 | + v-model="form.withdrawal_period" |
| 437 | - v-model="withdrawalPlan.currency" | 498 | + label="提取期(年)" |
| 438 | - label="币种" | 499 | + placeholder="请选择提取期" |
| 439 | - :options="['HKD', 'USD', 'CNY']" | 500 | + :options="withdrawalPeriods" |
| 440 | /> | 501 | /> |
| 441 | 502 | ||
| 442 | - <!-- 增加率 --> | 503 | + <!-- 每年递增提取之百分比 --> |
| 443 | <div> | 504 | <div> |
| 444 | - <div class="text-sm text-gray-600 mb-2">增加率(%)</div> | 505 | + <div class="text-sm text-gray-700 mb-2"> |
| 506 | + 每年递增提取之百分比(%) | ||
| 507 | + </div> | ||
| 445 | <nut-input | 508 | <nut-input |
| 446 | - v-model="withdrawalPlan.increase_rate" | 509 | + v-model="form.increase_rate" |
| 447 | type="digit" | 510 | type="digit" |
| 448 | - placeholder="请输入增加率" | 511 | + placeholder="请输入递增百分比" |
| 449 | /> | 512 | /> |
| 450 | </div> | 513 | </div> |
| 451 | </template> | 514 | </template> |
| 515 | + | ||
| 516 | + <!-- 按保单年度字段 --> | ||
| 517 | + <template v-if="form.specified_amount_type === '按保单年度'"> | ||
| 518 | + <PlanFieldAgePicker | ||
| 519 | + v-model="form.withdrawal_start_age" | ||
| 520 | + label="由几岁开始" | ||
| 521 | + placeholder="请输入开始提取年龄" | ||
| 522 | + /> | ||
| 523 | + | ||
| 524 | + <PlanFieldSelect | ||
| 525 | + v-model="form.withdrawal_period" | ||
| 526 | + label="提取期(年)" | ||
| 527 | + placeholder="请选择提取期" | ||
| 528 | + :options="withdrawalPeriods" | ||
| 529 | + /> | ||
| 530 | + </template> | ||
| 531 | + </template> | ||
| 532 | + | ||
| 533 | + <!-- 第三层 B:最高固定提取金额模式 --> | ||
| 534 | + <template v-if="form.withdrawal_mode === '最高固定提取金额'"> | ||
| 535 | + <PlanFieldAgePicker | ||
| 536 | + v-model="form.withdrawal_start_age" | ||
| 537 | + label="按年岁:由几岁开始" | ||
| 538 | + placeholder="请输入开始提取年龄" | ||
| 539 | + /> | ||
| 540 | + | ||
| 541 | + <PlanFieldSelect | ||
| 542 | + v-model="form.withdrawal_period" | ||
| 543 | + label="提取期(年)" | ||
| 544 | + placeholder="请选择提取期" | ||
| 545 | + :options="withdrawalPeriods" | ||
| 546 | + /> | ||
| 547 | + </template> | ||
| 548 | + </template> | ||
| 452 | </div> | 549 | </div> |
| 453 | </template> | 550 | </template> |
| 551 | + | ||
| 552 | +<script setup> | ||
| 553 | +// 提取模式切换时的字段清理逻辑 | ||
| 554 | +const onWithdrawalModeChange = (mode) => { | ||
| 555 | + if (mode === '最高固定提取金额') { | ||
| 556 | + // 最高固定金额模式不需要指定金额的相关字段 | ||
| 557 | + delete form.specified_amount_type | ||
| 558 | + delete form.increase_rate | ||
| 559 | + } | ||
| 560 | +} | ||
| 561 | + | ||
| 562 | +// 监听提取方式变化 | ||
| 563 | +watch(() => form.specified_amount_type, (newType) => { | ||
| 564 | + // 两种方式都不需要 annual_amount 和 increase_rate(小程序端不需要) | ||
| 565 | + delete form.annual_amount | ||
| 566 | + delete form.increase_rate | ||
| 567 | +}) | ||
| 568 | + | ||
| 569 | +// 监听启用状态变化 | ||
| 570 | +watch(() => form.withdrawal_enabled, (newValue) => { | ||
| 571 | + if (newValue === '否') { | ||
| 572 | + // 清除所有提取计划相关字段 | ||
| 573 | + delete form.withdrawal_mode | ||
| 574 | + delete form.specified_amount_type | ||
| 575 | + delete form.withdrawal_start_age | ||
| 576 | + delete form.withdrawal_period | ||
| 577 | + delete form.increase_rate | ||
| 578 | + } | ||
| 579 | +}) | ||
| 580 | +</script> | ||
| 454 | ``` | 581 | ``` |
| 455 | 582 | ||
| 456 | --- | 583 | --- | ... | ... |
docs/PLAN/plan-entry-quick-guide.md
0 → 100644
| 1 | +# 计划书生成模块 - 快速使用指南 | ||
| 2 | + | ||
| 3 | +> **适用场景**:需要在页面中添加"计划书"功能 | ||
| 4 | + | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +## 🚀 5分钟快速集成 | ||
| 8 | + | ||
| 9 | +### 1. 在页面中引入组件 | ||
| 10 | + | ||
| 11 | +```vue | ||
| 12 | +<script setup> | ||
| 13 | +import { ref } from 'vue' | ||
| 14 | +import PlanFormContainer from '@/components/PlanFormContainer.vue' | ||
| 15 | + | ||
| 16 | +const showPlanPopup = ref(false) | ||
| 17 | +const selectedProduct = ref(null) | ||
| 18 | +</script> | ||
| 19 | +``` | ||
| 20 | + | ||
| 21 | +### 2. 绑定按钮点击事件 | ||
| 22 | + | ||
| 23 | +```vue | ||
| 24 | +<template> | ||
| 25 | + <!-- 在产品列表或产品详情页 --> | ||
| 26 | + <nut-button | ||
| 27 | + type="primary" | ||
| 28 | + @click="openPlanPopup(product)" | ||
| 29 | + > | ||
| 30 | + 计划书 | ||
| 31 | + </nut-button> | ||
| 32 | +</template> | ||
| 33 | +``` | ||
| 34 | + | ||
| 35 | +### 3. 打开计划书弹窗 | ||
| 36 | + | ||
| 37 | +```javascript | ||
| 38 | +const openPlanPopup = (product) => { | ||
| 39 | + // 确保产品有 form_sn 字段 | ||
| 40 | + if (!product.form_sn) { | ||
| 41 | + console.error('产品缺少 form_sn 字段', product) | ||
| 42 | + return | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + selectedProduct.value = product | ||
| 46 | + showPlanPopup.value = true | ||
| 47 | +} | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +### 4. 处理提交 | ||
| 51 | + | ||
| 52 | +```javascript | ||
| 53 | +const handleClose = () => { | ||
| 54 | + showPlanPopup.value = false | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +const handleSubmit = (formData) => { | ||
| 58 | + console.log('提交计划书:', formData) | ||
| 59 | + | ||
| 60 | + // 调用 API 提交 | ||
| 61 | + submitPlanAPI({ | ||
| 62 | + product_id: selectedProduct.value.id, | ||
| 63 | + form_sn: selectedProduct.value.form_sn, | ||
| 64 | + form_data: formData | ||
| 65 | + }) | ||
| 66 | + | ||
| 67 | + handleClose() | ||
| 68 | +} | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +### 5. 渲染弹窗组件 | ||
| 72 | + | ||
| 73 | +```vue | ||
| 74 | +<template> | ||
| 75 | + <PlanFormContainer | ||
| 76 | + v-model:visible="showPlanPopup" | ||
| 77 | + :product="selectedProduct" | ||
| 78 | + @close="handleClose" | ||
| 79 | + @submit="handleSubmit" | ||
| 80 | + /> | ||
| 81 | +</template> | ||
| 82 | +``` | ||
| 83 | + | ||
| 84 | +--- | ||
| 85 | + | ||
| 86 | +## ✅ 完整示例 | ||
| 87 | + | ||
| 88 | +```vue | ||
| 89 | +<template> | ||
| 90 | + <div class="product-page"> | ||
| 91 | + <!-- 产品信息 --> | ||
| 92 | + <nut-card> | ||
| 93 | + <h2>{{ product.product_name }}</h2> | ||
| 94 | + <nut-button | ||
| 95 | + type="primary" | ||
| 96 | + @click="openPlanPopup(product)" | ||
| 97 | + > | ||
| 98 | + 生成计划书 | ||
| 99 | + </nut-button> | ||
| 100 | + </nut-card> | ||
| 101 | + | ||
| 102 | + <!-- 计划书弹窗 --> | ||
| 103 | + <PlanFormContainer | ||
| 104 | + v-model:visible="showPlanPopup" | ||
| 105 | + :product="selectedProduct" | ||
| 106 | + @close="handleClose" | ||
| 107 | + @submit="handleSubmit" | ||
| 108 | + /> | ||
| 109 | + </div> | ||
| 110 | +</template> | ||
| 111 | + | ||
| 112 | +<script setup> | ||
| 113 | +import { ref } from 'vue' | ||
| 114 | +import PlanFormContainer from '@/components/PlanFormContainer.vue' | ||
| 115 | +import { submitPlanAPI } from '@/api/plan' | ||
| 116 | + | ||
| 117 | +const product = ref({ | ||
| 118 | + id: 1, | ||
| 119 | + product_name: "WIOP3E 盈传创富保障计划 3", | ||
| 120 | + form_sn: "life-insurance-wiop3e" | ||
| 121 | +}) | ||
| 122 | + | ||
| 123 | +const showPlanPopup = ref(false) | ||
| 124 | +const selectedProduct = ref(null) | ||
| 125 | + | ||
| 126 | +const openPlanPopup = (prod) => { | ||
| 127 | + if (!prod.form_sn) { | ||
| 128 | + Taro.showToast({ | ||
| 129 | + title: '产品配置错误', | ||
| 130 | + icon: 'error' | ||
| 131 | + }) | ||
| 132 | + return | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + selectedProduct.value = prod | ||
| 136 | + showPlanPopup.value = true | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +const handleClose = () => { | ||
| 140 | + showPlanPopup.value = false | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +const handleSubmit = async (formData) => { | ||
| 144 | + try { | ||
| 145 | + Taro.showLoading({ title: '提交中...' }) | ||
| 146 | + | ||
| 147 | + const res = await submitPlanAPI({ | ||
| 148 | + product_id: selectedProduct.value.id, | ||
| 149 | + form_sn: selectedProduct.value.form_sn, | ||
| 150 | + form_data: formData | ||
| 151 | + }) | ||
| 152 | + | ||
| 153 | + if (res.code === 1) { | ||
| 154 | + Taro.showToast({ | ||
| 155 | + title: '提交成功', | ||
| 156 | + icon: 'success' | ||
| 157 | + }) | ||
| 158 | + | ||
| 159 | + // 跳转到结果页 | ||
| 160 | + Taro.navigateTo({ | ||
| 161 | + url: `/pages/plan-submit-result/index?id=${res.data.plan_id}` | ||
| 162 | + }) | ||
| 163 | + } else { | ||
| 164 | + Taro.showToast({ | ||
| 165 | + title: res.msg || '提交失败', | ||
| 166 | + icon: 'error' | ||
| 167 | + }) | ||
| 168 | + } | ||
| 169 | + } catch (err) { | ||
| 170 | + console.error('提交计划书失败:', err) | ||
| 171 | + Taro.showToast({ | ||
| 172 | + title: '网络错误', | ||
| 173 | + icon: 'error' | ||
| 174 | + }) | ||
| 175 | + } finally { | ||
| 176 | + Taro.hideLoading() | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + handleClose() | ||
| 180 | +} | ||
| 181 | +</script> | ||
| 182 | +``` | ||
| 183 | + | ||
| 184 | +--- | ||
| 185 | + | ||
| 186 | +## 🔧 添加新产品支持 | ||
| 187 | + | ||
| 188 | +### 步骤 1: 添加配置 | ||
| 189 | + | ||
| 190 | +在 `src/config/plan-templates.js` 中添加: | ||
| 191 | + | ||
| 192 | +```javascript | ||
| 193 | +export const PLAN_TEMPLATES = { | ||
| 194 | + // ... 现有配置 | ||
| 195 | + | ||
| 196 | + 'your-product-form-sn': { | ||
| 197 | + name: '产品名称', | ||
| 198 | + component: 'YourProductTemplate', | ||
| 199 | + config: { | ||
| 200 | + currency: 'USD', | ||
| 201 | + payment_periods: ['5 年', '10 年'], | ||
| 202 | + age_range: { min: 0, max: 65 }, | ||
| 203 | + insurance_period: '终身' | ||
| 204 | + } | ||
| 205 | + } | ||
| 206 | +} | ||
| 207 | +``` | ||
| 208 | + | ||
| 209 | +### 步骤 2: 创建模板 | ||
| 210 | + | ||
| 211 | +在 `src/components/PlanTemplates/YourProductTemplate.vue`: | ||
| 212 | + | ||
| 213 | +```vue | ||
| 214 | +<template> | ||
| 215 | + <div v-if="config"> | ||
| 216 | + <!-- 使用通用字段组件 --> | ||
| 217 | + <PlanFieldRadio v-model="form.gender" label="性别" :options="['男', '女']" /> | ||
| 218 | + <!-- ... 其他字段 --> | ||
| 219 | + </div> | ||
| 220 | +</template> | ||
| 221 | + | ||
| 222 | +<script setup> | ||
| 223 | +import { reactive, watch } from 'vue' | ||
| 224 | +import PlanFieldRadio from '../PlanFields/RadioGroup.vue' | ||
| 225 | + | ||
| 226 | +const props = defineProps({ | ||
| 227 | + modelValue: { type: Object, default: () => ({}) }, | ||
| 228 | + config: { type: Object, required: true } | ||
| 229 | +}) | ||
| 230 | + | ||
| 231 | +const emit = defineEmits(['update:modelValue']) | ||
| 232 | + | ||
| 233 | +const form = reactive({ ...props.modelValue }) | ||
| 234 | + | ||
| 235 | +watch(() => form, (newVal) => emit('update:modelValue', newVal), { deep: true }) | ||
| 236 | +</script> | ||
| 237 | +``` | ||
| 238 | + | ||
| 239 | +### 步骤 3: 注册组件 | ||
| 240 | + | ||
| 241 | +在 `src/components/PlanFormContainer.vue` 中: | ||
| 242 | + | ||
| 243 | +```javascript | ||
| 244 | +import YourProductTemplate from './PlanTemplates/YourProductTemplate.vue' | ||
| 245 | + | ||
| 246 | +const componentMap = { | ||
| 247 | + // ... 现有组件 | ||
| 248 | + 'YourProductTemplate': YourProductTemplate | ||
| 249 | +} | ||
| 250 | +``` | ||
| 251 | + | ||
| 252 | +--- | ||
| 253 | + | ||
| 254 | +## 🐛 常见问题 | ||
| 255 | + | ||
| 256 | +### Q1: 弹窗显示"未找到对应的计划书模版" | ||
| 257 | + | ||
| 258 | +**原因**:产品缺少 `form_sn` 字段 | ||
| 259 | + | ||
| 260 | +**解决**: | ||
| 261 | +```javascript | ||
| 262 | +// 检查产品数据 | ||
| 263 | +console.log(product.form_sn) // 应该有值,如 "life-insurance-wiop3e" | ||
| 264 | + | ||
| 265 | +// 如果没有,联系后端添加 | ||
| 266 | +``` | ||
| 267 | + | ||
| 268 | +--- | ||
| 269 | + | ||
| 270 | +### Q2: 表单输入时出现 `value.replace is not a function` | ||
| 271 | + | ||
| 272 | +**原因**:这是已修复的问题 | ||
| 273 | + | ||
| 274 | +**解决**:确保使用最新版本的 `AmountInput.vue` | ||
| 275 | + | ||
| 276 | +--- | ||
| 277 | + | ||
| 278 | +### Q3: 年龄选择器显示为 018,提交需要 18 | ||
| 279 | + | ||
| 280 | +**说明**:这是正常行为 | ||
| 281 | + | ||
| 282 | +- **显示**:3 位数字格式(018) | ||
| 283 | +- **提交**:普通数字(18) | ||
| 284 | + | ||
| 285 | +组件会自动处理转换,无需手动处理。 | ||
| 286 | + | ||
| 287 | +--- | ||
| 288 | + | ||
| 289 | +### Q4: 提取计划字段显示不正确 | ||
| 290 | + | ||
| 291 | +**说明**:提取计划有复杂的条件渲染逻辑 | ||
| 292 | + | ||
| 293 | +- 第一层:是否启用(是/否) | ||
| 294 | +- 第二层:提取选项(指定提取金额/最高固定提取金额) | ||
| 295 | +- 第三层:具体方式(按年岁/按保单年度) | ||
| 296 | + | ||
| 297 | +确保按顺序选择,相关字段会自动显示。 | ||
| 298 | + | ||
| 299 | +--- | ||
| 300 | + | ||
| 301 | +## 📚 更多信息 | ||
| 302 | + | ||
| 303 | +- [完整架构文档](./plan-entry-architecture.md) | ||
| 304 | +- [经验教训总结](../lessons-learned/plan-entry-module-summary.md) | ||
| 305 | +- [API 联调日志](../api-integration-log.md) | ||
| 306 | + | ||
| 307 | +--- | ||
| 308 | + | ||
| 309 | +**最后更新**: 2026-02-06 |
| 1 | +# 计划书生成模块 - 完整总结与经验教训 | ||
| 2 | + | ||
| 3 | +**创建时间**: 2026-02-06 | ||
| 4 | +**模块**: 计划书生成(Plan Entry) | ||
| 5 | +**状态**: ✅ 已完成 | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 📐 模块架构 | ||
| 10 | + | ||
| 11 | +### 1. 整体架构图 | ||
| 12 | + | ||
| 13 | +``` | ||
| 14 | +PlanFormContainer.vue (表单容器) | ||
| 15 | + ↓ 根据 product.form_sn 动态加载 | ||
| 16 | + ├─ LifeInsuranceTemplate.vue (人寿保险) | ||
| 17 | + ├─ CriticalIllnessTemplate.vue (重疾保险) | ||
| 18 | + └─ SavingsTemplate.vue (储蓄型产品) | ||
| 19 | + ↓ 使用通用字段组件 | ||
| 20 | + └─ PlanFields/ | ||
| 21 | + ├─ AgePicker.vue (年龄选择器) | ||
| 22 | + ├─ AmountInput.vue (保额输入) | ||
| 23 | + ├─ DatePicker.vue (日期选择器) | ||
| 24 | + ├─ RadioGroup.vue (单选组) | ||
| 25 | + └─ SelectPicker.vue (下拉选择) | ||
| 26 | +``` | ||
| 27 | + | ||
| 28 | +### 2. 核心文件 | ||
| 29 | + | ||
| 30 | +| 文件 | 用途 | 关键特性 | | ||
| 31 | +|------|------|----------| | ||
| 32 | +| `PlanFormContainer.vue` | 动态模板容器 | 根据 form_sn 加载模板 | | ||
| 33 | +| `config/plan-templates.js` | 模板配置映射 | form_sn → 组件配置 | | ||
| 34 | +| `PlanTemplates/*.vue` | 具体模板组件 | LifeInsurance/CriticalIllness/Savings | | ||
| 35 | +| `PlanFields/*.vue` | 通用表单字段 | 可复用的表单组件 | | ||
| 36 | + | ||
| 37 | +### 3. 配置驱动设计 | ||
| 38 | + | ||
| 39 | +**核心原则**:通过产品的 `form_sn` 字段自动识别并加载对应模板 | ||
| 40 | + | ||
| 41 | +```javascript | ||
| 42 | +// 产品 API 返回 | ||
| 43 | +{ | ||
| 44 | + id: 1, | ||
| 45 | + product_name: "WIOP3E 盈传创富保障计划 3", | ||
| 46 | + form_sn: "life-insurance-wiop3e" // ← 关键字段 | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// 配置文件映射 | ||
| 50 | +export const PLAN_TEMPLATES = { | ||
| 51 | + 'life-insurance-wiop3e': { | ||
| 52 | + name: 'WIOP3E...', | ||
| 53 | + component: 'LifeInsuranceTemplate', | ||
| 54 | + config: { | ||
| 55 | + currency: 'USD', | ||
| 56 | + payment_periods: ['整付(0-75 岁)', '5 年(0-70 岁)', ...] | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | +} | ||
| 60 | +``` | ||
| 61 | + | ||
| 62 | +--- | ||
| 63 | + | ||
| 64 | +## ⚠️ 关键问题与解决方案 | ||
| 65 | + | ||
| 66 | +### 问题 1: AmountInput 组件输入报错 | ||
| 67 | + | ||
| 68 | +**症状**: | ||
| 69 | +``` | ||
| 70 | +value.replace is not a function | ||
| 71 | +TypeError: value.replace is not a function | ||
| 72 | +``` | ||
| 73 | + | ||
| 74 | +**根本原因**: | ||
| 75 | +- NutUI 在小程序环境下,`@input` 事件返回的对象结构是 `{ detail: { value: "xxx" } }` | ||
| 76 | +- 原代码直接对 `e` 调用 `.replace()`,没有提取实际的值 | ||
| 77 | + | ||
| 78 | +**✅ 正确做法**: | ||
| 79 | +```javascript | ||
| 80 | +// src/components/PlanFields/AmountInput.vue | ||
| 81 | + | ||
| 82 | +const onInput = (e) => { | ||
| 83 | + // 防御性提取值(兼容 Web 和小程序) | ||
| 84 | + const rawValue = e?.detail?.value || e?.target?.value || '' | ||
| 85 | + | ||
| 86 | + // 转换为字符串再处理 | ||
| 87 | + const valueStr = String(rawValue) | ||
| 88 | + | ||
| 89 | + // 处理输入逻辑 | ||
| 90 | + // ... | ||
| 91 | +} | ||
| 92 | +``` | ||
| 93 | + | ||
| 94 | +**关键要点**: | ||
| 95 | +- ✅ 始终从 `e.detail.value` 或 `e.target.value` 提取值 | ||
| 96 | +- ✅ 使用 `String()` 显式转换,避免类型错误 | ||
| 97 | +- ✅ 使用内部状态 `inputValue` 分离显示值和模型值 | ||
| 98 | +- ✅ 仅在 `@blur` 时进行严格格式化 | ||
| 99 | + | ||
| 100 | +--- | ||
| 101 | + | ||
| 102 | +### 问题 2: 提取计划字段结构错误(SavingsTemplate) | ||
| 103 | + | ||
| 104 | +**历史问题**: | ||
| 105 | +经过多次修正,最初实现的字段结构与需求不符。 | ||
| 106 | + | ||
| 107 | +**最终正确的结构**(小程序端): | ||
| 108 | + | ||
| 109 | +```javascript | ||
| 110 | +// 第一层:是否启用提取计划 | ||
| 111 | +withdrawal_enabled: '是' | '否' | ||
| 112 | + | ||
| 113 | +// 第二层:提取选项(仅当启用时显示) | ||
| 114 | +withdrawal_mode: '指定提取金额' | '最高固定提取金额' | ||
| 115 | + | ||
| 116 | +// 第三层:根据不同选项显示不同字段 | ||
| 117 | +if (withdrawal_mode === '指定提取金额') { | ||
| 118 | + specified_amount_type: '按年岁' | '按保单年度' | ||
| 119 | + | ||
| 120 | + if (specified_amount_type === '按年岁') { | ||
| 121 | + withdrawal_start_age: number // 由几岁开始 | ||
| 122 | + withdrawal_period: string // 提取期(年) | ||
| 123 | + increase_rate: string // 每年递增提取之百分比(%) | ||
| 124 | + // ❌ 不需要:annual_amount(小程序端不需要此字段) | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + if (specified_amount_type === '按保单年度') { | ||
| 128 | + withdrawal_start_age: number | ||
| 129 | + withdrawal_period: string | ||
| 130 | + // ❌ 不需要:annual_amount, increase_rate | ||
| 131 | + } | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +if (withdrawal_mode === '最高固定提取金额') { | ||
| 135 | + withdrawal_start_age: number // 按年岁:由几岁开始 | ||
| 136 | + withdrawal_period: string // 提取期(年) | ||
| 137 | +} | ||
| 138 | +``` | ||
| 139 | + | ||
| 140 | +**关键要点**: | ||
| 141 | +- ✅ **三层结构**:启用确认 → 提取选项 → 具体字段 | ||
| 142 | +- ✅ **小程序端币种固定**:使用配置中的 `default_currency`,不需要用户选择 | ||
| 143 | +- ✅ **字段按需显示**:根据用户选择动态显示相关字段 | ||
| 144 | +- ✅ **自动清理无关字段**:使用 `watch` 监听变化,删除不相关字段 | ||
| 145 | + | ||
| 146 | +**字段清理逻辑**: | ||
| 147 | +```javascript | ||
| 148 | +// 当切换提取模式时 | ||
| 149 | +watch( | ||
| 150 | + () => form.withdrawal_mode, | ||
| 151 | + (mode) => { | ||
| 152 | + if (mode === '最高固定提取金额') { | ||
| 153 | + // 清除指定金额相关字段 | ||
| 154 | + delete form.specified_amount_type | ||
| 155 | + delete form.annual_amount | ||
| 156 | + delete form.increase_rate | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | +) | ||
| 160 | + | ||
| 161 | +// 当切换指定金额类型时 | ||
| 162 | +watch( | ||
| 163 | + () => form.specified_amount_type, | ||
| 164 | + () => { | ||
| 165 | + // 小程序端不需要这些字段 | ||
| 166 | + delete form.annual_amount | ||
| 167 | + delete form.increase_rate | ||
| 168 | + } | ||
| 169 | +) | ||
| 170 | + | ||
| 171 | +// 当关闭提取计划时 | ||
| 172 | +watch( | ||
| 173 | + () => form.withdrawal_enabled, | ||
| 174 | + (enabled) => { | ||
| 175 | + if (enabled === '否') { | ||
| 176 | + // 清除所有提取计划字段 | ||
| 177 | + delete form.withdrawal_mode | ||
| 178 | + delete form.specified_amount_type | ||
| 179 | + delete form.withdrawal_start_age | ||
| 180 | + delete form.withdrawal_period | ||
| 181 | + delete form.annual_amount | ||
| 182 | + delete form.increase_rate | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | +) | ||
| 186 | +``` | ||
| 187 | + | ||
| 188 | +--- | ||
| 189 | + | ||
| 190 | +### 问题 3: 模板组件导入路径错误 | ||
| 191 | + | ||
| 192 | +**症状**: | ||
| 193 | +``` | ||
| 194 | +Failed to resolve component: SavingsTemplate | ||
| 195 | +``` | ||
| 196 | + | ||
| 197 | +**原因**: | ||
| 198 | +`PlanFormContainer.vue` 中 `SavingsTemplate` 的导入路径错误 | ||
| 199 | + | ||
| 200 | +**✅ 正确做法**: | ||
| 201 | +```javascript | ||
| 202 | +// ❌ 错误 | ||
| 203 | +import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue' | ||
| 204 | + | ||
| 205 | +// ✅ 正确 | ||
| 206 | +import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue' | ||
| 207 | +// 或者使用路径别名 | ||
| 208 | +import SavingsTemplate from '@/components/PlanTemplates/SavingsTemplate.vue' | ||
| 209 | +``` | ||
| 210 | + | ||
| 211 | +**关键要点**: | ||
| 212 | +- ✅ 检查组件导入路径是否正确 | ||
| 213 | +- ✅ 使用 VSCode 的"跳转到定义"功能验证路径 | ||
| 214 | +- ✅ 组件名称和文件名保持一致(PascalCase) | ||
| 215 | + | ||
| 216 | +--- | ||
| 217 | + | ||
| 218 | +### 问题 4: 年龄计算和显示 | ||
| 219 | + | ||
| 220 | +**需求**: | ||
| 221 | +- 显示:3 位数字格式(018) | ||
| 222 | +- 提交:普通数字(18) | ||
| 223 | + | ||
| 224 | +**✅ 正确做法**: | ||
| 225 | +```javascript | ||
| 226 | +// src/components/PlanFields/AgePicker.vue | ||
| 227 | + | ||
| 228 | +// 显示格式化(转换为字符串,补齐3位) | ||
| 229 | +const displayAge = computed(() => { | ||
| 230 | + return String(props.modelValue || 0).padStart(3, '0') | ||
| 231 | +}) | ||
| 232 | + | ||
| 233 | +// 提交时转换为数字 | ||
| 234 | +const onConfirm = ({ value }) => { | ||
| 235 | + // value[0] 是 Picker 返回的数组,取出第一个值 | ||
| 236 | + const ageValue = value[0] || 0 | ||
| 237 | + | ||
| 238 | + // 发出数字值 | ||
| 239 | + emit('update:modelValue', Number(ageValue)) | ||
| 240 | +} | ||
| 241 | +``` | ||
| 242 | + | ||
| 243 | +**关键要点**: | ||
| 244 | +- ✅ 显示和提交分开处理 | ||
| 245 | +- ✅ 使用 `padStart(3, '0')` 格式化显示 | ||
| 246 | +- ✅ 使用 `Number()` 转换提交值 | ||
| 247 | +- ✅ 兼容 iOS 日期格式(将 `-` 替换为 `/`) | ||
| 248 | + | ||
| 249 | +--- | ||
| 250 | + | ||
| 251 | +## 💡 核心设计模式 | ||
| 252 | + | ||
| 253 | +### 1. 响应式表单数据同步 | ||
| 254 | + | ||
| 255 | +**所有模板组件统一使用**: | ||
| 256 | + | ||
| 257 | +```javascript | ||
| 258 | +import { reactive, watch } from 'vue' | ||
| 259 | + | ||
| 260 | +const props = defineProps({ | ||
| 261 | + modelValue: { type: Object, default: () => ({}) } | ||
| 262 | +}) | ||
| 263 | + | ||
| 264 | +const emit = defineEmits(['update:modelValue']) | ||
| 265 | + | ||
| 266 | +// 使用 reactive 创建响应式表单 | ||
| 267 | +const form = reactive({ | ||
| 268 | + ...props.modelValue, | ||
| 269 | + // 设置默认值 | ||
| 270 | + field1: props.modelValue.field1 || '默认值' | ||
| 271 | +}) | ||
| 272 | + | ||
| 273 | +// 监听表单变化,同步到父组件 | ||
| 274 | +watch( | ||
| 275 | + () => form, | ||
| 276 | + (newVal) => emit('update:modelValue', newVal), | ||
| 277 | + { deep: true } | ||
| 278 | +) | ||
| 279 | +``` | ||
| 280 | + | ||
| 281 | +**关键要点**: | ||
| 282 | +- ✅ 使用 `reactive` 而非 `ref`(表单对象) | ||
| 283 | +- ✅ 使用 `watch` + `deep: true` 监听对象变化 | ||
| 284 | +- ✅ 通过 `emit('update:modelValue')` 同步到父组件 | ||
| 285 | +- ✅ 父组件使用 `v-model` 绑定 | ||
| 286 | + | ||
| 287 | +--- | ||
| 288 | + | ||
| 289 | +### 2. 动态组件加载 | ||
| 290 | + | ||
| 291 | +**PlanFormContainer 中的实现**: | ||
| 292 | + | ||
| 293 | +```javascript | ||
| 294 | +import { computed } from 'vue' | ||
| 295 | +import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue' | ||
| 296 | +import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue' | ||
| 297 | +import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue' | ||
| 298 | + | ||
| 299 | +// 组件映射表 | ||
| 300 | +const componentMap = { | ||
| 301 | + 'LifeInsuranceTemplate': LifeInsuranceTemplate, | ||
| 302 | + 'CriticalIllnessTemplate': CriticalIllnessTemplate, | ||
| 303 | + 'SavingsTemplate': SavingsTemplate | ||
| 304 | +} | ||
| 305 | + | ||
| 306 | +// 根据配置动态选择组件 | ||
| 307 | +const currentTemplateComponent = computed(() => { | ||
| 308 | + const componentName = templateConfig.value.component | ||
| 309 | + return componentMap[componentName] || null | ||
| 310 | +}) | ||
| 311 | +``` | ||
| 312 | + | ||
| 313 | +**关键要点**: | ||
| 314 | +- ✅ 使用 `computed` 动态计算组件 | ||
| 315 | +- ✅ 使用组件映射表(对象)而非 `if-else` | ||
| 316 | +- ✅ 配置中的 `component` 字段与映射表 key 对应 | ||
| 317 | +- ✅ 使用 `<component :is="xxx">` 动态渲染 | ||
| 318 | + | ||
| 319 | +--- | ||
| 320 | + | ||
| 321 | +### 3. 配置驱动 + 后端覆盖 | ||
| 322 | + | ||
| 323 | +**设计原则**: | ||
| 324 | +- 前端配置文件提供默认值 | ||
| 325 | +- 后端 `plan_config` 可覆盖前端配置 | ||
| 326 | + | ||
| 327 | +```javascript | ||
| 328 | +// PlanFormContainer.vue | ||
| 329 | +const templateConfig = computed(() => { | ||
| 330 | + // 从配置文件中查找 | ||
| 331 | + const config = PLAN_TEMPLATES[props.product.form_sn] | ||
| 332 | + if (!config) return null | ||
| 333 | + | ||
| 334 | + // 合并配置:后端优先 | ||
| 335 | + return { | ||
| 336 | + ...config, | ||
| 337 | + config: { | ||
| 338 | + ...config.config, // 前端默认配置 | ||
| 339 | + ...(props.product.plan_config || {}) // 后端覆盖配置 | ||
| 340 | + } | ||
| 341 | + } | ||
| 342 | +}) | ||
| 343 | +``` | ||
| 344 | + | ||
| 345 | +**关键要点**: | ||
| 346 | +- ✅ 前端配置作为后备(fallback) | ||
| 347 | +- ✅ 后端配置优先(通过展开运算符覆盖) | ||
| 348 | +- ✅ 支持部分覆盖(如只覆盖 `currency`,其他用默认值) | ||
| 349 | + | ||
| 350 | +--- | ||
| 351 | + | ||
| 352 | +## 📋 完整使用流程 | ||
| 353 | + | ||
| 354 | +### 1. 在页面中使用 | ||
| 355 | + | ||
| 356 | +```vue | ||
| 357 | +<script setup> | ||
| 358 | +import { ref } from 'vue' | ||
| 359 | +import PlanFormContainer from '@/components/PlanFormContainer.vue' | ||
| 360 | + | ||
| 361 | +const showPlanPopup = ref(false) | ||
| 362 | +const selectedProduct = ref(null) | ||
| 363 | + | ||
| 364 | +const openPlanPopup = (product) => { | ||
| 365 | + selectedProduct.value = product | ||
| 366 | + showPlanPopup.value = true | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +const handleClose = () => { | ||
| 370 | + showPlanPopup.value = false | ||
| 371 | +} | ||
| 372 | + | ||
| 373 | +const handleSubmit = (formData) => { | ||
| 374 | + console.log('提交计划书:', formData) | ||
| 375 | + // 调用提交 API | ||
| 376 | + // submitPlanAPI({ | ||
| 377 | + // product_id: selectedProduct.value.id, | ||
| 378 | + // form_sn: selectedProduct.value.form_sn, | ||
| 379 | + // form_data: formData | ||
| 380 | + // }) | ||
| 381 | +} | ||
| 382 | +</script> | ||
| 383 | + | ||
| 384 | +<template> | ||
| 385 | + <button @click="openPlanPopup(product)">计划书</button> | ||
| 386 | + | ||
| 387 | + <PlanFormContainer | ||
| 388 | + v-model:visible="showPlanPopup" | ||
| 389 | + :product="selectedProduct" | ||
| 390 | + @close="handleClose" | ||
| 391 | + @submit="handleSubmit" | ||
| 392 | + /> | ||
| 393 | +</template> | ||
| 394 | +``` | ||
| 395 | + | ||
| 396 | +--- | ||
| 397 | + | ||
| 398 | +### 2. 添加新产品配置 | ||
| 399 | + | ||
| 400 | +**步骤**: | ||
| 401 | + | ||
| 402 | +1. **确认产品的 `form_sn`** | ||
| 403 | + ```javascript | ||
| 404 | + // 从产品 API 中查看 | ||
| 405 | + { | ||
| 406 | + id: 10, | ||
| 407 | + product_name: "新产品", | ||
| 408 | + form_sn: "new-product-form-sn" // ← 记住这个值 | ||
| 409 | + } | ||
| 410 | + ``` | ||
| 411 | + | ||
| 412 | +2. **在 `config/plan-templates.js` 中添加配置** | ||
| 413 | + ```javascript | ||
| 414 | + export const PLAN_TEMPLATES = { | ||
| 415 | + // ... 其他配置 | ||
| 416 | + | ||
| 417 | + 'new-product-form-sn': { | ||
| 418 | + name: '新产品名称', | ||
| 419 | + component: 'NewProductTemplate', | ||
| 420 | + config: { | ||
| 421 | + currency: 'USD', | ||
| 422 | + payment_periods: ['5 年', '10 年'], | ||
| 423 | + age_range: { min: 0, max: 65 }, | ||
| 424 | + insurance_period: '终身' | ||
| 425 | + } | ||
| 426 | + } | ||
| 427 | + } | ||
| 428 | + ``` | ||
| 429 | + | ||
| 430 | +3. **创建模板组件** | ||
| 431 | + ```vue | ||
| 432 | + <!-- src/components/PlanTemplates/NewProductTemplate.vue --> | ||
| 433 | + <template> | ||
| 434 | + <div v-if="config"> | ||
| 435 | + <!-- 使用通用字段组件 --> | ||
| 436 | + <PlanFieldRadio v-model="form.gender" label="性别" :options="['男', '女']" /> | ||
| 437 | + <!-- ... 其他字段 --> | ||
| 438 | + </div> | ||
| 439 | + </template> | ||
| 440 | + | ||
| 441 | + <script setup> | ||
| 442 | + import { reactive, watch } from 'vue' | ||
| 443 | + import PlanFieldRadio from '../PlanFields/RadioGroup.vue' | ||
| 444 | + // ... 导入其他字段组件 | ||
| 445 | + | ||
| 446 | + const props = defineProps({ | ||
| 447 | + modelValue: { type: Object, default: () => ({}) }, | ||
| 448 | + config: { type: Object, required: true } | ||
| 449 | + }) | ||
| 450 | + | ||
| 451 | + const emit = defineEmits(['update:modelValue']) | ||
| 452 | + | ||
| 453 | + const form = reactive({ ...props.modelValue }) | ||
| 454 | + | ||
| 455 | + watch(() => form, (newVal) => emit('update:modelValue', newVal), { deep: true }) | ||
| 456 | + </script> | ||
| 457 | + ``` | ||
| 458 | + | ||
| 459 | +4. **在 `PlanFormContainer.vue` 中导入** | ||
| 460 | + ```javascript | ||
| 461 | + import NewProductTemplate from './PlanTemplates/NewProductTemplate.vue' | ||
| 462 | + | ||
| 463 | + const componentMap = { | ||
| 464 | + // ... 其他组件 | ||
| 465 | + 'NewProductTemplate': NewProductTemplate | ||
| 466 | + } | ||
| 467 | + ``` | ||
| 468 | + | ||
| 469 | +--- | ||
| 470 | + | ||
| 471 | +## 🎯 最佳实践总结 | ||
| 472 | + | ||
| 473 | +### 1. 表单字段组件开发 | ||
| 474 | + | ||
| 475 | +**必须遵循**: | ||
| 476 | +- ✅ 使用 `v-model` 双向绑定 | ||
| 477 | +- ✅ 使用 `<script setup>` 语法 | ||
| 478 | +- ✅ Props 必须有类型和默认值 | ||
| 479 | +- ✅ Emits 必须定义事件名 | ||
| 480 | +- ✅ 添加详细的 JSDoc 注释 | ||
| 481 | + | ||
| 482 | +**示例**: | ||
| 483 | +```vue | ||
| 484 | +<script setup> | ||
| 485 | +/** | ||
| 486 | + * 年龄选择器 | ||
| 487 | + * | ||
| 488 | + * @description 使用 NutUI Picker 选择年龄,显示 3 位数字格式,提交普通数字 | ||
| 489 | + * @author Claude Code | ||
| 490 | + * @example | ||
| 491 | + * <AgePicker | ||
| 492 | + * v-model="age" | ||
| 493 | + * label="年龄" | ||
| 494 | + * placeholder="请选择年龄" | ||
| 495 | + * /> | ||
| 496 | + */ | ||
| 497 | +import { computed } from 'vue' | ||
| 498 | + | ||
| 499 | +const props = defineProps({ | ||
| 500 | + /** | ||
| 501 | + * 年龄值(数字) | ||
| 502 | + * @type {number} | ||
| 503 | + * @default 0 | ||
| 504 | + */ | ||
| 505 | + modelValue: { | ||
| 506 | + type: Number, | ||
| 507 | + default: 0 | ||
| 508 | + }, | ||
| 509 | + | ||
| 510 | + /** | ||
| 511 | + * 标签文本 | ||
| 512 | + * @type {string} | ||
| 513 | + */ | ||
| 514 | + label: { | ||
| 515 | + type: String, | ||
| 516 | + required: true | ||
| 517 | + }, | ||
| 518 | + | ||
| 519 | + /** | ||
| 520 | + * 占位符文本 | ||
| 521 | + * @type {string} | ||
| 522 | + */ | ||
| 523 | + placeholder: { | ||
| 524 | + type: String, | ||
| 525 | + default: '' | ||
| 526 | + } | ||
| 527 | +}) | ||
| 528 | + | ||
| 529 | +const emit = defineEmits([ | ||
| 530 | + /** | ||
| 531 | + * 更新年龄值事件 | ||
| 532 | + * @event update:modelValue | ||
| 533 | + * @param {number} value - 年龄值 | ||
| 534 | + */ | ||
| 535 | + 'update:modelValue' | ||
| 536 | +]) | ||
| 537 | + | ||
| 538 | +// ... 组件逻辑 | ||
| 539 | +</script> | ||
| 540 | +``` | ||
| 541 | + | ||
| 542 | +--- | ||
| 543 | + | ||
| 544 | +### 2. 模板组件开发 | ||
| 545 | + | ||
| 546 | +**必须遵循**: | ||
| 547 | +- ✅ 只负责字段布局,不包含复杂逻辑 | ||
| 548 | +- ✅ 使用通用字段组件组合 | ||
| 549 | +- ✅ 表单数据统一管理在 `form` 对象中 | ||
| 550 | +- ✅ 使用 `watch` 同步数据变化 | ||
| 551 | + | ||
| 552 | +**❌ 避免**: | ||
| 553 | +- ❌ 在模板组件中直接调用 API | ||
| 554 | +- ❌ 在模板组件中处理提交逻辑 | ||
| 555 | +- ❌ 过度复杂的条件渲染(超过3层) | ||
| 556 | + | ||
| 557 | +--- | ||
| 558 | + | ||
| 559 | +### 3. 配置文件管理 | ||
| 560 | + | ||
| 561 | +**必须遵循**: | ||
| 562 | +- ✅ 每个产品必须有唯一的 `form_sn` | ||
| 563 | +- ✅ 配置必须有默认值(后端不传时的后备) | ||
| 564 | +- ✅ 币种使用标准代码(USD/CNY/HKD/EUR) | ||
| 565 | +- ✅ 导出工具函数(`getTemplateConfig`、`getCurrencySymbol`) | ||
| 566 | + | ||
| 567 | +--- | ||
| 568 | + | ||
| 569 | +### 4. 响应式数据管理 | ||
| 570 | + | ||
| 571 | +**必须遵循**: | ||
| 572 | +- ✅ 表单对象使用 `reactive` | ||
| 573 | +- ✅ 监听对象使用 `deep: true` | ||
| 574 | +- ✅ 删除属性使用 `delete form.xxx`(不是 `form.xxx = undefined`) | ||
| 575 | +- ✅ 条件渲染使用 `v-if` 而非 `v-show`(表单字段) | ||
| 576 | + | ||
| 577 | +--- | ||
| 578 | + | ||
| 579 | +## 🚨 常见陷阱 | ||
| 580 | + | ||
| 581 | +### 陷阱 1: 直接修改 props | ||
| 582 | + | ||
| 583 | +**❌ 错误**: | ||
| 584 | +```javascript | ||
| 585 | +const props = defineProps({ modelValue: Object }) | ||
| 586 | +props.modelValue.field = 'value' // 直接修改 props | ||
| 587 | +``` | ||
| 588 | + | ||
| 589 | +**✅ 正确**: | ||
| 590 | +```javascript | ||
| 591 | +const form = reactive({ ...props.modelValue }) | ||
| 592 | +form.field = 'value' // 修改响应式对象 | ||
| 593 | + | ||
| 594 | +// 通过 watch 同步到父组件 | ||
| 595 | +watch(() => form, (newVal) => emit('update:modelValue', newVal), { deep: true }) | ||
| 596 | +``` | ||
| 597 | + | ||
| 598 | +--- | ||
| 599 | + | ||
| 600 | +### 陷阱 2: 忘记清理字段 | ||
| 601 | + | ||
| 602 | +**❌ 错误**: | ||
| 603 | +```javascript | ||
| 604 | +// 切换模式时,旧字段仍然存在 | ||
| 605 | +watch(() => form.mode, (mode) => { | ||
| 606 | + if (mode === 'mode_a') { | ||
| 607 | + // 添加新字段 | ||
| 608 | + form.field_a = 'value' | ||
| 609 | + } | ||
| 610 | + // 忘记删除 mode_b 的字段 | ||
| 611 | +}) | ||
| 612 | +``` | ||
| 613 | + | ||
| 614 | +**✅ 正确**: | ||
| 615 | +```javascript | ||
| 616 | +watch(() => form.mode, (mode) => { | ||
| 617 | + if (mode === 'mode_a') { | ||
| 618 | + form.field_a = 'value' | ||
| 619 | + delete form.field_b // 清理无关字段 | ||
| 620 | + } else if (mode === 'mode_b') { | ||
| 621 | + form.field_b = 'value' | ||
| 622 | + delete form.field_a // 清理无关字段 | ||
| 623 | + } | ||
| 624 | +}) | ||
| 625 | +``` | ||
| 626 | + | ||
| 627 | +--- | ||
| 628 | + | ||
| 629 | +### 陷阱 3: 事件对象处理不一致 | ||
| 630 | + | ||
| 631 | +**❌ 错误**: | ||
| 632 | +```javascript | ||
| 633 | +const onInput = (e) => { | ||
| 634 | + // 直接使用 e,可能在 Web 和小程序中表现不同 | ||
| 635 | + const value = e.value | ||
| 636 | +} | ||
| 637 | +``` | ||
| 638 | + | ||
| 639 | +**✅ 正确**: | ||
| 640 | +```javascript | ||
| 641 | +const onInput = (e) => { | ||
| 642 | + // 防御性提取值,兼容 Web 和小程序 | ||
| 643 | + const value = e?.detail?.value || e?.target?.value || '' | ||
| 644 | + // 显式转换为字符串 | ||
| 645 | + const valueStr = String(value) | ||
| 646 | +} | ||
| 647 | +``` | ||
| 648 | + | ||
| 649 | +--- | ||
| 650 | + | ||
| 651 | +### 陷阱 4: 日期计算不兼容 iOS | ||
| 652 | + | ||
| 653 | +**❌ 错误**: | ||
| 654 | +```javascript | ||
| 655 | +const birthDate = new Date('1990-01-01') // iOS 可能不支持 | ||
| 656 | +``` | ||
| 657 | + | ||
| 658 | +**✅ 正确**: | ||
| 659 | +```javascript | ||
| 660 | +const dateStr = birthday.replace(/-/g, '/') // 转换为 1990/01/01 | ||
| 661 | +const birthDate = new Date(dateStr) | ||
| 662 | +if (!Number.isNaN(birthDate.getTime())) { | ||
| 663 | + // 安全使用 | ||
| 664 | +} | ||
| 665 | +``` | ||
| 666 | + | ||
| 667 | +--- | ||
| 668 | + | ||
| 669 | +## 📚 参考文档 | ||
| 670 | + | ||
| 671 | +### 项目文档 | ||
| 672 | +- [计划书架构设计](../plan/plan-entry-architecture.md) | ||
| 673 | +- [API 联调日志](../api-integration-log.md) | ||
| 674 | +- [变更日志](../CHANGELOG.md) | ||
| 675 | + | ||
| 676 | +### 技术文档 | ||
| 677 | +- [Taro 官方文档](https://docs.taro.zone/) | ||
| 678 | +- [NutUI Taro 文档](https://nutui.jd.com/4/taro/) | ||
| 679 | +- [Vue 3 官方文档](https://cn.vuejs.org/) | ||
| 680 | + | ||
| 681 | +### 经验教训 | ||
| 682 | +- [项目全局经验教训](../lessons-learned.md) | ||
| 683 | + | ||
| 684 | +--- | ||
| 685 | + | ||
| 686 | +## ✅ 验收清单 | ||
| 687 | + | ||
| 688 | +### 功能完整性 | ||
| 689 | +- [ ] 所有产品类型都能正确加载对应模板 | ||
| 690 | +- [ ] 表单字段联动正常(出生日期 → 年龄) | ||
| 691 | +- [ ] 提取计划多层条件渲染正确 | ||
| 692 | +- [ ] 表单数据同步正常(v-model) | ||
| 693 | +- [ ] 提交数据格式正确 | ||
| 694 | + | ||
| 695 | +### 代码质量 | ||
| 696 | +- [ ] 所有组件都有 JSDoc 注释 | ||
| 697 | +- [ ] 所有函数都有类型定义 | ||
| 698 | +- [ ] 没有 `console.log` 或 `debugger` | ||
| 699 | +- [ ] 命名清晰,符合规范 | ||
| 700 | +- [ ] 组件职责单一 | ||
| 701 | + | ||
| 702 | +### 测试覆盖 | ||
| 703 | +- [ ] 测试不同产品的模板加载 | ||
| 704 | +- [ ] 测试表单字段输入和验证 | ||
| 705 | +- [ ] 测试字段联动逻辑 | ||
| 706 | +- [ ] 测试提交流程 | ||
| 707 | +- [ ] 测试边界情况(空值、异常值) | ||
| 708 | + | ||
| 709 | +--- | ||
| 710 | + | ||
| 711 | +**文档版本**: v1.0 | ||
| 712 | +**创建时间**: 2026-02-06 | ||
| 713 | +**最后更新**: 2026-02-06 |
| ... | @@ -43,7 +43,7 @@ import { ref, computed, watch } from 'vue' | ... | @@ -43,7 +43,7 @@ import { ref, computed, watch } from 'vue' |
| 43 | import PlanPopup from './PlanPopup/index.vue' | 43 | import PlanPopup from './PlanPopup/index.vue' |
| 44 | import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue' | 44 | import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue' |
| 45 | import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue' | 45 | import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue' |
| 46 | -import SavingsTemplate from './SavingsTemplate.vue' | 46 | +import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue' |
| 47 | import { PLAN_TEMPLATES } from '@/config/plan-templates' | 47 | import { PLAN_TEMPLATES } from '@/config/plan-templates' |
| 48 | 48 | ||
| 49 | /** | 49 | /** | ... | ... |
| 1 | +<template> | ||
| 2 | + <div v-if="config"> | ||
| 3 | + <!-- 性别 --> | ||
| 4 | + <PlanFieldRadio | ||
| 5 | + v-model="form.gender" | ||
| 6 | + label="性别" | ||
| 7 | + :options="['男', '女']" | ||
| 8 | + class="mb-5" | ||
| 9 | + /> | ||
| 10 | + | ||
| 11 | + <!-- 出生年月日 --> | ||
| 12 | + <PlanFieldDatePicker | ||
| 13 | + v-model="form.birthday" | ||
| 14 | + label="出生年月日" | ||
| 15 | + placeholder="请选择日期" | ||
| 16 | + @change="onBirthdayChange" | ||
| 17 | + class="mb-5" | ||
| 18 | + /> | ||
| 19 | + | ||
| 20 | + <!-- 年龄(根据出生日期自动计算,可编辑) --> | ||
| 21 | + <PlanFieldAgePicker | ||
| 22 | + v-model="form.age" | ||
| 23 | + label="年龄" | ||
| 24 | + placeholder="请选择出生日期自动计算" | ||
| 25 | + class="mb-5" | ||
| 26 | + /> | ||
| 27 | + | ||
| 28 | + <!-- 是否吸烟 --> | ||
| 29 | + <PlanFieldRadio | ||
| 30 | + v-model="form.smoker" | ||
| 31 | + label="是否吸烟" | ||
| 32 | + :options="['是', '否']" | ||
| 33 | + class="mb-5" | ||
| 34 | + /> | ||
| 35 | + | ||
| 36 | + <!-- 保额(年缴保费) --> | ||
| 37 | + <PlanFieldAmount | ||
| 38 | + v-model="form.coverage" | ||
| 39 | + label="年缴保费" | ||
| 40 | + placeholder="请输入年缴保费" | ||
| 41 | + :currency="config.currency" | ||
| 42 | + class="mb-5" | ||
| 43 | + /> | ||
| 44 | + | ||
| 45 | + <!-- 缴费年期 --> | ||
| 46 | + <PlanFieldSelect | ||
| 47 | + v-model="form.payment_period" | ||
| 48 | + label="缴费年期" | ||
| 49 | + placeholder="请选择缴费年期" | ||
| 50 | + :options="config.payment_periods" | ||
| 51 | + class="mb-5" | ||
| 52 | + /> | ||
| 53 | + | ||
| 54 | + <!-- 分割线 --> | ||
| 55 | + <div class="border-t border-gray-200 my-6"></div> | ||
| 56 | + | ||
| 57 | + <!-- 提取计划配置 --> | ||
| 58 | + <div v-if="config.withdrawal_plan?.enabled" class="withdrawal-plan-section"> | ||
| 59 | + <!-- 第一层:是否希望生成一份容许减少名义金额的提取说明? --> | ||
| 60 | + <PlanFieldRadio | ||
| 61 | + v-model="form.withdrawal_enabled" | ||
| 62 | + label="是否希望生成一份容许减少名义金额的提取说明?" | ||
| 63 | + :options="['是', '否']" | ||
| 64 | + class="mb-5" | ||
| 65 | + /> | ||
| 66 | + | ||
| 67 | + <!-- 仅当选择"是"时才显示以下内容 --> | ||
| 68 | + <template v-if="form.withdrawal_enabled === '是'"> | ||
| 69 | + <h3 class="text-base font-semibold text-gray-900 mb-4">款项提取(容许减少名义金额)</h3> | ||
| 70 | + | ||
| 71 | + <!-- 提取选项:指定提取金额 / 最高固定提取金额 --> | ||
| 72 | + <PlanFieldRadio | ||
| 73 | + v-model="form.withdrawal_mode" | ||
| 74 | + label="提取选项" | ||
| 75 | + :options="['指定提取金额', '最高固定提取金额']" | ||
| 76 | + @change="onWithdrawalModeChange" | ||
| 77 | + class="mb-5" | ||
| 78 | + /> | ||
| 79 | + | ||
| 80 | + <!-- 指定提取金额模式 --> | ||
| 81 | + <template v-if="form.withdrawal_mode === '指定提取金额'"> | ||
| 82 | + <!-- 按年岁/按保单年度选择 --> | ||
| 83 | + <PlanFieldRadio | ||
| 84 | + v-model="form.specified_amount_type" | ||
| 85 | + label="提取方式" | ||
| 86 | + :options="['按年岁', '按保单年度']" | ||
| 87 | + class="mb-5" | ||
| 88 | + /> | ||
| 89 | + | ||
| 90 | + <!-- 按年岁 --> | ||
| 91 | + <template v-if="form.specified_amount_type === '按年岁'"> | ||
| 92 | + <!-- 由几岁开始 --> | ||
| 93 | + <PlanFieldAgePicker | ||
| 94 | + v-model="form.withdrawal_start_age" | ||
| 95 | + label="由几岁开始" | ||
| 96 | + placeholder="请输入开始提取年龄" | ||
| 97 | + class="mb-5" | ||
| 98 | + /> | ||
| 99 | + | ||
| 100 | + <!-- 提取期(年) --> | ||
| 101 | + <PlanFieldSelect | ||
| 102 | + v-model="form.withdrawal_period" | ||
| 103 | + label="提取期(年)" | ||
| 104 | + placeholder="请选择提取期" | ||
| 105 | + :options="withdrawalPeriods" | ||
| 106 | + class="mb-5" | ||
| 107 | + /> | ||
| 108 | + | ||
| 109 | + <!-- 每年递增提取之百分比 --> | ||
| 110 | + <div class="mb-5"> | ||
| 111 | + <div class="text-sm text-gray-700 mb-2">每年递增提取之百分比(%)</div> | ||
| 112 | + <nut-input | ||
| 113 | + v-model="form.increase_rate" | ||
| 114 | + type="digit" | ||
| 115 | + placeholder="请输入递增百分比" | ||
| 116 | + class="w-full" | ||
| 117 | + /> | ||
| 118 | + </div> | ||
| 119 | + </template> | ||
| 120 | + | ||
| 121 | + <!-- 按保单年度 --> | ||
| 122 | + <template v-if="form.specified_amount_type === '按保单年度'"> | ||
| 123 | + <!-- 由几岁开始 --> | ||
| 124 | + <PlanFieldAgePicker | ||
| 125 | + v-model="form.withdrawal_start_age" | ||
| 126 | + label="由几岁开始" | ||
| 127 | + placeholder="请输入开始提取年龄" | ||
| 128 | + class="mb-5" | ||
| 129 | + /> | ||
| 130 | + | ||
| 131 | + <!-- 提取期(年) --> | ||
| 132 | + <PlanFieldSelect | ||
| 133 | + v-model="form.withdrawal_period" | ||
| 134 | + label="提取期(年)" | ||
| 135 | + placeholder="请选择提取期" | ||
| 136 | + :options="withdrawalPeriods" | ||
| 137 | + class="mb-5" | ||
| 138 | + /> | ||
| 139 | + </template> | ||
| 140 | + </template> | ||
| 141 | + | ||
| 142 | + <!-- 最高固定提取金额模式 --> | ||
| 143 | + <template v-if="form.withdrawal_mode === '最高固定提取金额'"> | ||
| 144 | + <!-- 按年岁:由几岁开始 --> | ||
| 145 | + <PlanFieldAgePicker | ||
| 146 | + v-model="form.withdrawal_start_age" | ||
| 147 | + label="按年岁:由几岁开始" | ||
| 148 | + placeholder="请输入开始提取年龄" | ||
| 149 | + class="mb-5" | ||
| 150 | + /> | ||
| 151 | + | ||
| 152 | + <!-- 提取期(年) --> | ||
| 153 | + <PlanFieldSelect | ||
| 154 | + v-model="form.withdrawal_period" | ||
| 155 | + label="提取期(年)" | ||
| 156 | + placeholder="请选择提取期" | ||
| 157 | + :options="withdrawalPeriods" | ||
| 158 | + class="mb-5" | ||
| 159 | + /> | ||
| 160 | + </template> | ||
| 161 | + </template> | ||
| 162 | + </div> | ||
| 163 | + </div> | ||
| 164 | + | ||
| 165 | + <!-- 配置缺失提示 --> | ||
| 166 | + <div v-else class="text-center text-gray-500 py-10"> | ||
| 167 | + <p>⚠️ 模版配置未找到</p> | ||
| 168 | + <p class="text-sm mt-2">请检查产品配置或联系开发人员</p> | ||
| 169 | + </div> | ||
| 170 | +</template> | ||
| 171 | + | ||
| 172 | +<script setup> | ||
| 173 | +/** | ||
| 174 | + * 储蓄型保险计划书模版 | ||
| 175 | + * | ||
| 176 | + * @description GS/GC/FA/LV2 等储蓄型保险产品的计划书录入表单 | ||
| 177 | + * - 支持出生日期自动计算年龄 | ||
| 178 | + * - 支持提取计划配置(多种提取模式和方式) | ||
| 179 | + * - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期 | ||
| 180 | + * - 提取计划:指定提取金额(按年岁/按保单年度)、最高固定提取金额 | ||
| 181 | + * - 小程序端币种固定(使用配置中的默认币种) | ||
| 182 | + * @author Claude Code | ||
| 183 | + * @example | ||
| 184 | + * <SavingsTemplate | ||
| 185 | + * v-model="formData" | ||
| 186 | + * :config="templateConfig" | ||
| 187 | + * /> | ||
| 188 | + */ | ||
| 189 | +import { reactive, watch, computed } from 'vue' | ||
| 190 | +import PlanFieldAgePicker from '../PlanFields/AgePicker.vue' | ||
| 191 | +import PlanFieldAmount from '../PlanFields/AmountInput.vue' | ||
| 192 | +import PlanFieldDatePicker from '../PlanFields/DatePicker.vue' | ||
| 193 | +import PlanFieldRadio from '../PlanFields/RadioGroup.vue' | ||
| 194 | +import PlanFieldSelect from '../PlanFields/SelectPicker.vue' | ||
| 195 | + | ||
| 196 | +/** | ||
| 197 | + * 组件属性 | ||
| 198 | + */ | ||
| 199 | +const props = defineProps({ | ||
| 200 | + /** | ||
| 201 | + * 表单数据对象 | ||
| 202 | + * @type {Object} | ||
| 203 | + */ | ||
| 204 | + modelValue: { | ||
| 205 | + type: Object, | ||
| 206 | + default: () => ({}) | ||
| 207 | + }, | ||
| 208 | + | ||
| 209 | + /** | ||
| 210 | + * 模版配置 | ||
| 211 | + * @type {Object} | ||
| 212 | + * @property {string} currency - 币种代码 | ||
| 213 | + * @property {Array<string>} payment_periods - 缴费年期选项 | ||
| 214 | + * @property {Object} age_range - 年龄范围 { min, max } | ||
| 215 | + * @property {string} insurance_period - 保险期间 | ||
| 216 | + * @property {Object} withdrawal_plan - 提取计划配置 | ||
| 217 | + * @property {boolean} withdrawal_plan.enabled - 是否启用提取计划 | ||
| 218 | + * @property {Array<string>} withdrawal_plan.currencies - 支持的币种 | ||
| 219 | + * @property {string} withdrawal_plan.default_currency - 默认币种 | ||
| 220 | + * @property {Array<string>} withdrawal_plan.withdrawal_modes - 提取模式 | ||
| 221 | + * @property {Array<string>} withdrawal_plan.withdrawal_periods - 提取年期 | ||
| 222 | + */ | ||
| 223 | + config: { | ||
| 224 | + type: Object, | ||
| 225 | + required: true | ||
| 226 | + } | ||
| 227 | +}) | ||
| 228 | + | ||
| 229 | +/** | ||
| 230 | + * 组件事件 | ||
| 231 | + */ | ||
| 232 | +const emit = defineEmits([ | ||
| 233 | + /** | ||
| 234 | + * 更新表单数据事件 | ||
| 235 | + * @event update:modelValue | ||
| 236 | + * @param {Object} value - 表单数据 | ||
| 237 | + */ | ||
| 238 | + 'update:modelValue' | ||
| 239 | +]) | ||
| 240 | + | ||
| 241 | +/** | ||
| 242 | + * 表单数据 | ||
| 243 | + * @type {Object} | ||
| 244 | + */ | ||
| 245 | +const form = reactive({ | ||
| 246 | + ...props.modelValue, | ||
| 247 | + // 默认值 | ||
| 248 | + withdrawal_enabled: props.modelValue.withdrawal_enabled || '否', | ||
| 249 | + withdrawal_mode: props.modelValue.withdrawal_mode || '指定提取金额', | ||
| 250 | + specified_amount_type: props.modelValue.specified_amount_type || '按年岁' | ||
| 251 | +}) | ||
| 252 | + | ||
| 253 | +/** | ||
| 254 | + * 监听表单数据变化,同步到父组件 | ||
| 255 | + */ | ||
| 256 | +watch( | ||
| 257 | + () => form, | ||
| 258 | + (newVal) => emit('update:modelValue', newVal), | ||
| 259 | + { deep: true } | ||
| 260 | +) | ||
| 261 | + | ||
| 262 | +/** | ||
| 263 | + * 默认币种(从配置读取) | ||
| 264 | + * @type {ComputedRef<string>} | ||
| 265 | + */ | ||
| 266 | +const defaultCurrency = computed(() => { | ||
| 267 | + return props.config?.withdrawal_plan?.default_currency || 'HKD' | ||
| 268 | +}) | ||
| 269 | + | ||
| 270 | +/** | ||
| 271 | + * 提取年期选项(从配置读取) | ||
| 272 | + * @type {ComputedRef<Array<string>>} | ||
| 273 | + */ | ||
| 274 | +const withdrawalPeriods = computed(() => { | ||
| 275 | + return props.config?.withdrawal_plan?.withdrawal_periods || [ | ||
| 276 | + '1年', | ||
| 277 | + '2年', | ||
| 278 | + '3年', | ||
| 279 | + '5年', | ||
| 280 | + '10年', | ||
| 281 | + '15年', | ||
| 282 | + '20年', | ||
| 283 | + '终身' | ||
| 284 | + ] | ||
| 285 | +}) | ||
| 286 | + | ||
| 287 | +/** | ||
| 288 | + * 出生日期变化时自动计算年龄 | ||
| 289 | + * @param {string} birthday - 出生日期(格式:YYYY-MM-DD) | ||
| 290 | + * | ||
| 291 | + * @description 用户选择出生日期后,自动计算并填充年龄字段 | ||
| 292 | + * 计算公式:当前年份 - 出生年份 | ||
| 293 | + */ | ||
| 294 | +const onBirthdayChange = (birthday) => { | ||
| 295 | + if (birthday) { | ||
| 296 | + // 兼容 iOS 的日期格式 (YYYY/MM/DD) | ||
| 297 | + const dateStr = birthday.replace(/-/g, '/') | ||
| 298 | + const birthDate = new Date(dateStr) | ||
| 299 | + | ||
| 300 | + if (!Number.isNaN(birthDate.getTime())) { | ||
| 301 | + const birthYear = birthDate.getFullYear() | ||
| 302 | + const currentYear = new Date().getFullYear() | ||
| 303 | + const calculatedAge = currentYear - birthYear | ||
| 304 | + | ||
| 305 | + // 自动填充年龄字段(确保非负) | ||
| 306 | + form.age = Math.max(0, calculatedAge) | ||
| 307 | + } | ||
| 308 | + } | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +/** | ||
| 312 | + * 提取模式变化时的处理 | ||
| 313 | + * @param {string} mode - 新的提取模式 | ||
| 314 | + * | ||
| 315 | + * @description 当用户切换提取模式时,清除不相关的字段 | ||
| 316 | + * - 切换到"指定提取金额":保留字段,等待用户选择子选项 | ||
| 317 | + * - 切换到"最高固定金额":清除指定金额相关字段 | ||
| 318 | + */ | ||
| 319 | +const onWithdrawalModeChange = (mode) => { | ||
| 320 | + if (mode === '最高固定提取金额') { | ||
| 321 | + // 最高固定金额模式不需要指定金额的相关字段 | ||
| 322 | + delete form.specified_amount_type | ||
| 323 | + delete form.annual_amount | ||
| 324 | + delete form.increase_rate | ||
| 325 | + } else if (mode === '指定提取金额') { | ||
| 326 | + // 指定提取金额模式,等待用户选择按年岁或按保单年度 | ||
| 327 | + // 保留现有字段 | ||
| 328 | + } | ||
| 329 | +} | ||
| 330 | + | ||
| 331 | +/** | ||
| 332 | + * 监听指定提取金额方式变化 | ||
| 333 | + * @description 当用户在"按年岁"和"按保单年度"之间切换时,清理不相关字段 | ||
| 334 | + */ | ||
| 335 | +watch( | ||
| 336 | + () => form.specified_amount_type, | ||
| 337 | + (newType) => { | ||
| 338 | + // 两种方式都不需要 annual_amount 和 increase_rate(小程序端不需要) | ||
| 339 | + delete form.annual_amount | ||
| 340 | + delete form.increase_rate | ||
| 341 | + } | ||
| 342 | +) | ||
| 343 | + | ||
| 344 | +/** | ||
| 345 | + * 监听提取计划启用状态变化 | ||
| 346 | + * @description 当用户选择"否"时,清除所有提取计划相关字段 | ||
| 347 | + */ | ||
| 348 | +watch( | ||
| 349 | + () => form.withdrawal_enabled, | ||
| 350 | + (newValue) => { | ||
| 351 | + if (newValue === '否') { | ||
| 352 | + // 清除所有提取计划相关字段 | ||
| 353 | + delete form.withdrawal_mode | ||
| 354 | + delete form.specified_amount_type | ||
| 355 | + delete form.withdrawal_start_age | ||
| 356 | + delete form.withdrawal_period | ||
| 357 | + delete form.annual_amount | ||
| 358 | + delete form.increase_rate | ||
| 359 | + } | ||
| 360 | + } | ||
| 361 | +) | ||
| 362 | +</script> | ||
| 363 | + | ||
| 364 | +<style lang="less" scoped> | ||
| 365 | +/* 提取计划区域样式 */ | ||
| 366 | +.withdrawal-plan-section { | ||
| 367 | + /* 可以在这里添加特殊的样式 */ | ||
| 368 | +} | ||
| 369 | +</style> |
| ... | @@ -103,17 +103,19 @@ export const PLAN_TEMPLATES = { | ... | @@ -103,17 +103,19 @@ export const PLAN_TEMPLATES = { |
| 103 | 103 | ||
| 104 | // ====== 储蓄型产品(统一逻辑) ====== | 104 | // ====== 储蓄型产品(统一逻辑) ====== |
| 105 | 105 | ||
| 106 | - // GS - 宏摯傳承保障計劃 | 106 | + // GS - 宏挚传承保障计划 |
| 107 | 'savings-gs': { | 107 | 'savings-gs': { |
| 108 | - name: '宏摯傳承保障計劃', | 108 | + name: '宏挚传承保障计划', |
| 109 | component: 'SavingsTemplate', | 109 | component: 'SavingsTemplate', |
| 110 | category: 'savings', // 储蓄型产品 | 110 | category: 'savings', // 储蓄型产品 |
| 111 | config: { | 111 | config: { |
| 112 | currency: 'USD', // 默认美元 | 112 | currency: 'USD', // 默认美元 |
| 113 | payment_periods: [ | 113 | payment_periods: [ |
| 114 | - '整付(0-100 岁)', | 114 | + '整付', |
| 115 | - '5 年(0-80 岁)', | 115 | + '3 年', |
| 116 | - '10 年(0-75 岁)' | 116 | + '5 年', |
| 117 | + '10 年', | ||
| 118 | + '15 年', | ||
| 117 | ], | 119 | ], |
| 118 | age_range: { min: 0, max: 100 }, | 120 | age_range: { min: 0, max: 100 }, |
| 119 | insurance_period: '终身', | 121 | insurance_period: '终身', |
| ... | @@ -140,17 +142,17 @@ export const PLAN_TEMPLATES = { | ... | @@ -140,17 +142,17 @@ export const PLAN_TEMPLATES = { |
| 140 | } | 142 | } |
| 141 | }, | 143 | }, |
| 142 | 144 | ||
| 143 | - // GC - 宏摯家傳承保險計劃 | 145 | + // GC - 宏挚家传保险计划 |
| 144 | 'savings-gc': { | 146 | 'savings-gc': { |
| 145 | - name: '宏摯家傳承保險計劃', | 147 | + name: '宏挚家传保险计划', |
| 146 | component: 'SavingsTemplate', | 148 | component: 'SavingsTemplate', |
| 147 | category: 'savings', | 149 | category: 'savings', |
| 148 | config: { | 150 | config: { |
| 149 | currency: 'USD', | 151 | currency: 'USD', |
| 150 | payment_periods: [ | 152 | payment_periods: [ |
| 151 | - '整付(0-100 岁)', | 153 | + '整付', |
| 152 | - '5 年(0-80 岁)', | 154 | + '3 年', |
| 153 | - '10 年(0-75 岁)' | 155 | + '5 年', |
| 154 | ], | 156 | ], |
| 155 | age_range: { min: 0, max: 100 }, | 157 | age_range: { min: 0, max: 100 }, |
| 156 | insurance_period: '终身', | 158 | insurance_period: '终身', |
| ... | @@ -173,17 +175,17 @@ export const PLAN_TEMPLATES = { | ... | @@ -173,17 +175,17 @@ export const PLAN_TEMPLATES = { |
| 173 | } | 175 | } |
| 174 | }, | 176 | }, |
| 175 | 177 | ||
| 176 | - // FA - 宏浚傳承保障計劃 | 178 | + // FA - 宏浚传承保障计划 |
| 177 | 'savings-fa': { | 179 | 'savings-fa': { |
| 178 | - name: '宏浚傳承保障計劃', | 180 | + name: '宏浚传承保障计划', |
| 179 | component: 'SavingsTemplate', | 181 | component: 'SavingsTemplate', |
| 180 | category: 'savings', | 182 | category: 'savings', |
| 181 | config: { | 183 | config: { |
| 182 | currency: 'USD', | 184 | currency: 'USD', |
| 183 | payment_periods: [ | 185 | payment_periods: [ |
| 184 | - '整付(0-100 岁)', | 186 | + '整付', |
| 185 | - '5 年(0-80 岁)', | 187 | + '2 年', |
| 186 | - '10 年(0-75 岁)' | 188 | + '5 年', |
| 187 | ], | 189 | ], |
| 188 | age_range: { min: 0, max: 100 }, | 190 | age_range: { min: 0, max: 100 }, |
| 189 | insurance_period: '终身', | 191 | insurance_period: '终身', |
| ... | @@ -206,17 +208,18 @@ export const PLAN_TEMPLATES = { | ... | @@ -206,17 +208,18 @@ export const PLAN_TEMPLATES = { |
| 206 | } | 208 | } |
| 207 | }, | 209 | }, |
| 208 | 210 | ||
| 209 | - // LV2 - 赤霞珠終身壽險計劃2(储蓄型终身寿险) | 211 | + // LV2 - 赤霞珠终身寿险计划2(储蓄型终身寿险) |
| 210 | 'savings-lv2': { | 212 | 'savings-lv2': { |
| 211 | - name: '赤霞珠終身壽險計劃2', | 213 | + name: '赤霞珠终身寿险计划2', |
| 212 | component: 'SavingsTemplate', | 214 | component: 'SavingsTemplate', |
| 213 | category: 'savings', | 215 | category: 'savings', |
| 214 | config: { | 216 | config: { |
| 215 | currency: 'USD', | 217 | currency: 'USD', |
| 216 | payment_periods: [ | 218 | payment_periods: [ |
| 217 | - '整付(0-100 岁)', | 219 | + '5 年', |
| 218 | - '5 年(0-80 岁)', | 220 | + '8 年', |
| 219 | - '10 年(0-75 岁)' | 221 | + '12 年', |
| 222 | + '15 年', | ||
| 220 | ], | 223 | ], |
| 221 | age_range: { min: 0, max: 100 }, | 224 | age_range: { min: 0, max: 100 }, |
| 222 | insurance_period: '终身', | 225 | insurance_period: '终身', | ... | ... |
-
Please register or login to post a comment