hookehuyr

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 (修复)
...@@ -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 +**选项**:是 / 否(默认:否)
397 +
398 +#### 第二层:提取选项(第一层选择"是"时显示)
399 +
400 +**问题**:提取选项
401 +
402 +**选项**
403 +1. 指定提取金额
404 +2. 最高固定提取金额
405 +
406 +#### 第三层:具体字段(根据第二层选择显示不同字段)
361 407
362 -#### 方式 1:年龄指定金额(按年龄提取) 408 +##### A. 指定提取金额模式
363 409
364 -**字段** 410 +**子选项**:提取方式
365 -- `start_age`:开始提取年龄(数字)
366 -- `withdrawal_period`:提取年期(数字,单位:年)
367 -- `annual_amount`:每年提取金额(数字,单位:分)
368 -- `currency`:币种(HKD/USD/CNY)
369 -- `increase_rate`:增加率(百分比,如 5 表示 5%)
370 411
371 -**示例** 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 - />
409 -
410 - <!-- 开始年龄 -->
411 - <PlanFieldAgePicker
412 - v-model="withdrawalPlan.start_age"
413 - label="开始年龄"
414 - placeholder="请选择开始提取年龄"
415 /> 465 />
416 466
417 - <!-- 提取年期 --> 467 + <!-- 第二层 + 第三层:仅当选择"是"时显示 -->
418 - <PlanFieldSelect 468 + <template v-if="form.withdrawal_enabled === '是'">
419 - v-model="withdrawalPlan.withdrawal_period" 469 + <h3>款项提取(容许减少名义金额)</h3>
420 - label="提取年期"
421 - placeholder="请选择提取年期"
422 - :options="withdrawalPeriodOptions"
423 - />
424 470
425 - <!-- 方式1:年龄指定金额 - 额外字段 --> 471 + <!-- 第二层:提取选项 -->
426 - <template v-if="withdrawalPlan.mode === '年龄指定金额'"> 472 + <PlanFieldRadio
427 - <!-- 每年提取金额 --> 473 + v-model="form.withdrawal_mode"
428 - <PlanFieldAmount 474 + label="提取选项"
429 - v-model="withdrawalPlan.annual_amount" 475 + :options="['指定提取金额', '最高固定提取金额']"
430 - label="每年提取金额" 476 + @change="onWithdrawalModeChange"
431 - placeholder="请输入金额"
432 - :currency="withdrawalPlan.currency"
433 /> 477 />
434 478
435 - <!-- 币种 --> 479 + <!-- 第三层 A:指定提取金额模式 -->
436 - <CurrencySelector 480 + <template v-if="form.withdrawal_mode === '指定提取金额'">
437 - v-model="withdrawalPlan.currency" 481 + <!-- 子选项:提取方式 -->
438 - label="币种" 482 + <PlanFieldRadio
439 - :options="['HKD', 'USD', 'CNY']" 483 + v-model="form.specified_amount_type"
440 - /> 484 + label="提取方式"
485 + :options="['按年岁', '按保单年度']"
486 + />
441 487
442 - <!-- 增加率 --> 488 + <!-- 按年岁字段 -->
443 - <div> 489 + <template v-if="form.specified_amount_type === '按年岁'">
444 - <div class="text-sm text-gray-600 mb-2">增加率(%)</div> 490 + <PlanFieldAgePicker
445 - <nut-input 491 + v-model="form.withdrawal_start_age"
446 - v-model="withdrawalPlan.increase_rate" 492 + label="由几岁开始"
447 - type="digit" 493 + placeholder="请输入开始提取年龄"
448 - placeholder="请输入增加率" 494 + />
495 +
496 + <PlanFieldSelect
497 + v-model="form.withdrawal_period"
498 + label="提取期(年)"
499 + placeholder="请选择提取期"
500 + :options="withdrawalPeriods"
501 + />
502 +
503 + <!-- 每年递增提取之百分比 -->
504 + <div>
505 + <div class="text-sm text-gray-700 mb-2">
506 + 每年递增提取之百分比(%)
507 + </div>
508 + <nut-input
509 + v-model="form.increase_rate"
510 + type="digit"
511 + placeholder="请输入递增百分比"
512 + />
513 + </div>
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="请输入开始提取年龄"
449 /> 539 />
450 - </div> 540 +
541 + <PlanFieldSelect
542 + v-model="form.withdrawal_period"
543 + label="提取期(年)"
544 + placeholder="请选择提取期"
545 + :options="withdrawalPeriods"
546 + />
547 + </template>
451 </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 ---
......
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: '终身',
......