hookehuyr

feat(product): 实现计划书功能并重命名产品中心页面

......@@ -17,3 +17,8 @@ unpackage/
.tmp/
CLAUDE.md
.claude/
# Office documents
*.docx
*.xlsx
*.pptx
......
......@@ -7,15 +7,21 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default']
AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default']
CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default']
DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default']
DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default']
FilterTabs: typeof import('./src/components/FilterTabs.vue')['default']
'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default']
IconFont: typeof import('./src/components/IconFont.vue')['default']
IndexNav: typeof import('./src/components/indexNav.vue')['default']
LifeInsuranceTemplate: typeof import('./src/components/PlanTemplates/LifeInsuranceTemplate.vue')['default']
ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default']
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutDatepicker: typeof import('@nutui/nutui-taro')['Datepicker']
NutEmpty: typeof import('@nutui/nutui-taro')['Empty']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
......@@ -27,17 +33,21 @@ declare module 'vue' {
OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default']
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default']
RadioGroup: typeof import('./src/components/PlanFields/RadioGroup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SavingsTemplate: typeof import('./src/components/SavingsTemplate.vue')['default']
SchemeA: typeof import('./src/components/PlanSchemes/SchemeA.vue')['default']
SchemeB: typeof import('./src/components/PlanSchemes/SchemeB.vue')['default']
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
SectionCard: typeof import('./src/components/SectionCard.vue')['default']
SectionItem: typeof import('./src/components/SectionItem.vue')['default']
SelectPicker: typeof import('./src/components/PlanFields/SelectPicker.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
}
}
......
# 计划书录入功能模块 - 架构设计方案
**创建时间**: 2026-02-06
**设计师**: Claude Code
**状态**: ✅ 已批准
**批准时间**: 2026-02-06
**版本**: v2.0
**最后更新**: 2026-02-06
**更新记录**
- v2.0 (2026-02-06): 整合储蓄型产品(GS/GC/FA/LV2),新增提取计划功能
- v1.0 (2026-02-06): 初始版本,支持人寿保险和重疾保险产品
---
## 📋 需求总结
### 核心需求
1. **产品驱动**:一个产品对应一个计划书模版,用户不需要选择模版类型
2. **保额配置**:支持不同币种(人民币、美元等),币种符号可配置
3. **年龄选择**:使用 NutUI Popup + Picker 多列选择器,3位数字,初始值018,传给后端的值是数字18
4. **缴费年期**:key 和 value 相同(如"整付(0-75 岁)")
5. **日期选择**:使用 DatePicker 日期选择器
6. **扩展性**:产品和模版的映射方式需要考虑未来扩展
### 用户流程
```
用户在首页/产品详情页点击"计划书"按钮
系统根据产品名称/ID自动选择对应的模版
显示计划书录入弹窗(包含动态表单)
用户填写表单(性别、年龄、出生年月日、是否吸烟、保额、缴费年期等)
提交 → 跳转到计划书提交结果页
```
---
## 🔍 现有实现分析
### 当前架构
```
首页 (index.vue)
└─ 点击"计划书"按钮
└─ 打开 PlanPopup
├─ SchemeA (硬编码)
└─ SchemeB (硬编码)
```
### 问题
1. ❌ 硬编码模版:通过 `currentScheme` 变量手动选择 SchemeA 或 SchemeB
2. ❌ 缺少产品-模版映射:没有根据产品自动选择模版的机制
3. ❌ 表单组件有限:缺少保额(币种配置)、年龄选择器、日期选择器等组件
4. ❌ 扩展性差:新增产品或模版需要修改代码
### 现有表单字段
| 模版 | 字段 | 类型 |
|------|------|------|
| SchemeA | 客户姓名、性别、年龄、行业、年收入、家庭结构、保险需求、期望收益率 | Input、Radio、Picker、多选标签 |
| SchemeB | 币种、计划、附加计划、性别、年龄、保险期间、交费期间、年交保费 | 固定值、Radio、Input |
### 产品 API 数据结构
```javascript
{
id: 1,
product_name: "WIOP3E 盈传创富保障计划 3 - 优选版",
form_sn: "xxx", // 关联表单sn(可能用来关联模版)
categories: [...],
tags: [...],
documents: [...],
cover_image: "..."
}
```
### 计划书文档定义
**第一版模版**`docs/plan/计划书模版.md`):
**人寿产品**(WIOP3E、WIOP3):
- 核心信息:性别、年龄、出生年月日、是否吸烟
- 保额
- 缴费年期:整付(0-75 岁)、5 年(0-70 岁)、10 年(0-70 岁)
**重疾产品**(MPC、MBC PRO、MBC2):
- 核心信息:性别、年龄、出生年月日、是否吸烟
- 保额
- 缴费年期:10 年(15 日 - 65 岁)、20 年(15 日 - 65 岁)、25 年(15 日 - 60 岁)
**第二版模版**`docs/plan/计划书模版2.docx`):
**储蓄型产品**(统一逻辑):
- 产品列表:
- GS(宏摯傳承保障計劃)
- GC(宏摯家傳承保險計劃)
- FA(宏浚傳承保障計劃)
- LV2(赤霞珠終身壽險計劃2)
- 核心信息:性别、年龄、出生年月日、是否吸烟
- 保额
- 缴费年期:各产品不同(详见配置文件)
- **提取计划功能**(所有储蓄产品通用):
- 方式 1:年龄指定金额(按年龄提取)
- 开始年龄(start_age)
- 提取年期(withdrawal_period)
- 每年提取金额(annual_amount)
- 币种(currency):支持 HKD、USD、CNY
- 增加率(increase_rate):每年提取金额的增长百分比
- 方式 2:最高固定金额(按年龄提取)
- 开始年龄(start_age)
- 提取年期(withdrawal_period)
- **多币种支持**:提取金额支持多币种(HKD、USD、CNY)
---
## 🏗️ 架构设计方案
### 方案 A:产品 API 驱动(推荐)⭐
#### 核心思路
**后端在产品 API 返回数据中已有 `form_sn` 字段,用于指定该产品使用的计划书模版。**
#### 数据结构设计
**1. 产品 API 返回数据(已有字段)**
```javascript
// src/api/get_product.js
{
id: 1,
product_name: "WIOP3E 盈传创富保障计划 3 - 优选版",
form_sn: "life-insurance-wiop3e", // ✅ 已有字段:计划书模版标识(就是模版名称)
// 注意:如果需要模版配置(如币种、缴费年期选项),可以新增 plan_config 字段
// 或者前端根据 form_sn 从配置文件中读取对应的配置
// ... 其他字段
}
```
**2. 模版配置文件**
```javascript
// src/config/plan-templates.js
export const PLAN_TEMPLATES = {
// form_sn: life-insurance-wiop3e
'life-insurance-wiop3e': {
name: 'WIOP3E 盈传创富保障计划 3 - 优选版',
component: 'LifeInsuranceTemplate',
// 模版配置(可选:如果后端不提供 plan_config,则使用这里的配置)
config: {
currency: "USD", // 币种:USD/CNY
payment_periods: [ // 缴费年期选项
"整付(0-75 岁)",
"5 年(0-70 岁)",
"10 年(0-70 岁)"
],
age_range: { min: 0, max: 75 }, // 年龄范围
insurance_period: "终身" // 保险期间
}
},
// form_sn: critical-illness-mpc
'critical-illness-mpc': {
name: 'MPC 守护无间重疾',
component: 'CriticalIllnessTemplate',
config: {
currency: "CNY",
payment_periods: [
"10 年(15 日 - 65 岁)",
"20 年(15 日 - 65 岁)",
"25 年(15 日 - 60 岁)"
],
age_range: { min: 0, max: 65 },
insurance_period: "终身"
}
}
}
```
**3. 功能开关配置(未来扩展用)**
```javascript
// src/config/plan-templates.js
/**
* 全局功能开关
* @description 用于控制实验性功能或未来扩展功能的开关
* @example 开启多币种功能:设置 MULTI_CURRENCY_ENABLED = true
*/
export const FEATURE_FLAGS = {
// 多币种切换功能(当前:关闭,未来:开启)
// false: 方案 1 - 固定币种(当前实现)
// true: 方案 2 - 支持多币种切换(未来扩展)
MULTI_CURRENCY_ENABLED: false,
}
```
**4. 出生日期驱动年龄(推荐方案)**
#### 核心逻辑
```
用户选择"出生年月日"
系统自动计算年龄 = 当前年份 - 出生年份
年龄字段自动填充
用户可以手动修改年龄(特殊情况)
```
#### 优势
-**更准确**:出生日期是准确的个人信息
-**自动计算**:避免用户手动输入错误
-**灵活性**:用户可以修改年龄
-**逻辑简单**:不需要复杂的日期限制
#### 实现示例
```javascript
// 用户选择出生日期
const onBirthdayChange = (birthday) => {
form.birthday = birthday
// 自动计算年龄
const birthYear = new Date(birthday).getFullYear()
const currentYear = new Date().getFullYear()
form.age = currentYear - birthYear
}
```
---
#### 组件架构
```
PlanFormContainer (计划书表单容器)
└─ 根据产品 form_sn 动态加载模版组件
├─ LifeInsuranceTemplate (人寿保险模版)
├─ CriticalIllnessTemplate (重疾保险模版)
├─ SavingsTemplate (储蓄型产品模版)
└─ 其他模版...
动态表单字段组件:
├─ AgePicker (年龄选择器)
├─ AmountInput (保额输入 + 币种)
├─ DatePicker (日期选择器)
├─ RadioGroup (单选)
├─ SelectPicker (下拉选择)
└─ WithdrawalPlanSelector (提取计划选择器 - 储蓄产品专用)
提取计划相关组件:
├─ WithdrawalPlanSelector.vue (主选择器:方式1/方式2)
├─ SpecifiedAmountForm.vue (年龄指定金额表单)
├─ FixedAmountForm.vue (最高固定金额表单)
└─ CurrencySelector.vue (币种选择器)
```
---
## 🎯 推荐方案
**选择:方案 A(产品 API 驱动)**
### 理由
1.**扩展性强**:新增产品只需后端配置 `plan_template` 字段
2.**维护成本低**:产品信息和模版信息都在同一处
3.**类型安全**:前端可以使用 TypeScript 验证模版配置
4.**支持动态配置**:可以通过 CMS 后台管理产品和模版的映射关系
### 后端配合要求
1. **产品已有字段**`form_sn` - 模版标识(如 `life-insurance-wiop3e`
- ✅ 这个字段已经存在,可以直接使用
2. **可选增强**:如果需要动态配置模版参数(如币种、缴费年期选项),可以新增 `plan_config` 字段:
- `currency`: 币种(USD/CNY)
- `payment_periods`: 缴费年期选项数组
- `age_range`: 年龄范围({ min, max })
- `insurance_period`: 保险期间
**注意**:如果后端不提供 `plan_config`,前端会从 `src/config/plan-templates.js` 配置文件中读取默认配置。
---
## 📂 文件结构
```
src/
├── api/
│ └── plan.js # 计划书 API(新增)
├── config/
│ └── plan-templates.js # 计划书模版配置(新增)
├── components/
│ ├── PlanFormContainer.vue # 计划书表单容器(新增)
│ ├── PlanPopup/
│ │ └── index.vue # 弹窗容器(已有)
│ │
│ ├── PlanTemplates/ # 具体模版组件(新增)
│ │ ├── LifeInsuranceTemplate.vue
│ │ ├── CriticalIllnessTemplate.vue
│ │ ├── SavingsTemplate.vue # 储蓄型产品模版(GS/GC/FA/LV2)
│ │ └── README.md # 模版开发文档
│ │
│ └── PlanFields/ # 通用表单字段组件(新增)
│ ├── AgePicker.vue # 年龄选择器
│ ├── AmountInput.vue # 保额输入
│ ├── DatePicker.vue # 日期选择器
│ ├── RadioGroup.vue # 单选组
│ ├── SelectPicker.vue # 下拉选择
│ └── WithdrawalPlan/ # 提取计划组件(储蓄产品专用)
│ ├── WithdrawalPlanSelector.vue # 主选择器
│ ├── SpecifiedAmountForm.vue # 年龄指定金额表单
│ ├── FixedAmountForm.vue # 最高固定金额表单
│ └── CurrencySelector.vue # 币种选择器
└── pages/
├── index.vue # 首页(修改)
└── product-detail/
└── index.vue # 产品详情页(修改)
```
---
## 🔧 表单字段组件设计
### 1. AgePicker(年龄选择器)
**需求**
- 使用 NutUI Popup + Picker 多列样式
- 年龄范围:0-120 岁
- 显示格式:3位数字(018)
- 提交格式:数字(18)
### 2. AmountInput(保额输入)
**需求**
- 支持不同币种(CNY、USD、HKD、EUR)
- 小数点后2位控制
- 币种符号可配置
### 3. DatePicker(出生日期选择器)→ 自动计算年龄
**优化方案**
- 用户选择出生年月日
- 系统自动计算年龄 = 当前年份 - 出生年份
- 年龄字段自动填充
- 用户可以手动修改年龄(特殊情况)
### 4. RadioGroup(单选组)
**需求**
- 性别、是否吸烟等单选项
### 5. SelectPicker(下拉选择)
**需求**
- 缴费年期选择
- key 和 value 相同(如"整付(0-75 岁)")
### 6. WithdrawalPlanSelector(提取计划选择器)⭐ 储蓄产品专用
**业务场景**:储蓄型产品(GS/GC/FA/LV2)支持提取计划功能
**两种提取方式**
#### 方式 1:年龄指定金额(按年龄提取)
**字段**
- `start_age`:开始提取年龄(数字)
- `withdrawal_period`:提取年期(数字,单位:年)
- `annual_amount`:每年提取金额(数字,单位:分)
- `currency`:币种(HKD/USD/CNY)
- `increase_rate`:增加率(百分比,如 5 表示 5%)
**示例**
```javascript
{
mode: 'specified_amount',
start_age: 60,
withdrawal_period: 10,
annual_amount: 5000000, // 50,000.00 HKD
currency: 'HKD',
increase_rate: 5 // 每年增长 5%
}
```
#### 方式 2:最高固定金额(按年龄提取)
**字段**
- `start_age`:开始提取年龄(数字)
- `withdrawal_period`:提取年期(数字,单位:年)
**示例**
```javascript
{
mode: 'fixed_amount',
start_age: 60,
withdrawal_period: 10
}
```
**组件设计**
```vue
<template>
<div>
<!-- 提取方式选择 -->
<PlanFieldRadio
v-model="withdrawalPlan.mode"
label="提取方式"
:options="['年龄指定金额', '最高固定金额']"
/>
<!-- 开始年龄 -->
<PlanFieldAgePicker
v-model="withdrawalPlan.start_age"
label="开始年龄"
placeholder="请选择开始提取年龄"
/>
<!-- 提取年期 -->
<PlanFieldSelect
v-model="withdrawalPlan.withdrawal_period"
label="提取年期"
placeholder="请选择提取年期"
:options="withdrawalPeriodOptions"
/>
<!-- 方式1:年龄指定金额 - 额外字段 -->
<template v-if="withdrawalPlan.mode === '年龄指定金额'">
<!-- 每年提取金额 -->
<PlanFieldAmount
v-model="withdrawalPlan.annual_amount"
label="每年提取金额"
placeholder="请输入金额"
:currency="withdrawalPlan.currency"
/>
<!-- 币种 -->
<CurrencySelector
v-model="withdrawalPlan.currency"
label="币种"
:options="['HKD', 'USD', 'CNY']"
/>
<!-- 增加率 -->
<div>
<div class="text-sm text-gray-600 mb-2">增加率(%)</div>
<nut-input
v-model="withdrawalPlan.increase_rate"
type="digit"
placeholder="请输入增加率"
/>
</div>
</template>
</div>
</template>
```
---
## 📊 数据流
```
用户点击"计划书"按钮
openPlanPopup(productId)
查找产品对象
获取 product.form_sn(已有字段)
根据 form_sn 从 PLAN_TEMPLATES 配置中查找对应模版
加载对应的模版组件(如 LifeInsuranceTemplate)
显示动态表单(使用 product.plan_config 或配置文件中的默认配置)
用户填写表单
提交 → handlePlanSubmit(formData)
跳转到结果页
```
---
## 🚀 实施步骤
### 阶段 1:基础架构(2-3天)
1. **创建模版配置文件**
- [x] 创建 `src/config/plan-templates.js`
- [x] 定义模版映射关系(人寿、重疾产品)
- [ ] 添加储蓄型产品配置(GS/GC/FA/LV2)
- [ ] 编写模版配置文档
2. **创建通用表单字段组件**
- [x] `AgePicker.vue` - 年龄选择器
- [x] `AmountInput.vue` - 保额输入
- [x] `DatePicker.vue` - 日期选择器
- [x] `RadioGroup.vue` - 单选组
- [x] `SelectPicker.vue` - 下拉选择
3. **创建计划书 API**
- [x] 创建 `src/api/plan.js`
- [x] 定义提交计划书 API 接口
### 阶段 2:模版组件(2-3天)
1. **创建具体模版组件**
- [x] `LifeInsuranceTemplate.vue` - 人寿保险模版
- [x] `CriticalIllnessTemplate.vue` - 重疾保险模版
- [ ] `SavingsTemplate.vue` - 储蓄型产品模版(GS/GC/FA/LV2)
2. **创建表单容器**
- [x] `PlanFormContainer.vue` - 动态模版容器
3. **创建提取计划组件**(储蓄产品专用)
- [ ] `WithdrawalPlanSelector.vue` - 主选择器
- [ ] `SpecifiedAmountForm.vue` - 年龄指定金额表单
- [ ] `FixedAmountForm.vue` - 最高固定金额表单
- [ ] `CurrencySelector.vue` - 币种选择器
### 阶段 3:集成测试(1-2天)
1. **修改首页**
- [ ] 集成 `PlanFormContainer`
- [ ] 传递产品对象
2. **修改产品详情页**
- [ ] 添加"计划书"按钮
- [ ] 集成 `PlanFormContainer`
3. **测试**
- [ ] 测试不同产品的模版加载
- [ ] 测试表单验证
- [ ] 测试提交流程
---
## 📝 API 接口设计
### 1. 获取产品详情(已存在,包含 form_sn)
**请求**
```javascript
GET /srv/?a=get_product&t=detail&i=1
```
**返回**
```javascript
{
"code": 1,
"msg": "success",
"data": {
"id": 1,
"product_name": "WIOP3E 盈传创富保障计划 3 - 优选版",
"form_sn": "life-insurance-wiop3e", // ✅ 已有字段,用于指定模版
// 可选:如果需要动态配置模版参数
"plan_config": {
"currency": "USD",
"payment_periods": [
"整付(0-75 岁)",
"5 年(0-70 岁)",
"10 年(0-70 岁)"
],
"age_range": { "min": 0, "max": 75 },
"insurance_period": "终身"
},
// ... 其他现有字段
}
}
```
### 2. 提交计划书(新增)
**请求**
```javascript
POST /srv/?a=submit_plan
{
"product_id": 1,
"template": "life-insurance-wiop3e",
"form_data": {
"gender": "男",
"age": 18,
"birthday": "1990-01-01",
"smoker": "否",
"coverage": 100000, // 单位:分
"payment_period": "10 年(0-70 岁)"
}
}
```
**返回**
```javascript
{
"code": 1,
"msg": "提交成功",
"data": {
"plan_id": 123,
"status": "processing", // processing | generated
"estimated_time": 300 // 预计生成时间(秒)
}
}
```
---
## ✅ 验收标准
- [ ] 用户点击产品"计划书"按钮,自动显示对应的模版
- [ ] 年龄选择器显示3位数字,提交时转换为数字
- [ ] 保额输入支持不同币种,保留2位小数
- [ ] 日期选择器限制年龄范围
- [ ] 缴费年期选项完整,key 和 value 相同
- [ ] 表单验证正常,必填项有提示
- [ ] 提交成功后跳转到结果页
- [ ] 支持扩展新产品模版(无需修改代码)
---
## 📚 参考文档
- [NutUI Picker 文档](https://nutui.jd.com/3/vant picker)
- [NutUI DatePicker 文档](https://nutui.jd.com/3/vant date-picker)
- [现有计划书模版文档](计划书模版.md)
- [项目经验教训总结](../lessons-learned.md)
---
## 💡 未来扩展
1. **CMS 后台管理**
- 通过后台管理产品和模版的映射关系
- 动态配置表单字段
- 无需修改代码即可新增模版
2. **表单字段扩展**
- 支持更多字段类型(多选、文件上传等)
- 支持字段联动(如年龄变化影响保额范围)
- 支持条件显示(如某些产品显示特定字段)
3. **预览功能**
- 实时预览计划书
- 保存草稿功能
- 历史记录查看
4. **批量生成**
- 支持批量导入客户信息
- 批量生成计划书
- 导出为 Excel/PDF
---
**文档版本**: v1.0
**创建时间**: 2026-02-06
**最后更新**: 2026-02-06
## 一、人寿产品
1. WIOP3E 盈传创富保障计划 3 - 优选版
2. WIOP3 - 盈传创富保障计划 3
### 核心信息
性别、年龄、出生年月日、是否吸烟
### 保额
### 缴费年期
1. 整付(0-75 岁)
2. 5 年(0-70 岁)
3. 10 年(0-70 岁)
## 二、重疾产品
1. MPC 守护无间重疾
2. MBC PRO 活跃人生重疾保 PRO
3. MBC2 活跃人生重疾保 2
### 核心信息
性别、年龄、出生年月日、是否吸烟
### 保额
### 缴费年期
1. 10 年(15 日 - 65 岁)
2. 20 年(15 日 - 65 岁)
3. 25 年(15 日 - 60 岁)
\ No newline at end of file
# 计划书功能测试实现记录
**日期**: 2026-02-06
**目的**: 前端测试计划书录入功能(后端接口尚未准备好)
---
## 📋 已完成的任务
### ✅ 任务1: 添加计划书按钮
#### 1.1 首页热卖产品卡片
**文件**: `src/pages/index/index.vue`
**修改内容**:
- 替换了旧的 `PlanPopup` + `SchemeA/SchemeB` 系统
- 集成新的 `PlanFormContainer` 组件
- 添加了计划书弹窗状态管理 (`showPlanPopup`, `selectedProduct`)
- 更新了 `openPlanPopup()` 函数,根据产品ID查找产品对象
- 更新了 `handlePlanSubmit()` 函数
**计划书按钮**: 已存在(第84-91行),无需新增
#### 1.2 产品中心列表卡片
**文件**: `src/pages/knowledge-base/index.vue`
**修改内容**:
- 在产品卡片上添加了"详情"和"计划书"两个按钮
- 集成 `PlanFormContainer` 组件
- 添加了计划书弹窗状态管理
- 添加了 `openPlanPopup(product)` 函数
- 添加了 `handlePlanSubmit(formData)` 函数
**按钮布局**:
```
[详情] [计划书]
```
#### 1.3 产品详情页
**文件**: `src/pages/product-detail/index.vue`
**修改内容**:
- 在页面底部添加了固定定位的"制作计划书"按钮
- 集成 `PlanFormContainer` 组件
- 添加了计划书弹窗状态管理
- 添加了 `openPlanPopup()` 函数
- 添加了 `handlePlanSubmit(formData)` 函数
**按钮样式**: 全宽按钮,固定在页面底部
---
### ✅ 任务3: Mock 数据(热卖产品)
**文件**: `src/pages/index/index.vue`
**函数**: `fetchHotProducts()`
**Mock 数据包含全部7种产品类型**:
#### 人寿保险(2种)
1. **WIOP3E 盈传创富保障计划 3 - 优选版**
- form_sn: `life-insurance-wiop3e`
- 标签: 终身寿险、美元
2. **WIOP3 - 盈传创富保障计划 3**
- form_sn: `life-insurance-wiop3`
- 标签: 终身寿险、美元
#### 重疾保险(3种)
3. **MPC 守护无间重疾**
- form_sn: `critical-illness-mpc`
- 标签: 重疾保障、人民币
4. **MBC PRO 活跃人生重疾保 PRO**
- form_sn: `critical-illness-mbc-pro`
- 标签: 重疾保障、人民币
5. **MBC2 活跃人生重疾保 2**
- form_sn: `critical-illness-mbc2`
- 标签: 重疾保障、人民币
#### 储蓄型产品(4种)
6. **GS - 宏摯傳承保障計劃**
- form_sn: `savings-gs`
- 标签: 储蓄分红、美元
7. **GC - 宏摯家傳承保險計劃**
- form_sn: `savings-gc`
- 标签: 储蓄分红、美元
8. **FA - 宏浚傳承保障計劃**
- form_sn: `savings-fa`
- 标签: 储蓄分红、美元
9. **LV2 - 赤霞珠終身壽險計劃2**
- form_sn: `savings-lv2`
- 标签: 储蓄型终身寿险、美元
---
## 🚫 待清理的测试数据
### ⚠️ 重要提醒
**测试完成后需要移除以下内容**:
#### 1. 首页 Mock 数据
**文件**: `src/pages/index/index.vue`
**位置**: `fetchHotProducts()` 函数
**清理步骤**:
```javascript
// 删除测试数据部分(⚠️ 测试数据开始 - 测试完成后需要移除 ⚠️)
// 恢复真实API调用:
const res = await listAPI({
recommend: 'hot'
});
if (res.code === 1 && res.data && res.data.list) {
hotProducts.value = res.data.list;
}
```
#### 2. 提交 API 接口对接
**文件**: `src/pages/index/index.vue`, `src/pages/product-detail/index.vue`, `src/pages/knowledge-base/index.vue`
**当前状态**:
```javascript
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
```
**对接步骤**:
1. 导入 API: `import { submitPlanAPI } from '@/api/plan'`
2. 替换 `handlePlanSubmit()` 中的 TODO 部分:
```javascript
const res = await submitPlanAPI({
product_id: selectedProduct.value.id,
template: selectedProduct.value.form_sn,
form_data: formData
});
if (res.code === 1) {
// 跳转到成功页面
go('/pages/plan-submit-result/index', {
success: 'true',
plan_id: res.data.plan_id
});
}
```
---
## 📊 测试覆盖范围
### 模版类型测试
-**LifeInsuranceTemplate**: WIOP3E, WIOP3
-**CriticalIllnessTemplate**: MPC, MBC PRO, MBC2
-**SavingsTemplate**: GS, GC, FA, LV2
### 功能测试
- ✅ 计划书按钮显示在多个页面
- ✅ 点击按钮打开对应产品的计划书表单
- ✅ 根据产品的 `form_sn` 自动加载正确的模版
- ✅ 表单数据结构和验证
### 页面集成测试
- ✅ 首页 → 热卖产品卡片 → 计划书按钮
- ✅ 产品中心 → 产品列表卡片 → 计划书按钮
- ✅ 产品详情页 → 制作计划书按钮
---
## 🔍 后端接口需求
### 必需字段
产品 API 需要返回以下字段:
```javascript
{
id: 1,
product_name: "产品名称",
form_sn: "life-insurance-wiop3e", // 关键:计划书模版标识
recommend: "hot", // 可选:推荐标识
tags: [ // 可选:产品标签
{
id: 1,
name: "终身寿险",
bg_color: "#DBEAFE",
text_color: "#1E40AF"
}
]
}
```
### 提交计划书 API
**端点**: `/srv/?a=submit_plan&t=submit`
**方法**: POST
**参数**:
```javascript
{
product_id: number, // 产品ID
template: string, // form_sn
form_data: {
// 基础字段(所有模版)
gender: string, // "男" | "女"
age: number, // 年龄
birthday: string, // "YYYY-MM-DD"
smoker: string, // "是" | "否"
coverage: number, // 保额(分)
payment_period: string, // 缴费年期
// 储蓄产品专用字段
withdrawal_plan: {
mode: string, // "年龄指定金额" | "最高固定金额"
start_age: number, // 开始年龄
withdrawal_period: string, // 提取年期
// mode = "年龄指定金额" 时的额外字段
annual_amount: number, // 每年提取金额(分)
currency: string, // 币种
increase_rate: number // 增加率
}
}
}
```
---
## 📝 清理清单
测试完成后,按以下顺序清理:
- [ ] **步骤1**: 移除首页 Mock 数据,恢复真实 API
- [ ] **步骤2**: 对接提交计划书 API
- [ ] **步骤3**: 移除所有 `TODO: 后端接口还没有准备好` 的注释
- [ ] **步骤4**: 移除 `console.log('⚠️ 使用测试数据...')` 等调试日志
- [ ] **步骤5**: 测试完整的提交流程
- [ ] **步骤6**: 删除本测试文档(或归档)
---
## 🧪 测试指南
### 手动测试步骤
1. **首页测试**:
- 打开首页,查看"热卖产品"区域
- 应显示9个产品卡片(2人寿 + 3重疾 + 4储蓄)
- 点击任意产品的"计划书"按钮
- 应打开对应的计划书表单模版
2. **产品中心测试**:
- 进入"产品中心"页面
- 选择任意分类或搜索
- 点击产品卡片上的"计划书"按钮
- 应打开对应的计划书表单模版
3. **产品详情页测试**:
- 进入任意产品详情页
- 点击底部"制作计划书"按钮
- 应打开对应的计划书表单模版
4. **表单测试**:
- 人寿保险模版:填写性别、年龄、出生年月日等
- 重疾保险模版:填写基础字段
- 储蓄产品模版:填写基础字段 + 提取计划功能
---
**最后更新**: 2026-02-06
**维护者**: Claude Code
/**
* 计划书 API 接口
*
* @description 计划书相关的 API 接口定义
* @module api/plan
* @author Claude Code
* @created 2026-02-06
*/
import { fn } from './fn'
const Api = {
Submit: '/srv/?a=submit_plan&t=submit'
}
/**
* 提交计划书
*
* @description 提交计划书表单数据到后端,生成计划书 PDF
* @param {Object} params - 请求参数
* @param {number} params.product_id - 产品 ID
* @param {string} params.template - 模版标识(form_sn)
* @param {Object} params.form_data - 表单数据
* @param {string} params.form_data.gender - 性别("男" | "女")
* @param {number} params.form_data.age - 年龄
* @param {string} params.form_data.birthday - 出生日期(格式:YYYY-MM-DD)
* @param {string} params.form_data.smoker - 是否吸烟("是" | "否")
* @param {number} params.form_data.coverage - 保额(单位:分)
* @param {string} params.form_data.payment_period - 缴费年期
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: {
* plan_id: number; // 计划书 ID
* status: string; // 状态:processing | generated
* estimated_time: number; // 预计生成时间(秒)
* };
* }>}
*
* @example
* const res = await submitPlanAPI({
* product_id: 1,
* template: 'life-insurance-wiop3e',
* form_data: {
* gender: '男',
* age: 18,
* birthday: '1990-01-01',
* smoker: '否',
* coverage: 100000, // 1000.00 元(单位:分)
* payment_period: '10 年(0-70 岁)'
* }
* })
*
* if (res.code === 1) {
* console.log('计划书 ID:', res.data.plan_id)
* console.log('预计生成时间:', res.data.estimated_time, '秒')
* }
*/
export const submitPlanAPI = (params) => fn({
url: Api.Submit,
method: 'POST',
data: params
})
// 注意:查询计划书状态功能暂不需要
// 计划书生成是半自动流程(小程序 → 公司 → 手动上传 → 用户查看)
// 用户在"我的计划书"页面查看生成的计划书
......@@ -13,7 +13,7 @@ const pages = [
'pages/document-demo/index',
'pages/onboarding/index',
'pages/family-office/index',
'pages/knowledge-base/index',
'pages/product-center/index',
'pages/product-detail/index',
'pages/category-list/index',
'pages/material-list/index',
......
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
<!-- 触发区域 -->
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
:class="{ 'bg-gray-50': showPicker }"
@tap="openPicker"
>
<span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- Picker 弹窗 -->
<nut-popup
position="bottom"
v-model:visible="showPicker"
:z-index="9999"
:overlay="true"
>
<nut-picker
:columns="ageColumns"
:default-value="defaultValue"
@confirm="onConfirm"
@cancel="showPicker = false"
/>
</nut-popup>
</div>
</template>
<script setup>
/**
* 年龄选择器组件
*
* @description 使用 NutUI Popup + Picker 实现年龄选择
* - 显示格式:3位数字(如 018 表示 18 岁)
* - 提交格式:数字(如 18)
* - 年龄范围:0-120 岁
* @author Claude Code
* @example
* <AgePicker
* v-model="age"
* label="年龄"
* placeholder="请选择年龄"
* />
*/
import { ref, computed } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择年龄'
},
/**
* 绑定的值(数字)
* @type {number}
*/
modelValue: {
type: Number,
default: null
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {number} value - 选中的年龄(数字)
*/
'update:modelValue'
])
/**
* 控制 Picker 显示
* @type {Ref<boolean>}
*/
const showPicker = ref(false)
/**
* 打开选择器
*/
const openPicker = () => {
showPicker.value = true
}
/**
* 年龄选项(3位数字格式)
* @description 生成 000-120 的年龄选项数组
* @returns {Array<Array<{text: string, value: number}>>} Picker 列格式
*
* @example
* // 返回值示例
* [
* [
* { text: '000', value: 0 },
* { text: '001', value: 1 },
* ...
* { text: '018', value: 18 },
* ...
* { text: '120', value: 120 }
* ]
* ]
*/
const ageColumns = computed(() => {
const ages = []
for (let i = 0; i <= 120; i++) {
// 0, 1, 2 -> '000', '001', '002'
const ageStr = i.toString().padStart(3, '0')
ages.push({ text: ageStr, value: i })
}
return [ages]
})
/**
* 默认选中的值(3位数字格式)
* @description 如果没有值,默认显示 018(18岁)
* @returns {Array<string>} Picker 默认值格式
*
* @example
* // modelValue = 18
* defaultValue() // 返回: ['018']
*
* // modelValue = null
* defaultValue() // 返回: ['018']
*/
const defaultValue = computed(() => {
const age = props.modelValue || 18
return [age.toString().padStart(3, '0')]
})
/**
* 显示的值(数字格式)
* @description 将数字转换为字符串显示
* @returns {string} 显示文本
*
* @example
* // modelValue = 18
* displayValue() // 返回: '18'
*
* // modelValue = null
* displayValue() // 返回: ''
*/
const displayValue = computed(() => {
return props.modelValue ? props.modelValue.toString() : ''
})
/**
* 确认选择
* @param {Object} params - Picker 返回参数
* @param {Array} params.selectedOptions - 选中的选项数组
*
* @example
* // 用户选择 018
* onConfirm({ selectedOptions: [{ text: '018', value: 18 }] })
* // -> emit('update:modelValue', 18)
*/
const onConfirm = ({ selectedOptions }) => {
const age = selectedOptions[0]?.value
if (age !== undefined) {
emit('update:modelValue', age)
}
showPicker.value = false
}
</script>
<style lang="less" scoped>
/* 组件样式 */
</style>
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
<!-- 触发区域 -->
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
:class="{ 'bg-gray-50': showDatePicker }"
@tap="openDatePicker"
>
<span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- DatePicker 弹窗 -->
<nut-datepicker
v-model="showDatePicker"
:min-date="minDate"
:max-date="maxDate"
@confirm="onConfirm"
>
</nut-datepicker>
</div>
</template>
<script setup>
/**
* 日期选择器组件
*
* @description 使用 NutUI DatePicker 实现日期选择
* - 支持年龄范围限制(minAge, maxAge)
* - 格式:YYYY-MM-DD
* - 可触发自动计算年龄
* @author Claude Code
* @example
* <DatePicker
* v-model="birthday"
* label="出生年月日"
* placeholder="请选择日期"
* :min-age="0"
* :max-age="120"
* @change="onBirthdayChange"
* />
*/
import { ref, computed } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择日期'
},
/**
* 绑定的值(格式:YYYY-MM-DD)
* @type {string}
*/
modelValue: {
type: String,
default: ''
},
/**
* 最小年龄(用于计算最大出生日期)
* @type {number}
* @default 0
*/
minAge: {
type: Number,
default: 0
},
/**
* 最大年龄(用于计算最小出生日期)
* @type {number}
* @default 120
*/
maxAge: {
type: Number,
default: 120 }
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {string} value - 选中的日期(格式:YYYY-MM-DD)
*/
'update:modelValue',
/**
* 值变化事件(可用于触发自动计算年龄)
* @event change
* @param {string} value - 选中的日期(格式:YYYY-MM-DD)
*/
'change'
])
/**
* 控制 DatePicker 显示
* @type {Ref<boolean>}
*/
const showDatePicker = ref(false)
/**
* 打开日期选择器
*/
const openDatePicker = () => {
showDatePicker.value = true
}
/**
* 计算最小可选日期(基于最大年龄)
* @description maxAge 岁对应的出生日期
* @type {ComputedRef<Date>}
* @example
* // maxAge = 75, 当前日期 = 2026-02-06
* // minDate() // 返回: 1951-02-06
*/
const minDate = computed(() => {
const date = new Date()
date.setFullYear(date.getFullYear() - props.maxAge)
return date
})
/**
* 计算最大可选日期(基于最小年龄)
* @description minAge 岁对应的出生日期
* @type {ComputedRef<Date>}
* @example
* // minAge = 0, 当前日期 = 2026-02-06
* // maxDate() // 返回: 2026-02-06
*/
const maxDate = computed(() => {
const date = new Date()
date.setFullYear(date.getFullYear() - props.minAge)
return date
})
/**
* 显示的值
* @type {ComputedRef<string>}
*/
const displayValue = computed(() => {
return props.modelValue || ''
})
/**
* 确认选择
* @param {Object} values - DatePicker 返回的日期对象
*
* @example
* // 用户选择 2020-01-01
* onConfirm(new Date('2020-01-01'))
* // -> emit('update:modelValue', '2020-01-01')
* // -> emit('change', '2020-01-01')
*/
const onConfirm = (values) => {
const date = values
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const formattedDate = `${year}-${month}-${day}`
emit('update:modelValue', formattedDate)
emit('change', formattedDate)
showDatePicker.value = false
}
</script>
<style lang="less" scoped>
/* 组件样式 */
</style>
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
<!-- Radio Group -->
<nut-radio-group v-model="selectedValue" direction="horizontal" class="mb-4">
<nut-radio
v-for="option in options"
:key="option"
:label="option"
class="mr-8"
>
{{ option }}
</nut-radio>
</nut-radio-group>
</div>
</template>
<script setup>
/**
* 单选组组件
*
* @description 使用 NutUI RadioGroup 实现单选功能
* - 支持 v-model 双向绑定
* - 横向排列
* @author Claude Code
* @example
* <RadioGroup
* v-model="gender"
* label="性别"
* :options="['男', '女']"
* />
*/
import { computed } from 'vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 选项数组
* @type {Array<string>}
* @example ['男', '女']
* @example ['是', '否']
*/
options: {
type: Array,
required: true
},
/**
* 绑定的值
* @type {string}
*/
modelValue: {
type: String,
default: ''
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {string} value - 选中的选项
*/
'update:modelValue'
])
/**
* 当前选中的值(用于 v-model)
* @type {ComputedRef<string>}
*/
const selectedValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>
<style lang="less" scoped>
/* 组件样式 */
</style>
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
<!-- 触发区域 -->
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
:class="{ 'bg-gray-50': showPicker }"
@tap="openPicker"
>
<span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- Picker 弹窗 -->
<nut-popup
position="bottom"
v-model:visible="showPicker"
:z-index="9999"
:overlay="true"
>
<nut-picker
:columns="pickerColumns"
@confirm="onConfirm"
@cancel="showPicker = false"
/>
</nut-popup>
</div>
</template>
<script setup>
/**
* 下拉选择器组件
*
* @description 使用 NutUI Picker 实现下拉选择功能
* - key 和 value 相同(如"整付(0-75 岁)")
* - 适用于缴费年期等场景
* @author Claude Code
* @example
* <SelectPicker
* v-model="paymentPeriod"
* label="缴费年期"
* placeholder="请选择缴费年期"
* :options="['整付(0-75 岁)', '5 年(0-70 岁)']"
* />
*/
import { ref, computed } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择'
},
/**
* 绑定的值
* @type {string}
*/
modelValue: {
type: String,
default: ''
},
/**
* 选项数组(key 和 value 相同)
* @type {Array<string>}
* @example ['整付(0-75 岁)', '5 年(0-70 岁)', '10 年(0-70 岁)']
*/
options: {
type: Array,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {string} value - 选中的选项
*/
'update:modelValue'
])
/**
* 控制 Picker 显示
* @type {Ref<boolean>}
*/
const showPicker = ref(false)
/**
* 打开选择器
*/
const openPicker = () => {
showPicker.value = true
}
/**
* 转换为 Picker 格式
* @description 将选项数组转换为 Picker 需要的格式
* @type {ComputedRef<Array<{text: string, value: string}>>}
* @example
* // options = ['整付(0-75 岁)', '5 年(0-70 岁)']
* // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...]
*/
const pickerColumns = computed(() => {
return props.options.map(option => ({
text: option,
value: option // key 和 value 相同
}))
})
/**
* 显示的值
* @type {ComputedRef<string>}
*/
const displayValue = computed(() => {
return props.modelValue || ''
})
/**
* 确认选择
* @param {Object} params - Picker 返回参数
* @param {Array} params.selectedOptions - 选中的选项数组
*
* @example
* // 用户选择 '整付(0-75 岁)'
* onConfirm({ selectedOptions: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }] })
* // -> emit('update:modelValue', '整付(0-75 岁)')
*/
const onConfirm = ({ selectedOptions }) => {
const value = selectedOptions[0]?.value
if (value !== undefined) {
emit('update:modelValue', value)
}
showPicker.value = false
}
</script>
<style lang="less" scoped>
/* 组件样式 */
</style>
<template>
<!-- 使用 PlanPopup 容器组件 -->
<PlanPopup :title="templateConfig?.name || '计划书'" @close="close" @submit="submit">
<!-- 动态加载模版组件 -->
<component
:is="currentTemplateComponent"
v-model="formData"
:config="templateConfig"
v-if="currentTemplateComponent"
/>
<!-- 错误提示 -->
<div v-else class="text-center text-gray-500 py-10">
<p>⚠️ 未找到对应的计划书模版</p>
<p class="text-sm mt-2">form_sn: {{ product?.form_sn }}</p>
</div>
</PlanPopup>
</template>
<script setup>
/**
* 计划书表单容器
*
* @description 根据产品的 form_sn 动态加载对应的计划书模版组件
* - 自动识别产品并加载模版
* - 支持后端 plan_config 动态配置
* - 统一的表单提交处理
* @author Claude Code
* @example
* <PlanFormContainer
* v-model:visible="showPlanPopup"
* :product="selectedProduct"
* @close="handleClose"
* @submit="handleSubmit"
* />
*/
import { ref, computed, watch } from 'vue'
import PlanPopup from './PlanPopup/index.vue'
import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
import SavingsTemplate from './SavingsTemplate.vue'
import { PLAN_TEMPLATES } from '@/config/plan-templates'
/**
* 组件属性
*/
const props = defineProps({
/**
* 是否显示弹窗
* @type {boolean}
*/
visible: {
type: Boolean,
default: false
},
/**
* 产品对象
* @type {Object}
* @property {number} id - 产品 ID
* @property {string} product_name - 产品名称
* @property {string} form_sn - 模版标识(必需)
* @property {Object} plan_config - 模版配置(可选,后端返回)
*/
product: {
type: Object,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新显示状态事件
* @event update:visible
* @param {boolean} value - 显示状态
*/
'update:visible',
/**
* 关闭事件
* @event close
*/
'close',
/**
* 提交事件
* @event submit
* @param {Object} formData - 表单数据
*/
'submit'
])
/**
* 当前模版配置
* @description 根据 form_sn 从配置文件中查找,并合并后端 plan_config
* @type {ComputedRef<Object|null>}
*
* @example
* // product.form_sn = 'life-insurance-wiop3e'
* // templateConfig() 返回: {
* // name: 'WIOP3E...',
* // component: 'LifeInsuranceTemplate',
* // config: { currency: 'USD', ... }
* // }
*/
const templateConfig = computed(() => {
if (!props.product?.form_sn) {
console.warn('[PlanFormContainer] 产品缺少 form_sn 字段', props.product)
return null
}
// 从配置文件中查找模版
const config = PLAN_TEMPLATES[props.product.form_sn]
if (!config) {
console.error(`[PlanFormContainer] 未找到模版配置: ${props.product.form_sn}`)
return null
}
// 合并配置:优先使用后端返回的 plan_config,否则使用配置文件中的默认配置
return {
...config,
config: {
...config.config,
...(props.product.plan_config || {})
}
}
})
/**
* 当前模版组件
* @description 根据 component 名称动态加载对应的组件
* @type {ComputedRef<Component|null>}
*/
const currentTemplateComponent = computed(() => {
if (!templateConfig.value) return null
const componentMap = {
'LifeInsuranceTemplate': LifeInsuranceTemplate,
'CriticalIllnessTemplate': CriticalIllnessTemplate,
'SavingsTemplate': SavingsTemplate
}
const componentName = templateConfig.value.component
return componentMap[componentName] || null
})
/**
* 表单数据
* @type {Ref<Object>}
*/
const formData = ref({})
/**
* 监听产品变化,重置表单数据
*/
watch(
() => props.product,
(newProduct) => {
if (newProduct) {
// 重置表单数据
formData.value = {}
}
},
{ immediate: true }
)
/**
* 监听显示状态变化
*/
watch(
() => props.visible,
(newVal) => {
emit('update:visible', newVal)
}
)
/**
* 关闭弹窗
*/
const close = () => {
emit('close')
}
/**
* 提交表单
* @description 将表单数据和产品信息一起提交
*/
const submit = () => {
console.log('[PlanFormContainer] 提交计划书:', {
product_id: props.product.id,
product_name: props.product.product_name,
form_sn: props.product.form_sn,
form_data: formData.value
})
emit('submit', {
product_id: props.product.id,
form_sn: props.product.form_sn,
form_data: formData.value
})
}
</script>
<style lang="less" scoped>
/* 容器样式 */
</style>
<template>
<div>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择日期"
@change="onBirthdayChange"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:currency="config.currency"
/>
<!-- 缴费年期 -->
<PlanFieldSelect
v-model="form.payment_period"
label="缴费年期"
placeholder="请选择缴费年期"
:options="config.payment_periods"
/>
<!-- 保险期间 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">保险期间</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
</div>
</div>
</div>
</template>
<script setup>
/**
* 重疾保险计划书模版
*
* @description MPC/MBC PRO/MBC2 等重疾保险产品的计划书录入表单
* - 支持出生日期自动计算年龄
* - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
* @author Claude Code
* @example
* <CriticalIllnessTemplate
* v-model="formData"
* :config="templateConfig"
* />
*/
import { reactive, watch } from 'vue'
import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 表单数据对象
* @type {Object}
*/
modelValue: {
type: Object,
default: () => ({})
},
/**
* 模版配置
* @type {Object}
* @property {string} currency - 币种代码
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
*/
config: {
type: Object,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新表单数据事件
* @event update:modelValue
* @param {Object} value - 表单数据
*/
'update:modelValue'
])
/**
* 表单数据
* @type {Object}
*/
const form = reactive(props.modelValue || {})
/**
* 监听表单数据变化,同步到父组件
*/
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
{ deep: true }
)
/**
* 出生日期变化时自动计算年龄
* @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
*
* @description 用户选择出生日期后,自动计算并填充年龄字段
* 计算公式:当前年份 - 出生年份
*/
const onBirthdayChange = (birthday) => {
if (birthday) {
const birthYear = new Date(birthday).getFullYear()
const currentYear = new Date().getFullYear()
const calculatedAge = currentYear - birthYear
// 自动填充年龄字段
form.age = calculatedAge
}
}
</script>
<style lang="less" scoped>
/* 模版样式 */
</style>
<template>
<div>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择日期"
@change="onBirthdayChange"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:currency="config.currency"
/>
<!-- 缴费年期 -->
<PlanFieldSelect
v-model="form.payment_period"
label="缴费年期"
placeholder="请选择缴费年期"
:options="config.payment_periods"
/>
<!-- 保险期间 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">保险期间</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
</div>
</div>
</div>
</template>
<script setup>
/**
* 人寿保险计划书模版
*
* @description WIOP3E/WIOP3 等人寿保险产品的计划书录入表单
* - 支持出生日期自动计算年龄
* - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
* @author Claude Code
* @example
* <LifeInsuranceTemplate
* v-model="formData"
* :config="templateConfig"
* />
*/
import { reactive, watch, toRefs } from 'vue'
import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
import PlanFieldAmount from '../PlanFields/AmountInput.vue'
import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 表单数据对象
* @type {Object}
*/
modelValue: {
type: Object,
default: () => ({})
},
/**
* 模版配置
* @type {Object}
* @property {string} currency - 币种代码
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
*/
config: {
type: Object,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新表单数据事件
* @event update:modelValue
* @param {Object} value - 表单数据
*/
'update:modelValue'
])
/**
* 表单数据
* @type {Object}
*/
const form = reactive(props.modelValue || {})
/**
* 监听表单数据变化,同步到父组件
*/
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
{ deep: true }
)
/**
* 出生日期变化时自动计算年龄
* @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
*
* @description 用户选择出生日期后,自动计算并填充年龄字段
* 计算公式:当前年份 - 出生年份
*/
const onBirthdayChange = (birthday) => {
if (birthday) {
const birthYear = new Date(birthday).getFullYear()
const currentYear = new Date().getFullYear()
const calculatedAge = currentYear - birthYear
// 自动填充年龄字段
form.age = calculatedAge
}
}
</script>
<style lang="less" scoped>
/* 模版样式 */
</style>
<template>
<div>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择日期"
@change="onBirthdayChange"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:currency="config.currency"
/>
<!-- 缴费年期 -->
<PlanFieldSelect
v-model="form.payment_period"
label="缴费年期"
placeholder="请选择缴费年期"
:options="config.payment_periods"
/>
<!-- 保险期间 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">保险期间</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
</div>
</div>
<!-- ====== 提取计划功能(储蓄产品专用)====== -->
<div v-if="config.withdrawal_plan?.enabled" class="mt-6 pt-6 border-t border-gray-200">
<div class="text-base font-medium text-gray-900 mb-4">提取计划</div>
<!-- 提取方式选择 -->
<PlanFieldRadio
v-model="form.withdrawal_plan.mode"
label="提取方式"
:options="config.withdrawal_plan.withdrawal_modes"
/>
<!-- 开始年龄 -->
<PlanFieldAgePicker
v-model="form.withdrawal_plan.start_age"
label="开始年龄"
placeholder="请选择开始提取年龄"
/>
<!-- 提取年期 -->
<PlanFieldSelect
v-model="form.withdrawal_plan.withdrawal_period"
label="提取年期"
placeholder="请选择提取年期"
:options="config.withdrawal_plan.withdrawal_periods"
/>
<!-- 方式1:年龄指定金额 - 额外字段 -->
<template v-if="form.withdrawal_plan.mode === '年龄指定金额'">
<!-- 每年提取金额 -->
<PlanFieldAmount
v-model="form.withdrawal_plan.annual_amount"
label="每年提取金额"
placeholder="请输入金额"
:currency="form.withdrawal_plan.currency || config.withdrawal_plan.default_currency"
/>
<!-- 币种 -->
<div class="mb-5">
<div class="text-sm text-gray-600 mb-2">币种</div>
<div class="flex gap-2">
<button
v-for="curr in currencyOptions"
:key="curr.value"
:class="[
'px-4 py-2 rounded-lg text-sm border transition-colors',
(form.withdrawal_plan.currency || config.withdrawal_plan.default_currency) === curr.value
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-600 border-gray-200'
]"
@tap="selectCurrency(curr.value)"
>
{{ curr.label }}
</button>
</div>
</div>
<!-- 增加率 -->
<div class="mb-5">
<div class="text-sm text-gray-600 mb-2">增加率(%</div>
<nut-input
v-model="form.withdrawal_plan.increase_rate"
type="digit"
placeholder="请输入增加率"
class="border border-gray-200 rounded-lg"
/>
</div>
</template>
</div>
</div>
</template>
<script setup>
/**
* 储蓄型保险计划书模版
*
* @description GS/GC/FA/LV2 等储蓄型保险产品的计划书录入表单
* - 支持出生日期自动计算年龄
* - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
* - 提取计划功能:年龄指定金额、最高固定金额
* @author Claude Code
* @example
* <SavingsTemplate
* v-model="formData"
* :config="templateConfig"
* />
*/
import { reactive, watch, computed } from 'vue'
import PlanFieldAgePicker from './PlanFields/AgePicker.vue'
import PlanFieldAmount from './PlanFields/AmountInput.vue'
import PlanFieldDatePicker from './PlanFields/DatePicker.vue'
import PlanFieldRadio from './PlanFields/RadioGroup.vue'
import PlanFieldSelect from './PlanFields/SelectPicker.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 表单数据对象
* @type {Object}
*/
modelValue: {
type: Object,
default: () => ({})
},
/**
* 模版配置
* @type {Object}
* @property {string} currency - 币种代码
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
* @property {Object} withdrawal_plan - 提取计划配置
*/
config: {
type: Object,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新表单数据事件
* @event update:modelValue
* @param {Object} value - 表单数据
*/
'update:modelValue'
])
/**
* 表单数据
* @type {Object}
*/
const form = reactive(props.modelValue || {
// 初始化提取计划数据
withdrawal_plan: {
mode: '年龄指定金额',
start_age: null,
withdrawal_period: null,
annual_amount: null,
currency: props.config?.withdrawal_plan?.default_currency || 'HKD',
increase_rate: 0
}
})
/**
* 币种选项(用于提取计划)
* @type {ComputedRef<Array>}
*/
const currencyOptions = computed(() => {
const CURRENCY_MAP = {
HKD: { label: '港币', value: 'HKD' },
USD: { label: '美元', value: 'USD' },
CNY: { label: '人民币', value: 'CNY' }
}
const supportedCurrencies = props.config?.withdrawal_plan?.currencies || ['HKD']
return supportedCurrencies
.map(code => CURRENCY_MAP[code])
.filter(Boolean)
})
/**
* 监听表单数据变化,同步到父组件
*/
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
{ deep: true }
)
/**
* 出生日期变化时自动计算年龄
* @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
*
* @description 用户选择出生日期后,自动计算并填充年龄字段
* 计算公式:当前年份 - 出生年份
*/
const onBirthdayChange = (birthday) => {
if (birthday) {
const birthYear = new Date(birthday).getFullYear()
const currentYear = new Date().getFullYear()
const calculatedAge = currentYear - birthYear
// 自动填充年龄字段
form.age = calculatedAge
}
}
/**
* 选择币种(用于提取计划)
* @param {string} currencyCode - 币种代码
*/
const selectCurrency = (currencyCode) => {
if (form.withdrawal_plan) {
form.withdrawal_plan.currency = currencyCode
}
}
</script>
<style lang="less" scoped>
/* 模版样式 */
</style>
/**
* 计划书模版配置
*
* @description 定义产品 form_sn 到模版组件和配置的映射关系
* @module config/plan-templates
* @author Claude Code
* @created 2026-02-06
*/
/**
* 计划书模版配置映射
* @description form_sn 为产品 API 返回的字段,用于标识该产品使用的计划书模版
*
* @example
* // 产品 API 返回
* {
* id: 1,
* product_name: "WIOP3E 盈传创富保障计划 3 - 优选版",
* form_sn: "life-insurance-wiop3e" // 对应下面的配置 key
* }
*/
export const PLAN_TEMPLATES = {
// 人寿保险产品 - WIOP3E
'life-insurance-wiop3e': {
name: 'WIOP3E 盈传创富保障计划 3 - 优选版',
component: 'LifeInsuranceTemplate',
config: {
currency: 'USD', // 币种:USD/CNY/HKD/EUR
payment_periods: [
// 缴费年期选项
'整付(0-75 岁)',
'5 年(0-70 岁)',
'10 年(0-70 岁)'
],
age_range: { min: 0, max: 75 }, // 年龄范围
insurance_period: '终身' // 保险期间
}
},
// 人寿保险产品 - WIOP3
'life-insurance-wiop3': {
name: 'WIOP3 - 盈传创富保障计划 3',
component: 'LifeInsuranceTemplate',
config: {
currency: 'USD',
payment_periods: [
'整付(0-75 岁)',
'5 年(0-70 岁)',
'10 年(0-70 岁)'
],
age_range: { min: 0, max: 75 },
insurance_period: '终身'
}
},
// 重疾保险产品 - MPC
'critical-illness-mpc': {
name: 'MPC 守护无间重疾',
component: 'CriticalIllnessTemplate',
config: {
currency: 'CNY',
payment_periods: [
'10 年(15 日 - 65 岁)',
'20 年(15 日 - 65 岁)',
'25 年(15 日 - 60 岁)'
],
age_range: { min: 0, max: 65 },
insurance_period: '终身'
}
},
// 重疾保险产品 - MBC PRO
'critical-illness-mbc-pro': {
name: 'MBC PRO 活跃人生重疾保 PRO',
component: 'CriticalIllnessTemplate',
config: {
currency: 'CNY',
payment_periods: [
'10 年(15 日 - 65 岁)',
'20 年(15 日 - 65 岁)',
'25 年(15 日 - 60 岁)'
],
age_range: { min: 0, max: 65 },
insurance_period: '终身'
}
},
// 重疾保险产品 - MBC2
'critical-illness-mbc2': {
name: 'MBC2 活跃人生重疾保 2',
component: 'CriticalIllnessTemplate',
config: {
currency: 'CNY',
payment_periods: [
'10 年(15 日 - 65 岁)',
'20 年(15 日 - 65 岁)',
'25 年(15 日 - 60 岁)'
],
age_range: { min: 0, max: 65 },
insurance_period: '终身'
}
},
// ====== 储蓄型产品(统一逻辑) ======
// GS - 宏摯傳承保障計劃
'savings-gs': {
name: '宏摯傳承保障計劃',
component: 'SavingsTemplate',
category: 'savings', // 储蓄型产品
config: {
currency: 'USD', // 默认美元
payment_periods: [
'整付(0-100 岁)',
'5 年(0-80 岁)',
'10 年(0-75 岁)'
],
age_range: { min: 0, max: 100 },
insurance_period: '终身',
// 提取计划配置
withdrawal_plan: {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'], // 支持的币种
default_currency: 'HKD',
withdrawal_modes: [
'年龄指定金额', // 方式1
'最高固定金额' // 方式2
],
withdrawal_periods: [
'1年',
'2年',
'3年',
'5年',
'10年',
'15年',
'20年',
'终身'
]
}
}
},
// GC - 宏摯家傳承保險計劃
'savings-gc': {
name: '宏摯家傳承保險計劃',
component: 'SavingsTemplate',
category: 'savings',
config: {
currency: 'USD',
payment_periods: [
'整付(0-100 岁)',
'5 年(0-80 岁)',
'10 年(0-75 岁)'
],
age_range: { min: 0, max: 100 },
insurance_period: '终身',
withdrawal_plan: {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'],
default_currency: 'HKD',
withdrawal_modes: ['年龄指定金额', '最高固定金额'],
withdrawal_periods: [
'1年',
'2年',
'3年',
'5年',
'10年',
'15年',
'20年',
'终身'
]
}
}
},
// FA - 宏浚傳承保障計劃
'savings-fa': {
name: '宏浚傳承保障計劃',
component: 'SavingsTemplate',
category: 'savings',
config: {
currency: 'USD',
payment_periods: [
'整付(0-100 岁)',
'5 年(0-80 岁)',
'10 年(0-75 岁)'
],
age_range: { min: 0, max: 100 },
insurance_period: '终身',
withdrawal_plan: {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'],
default_currency: 'HKD',
withdrawal_modes: ['年龄指定金额', '最高固定金额'],
withdrawal_periods: [
'1年',
'2年',
'3年',
'5年',
'10年',
'15年',
'20年',
'终身'
]
}
}
},
// LV2 - 赤霞珠終身壽險計劃2(储蓄型终身寿险)
'savings-lv2': {
name: '赤霞珠終身壽險計劃2',
component: 'SavingsTemplate',
category: 'savings',
config: {
currency: 'USD',
payment_periods: [
'整付(0-100 岁)',
'5 年(0-80 岁)',
'10 年(0-75 岁)'
],
age_range: { min: 0, max: 100 },
insurance_period: '终身',
withdrawal_plan: {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'],
default_currency: 'HKD',
withdrawal_modes: ['年龄指定金额', '最高固定金额'],
withdrawal_periods: [
'1年',
'2年',
'3年',
'5年',
'10年',
'15年',
'20年',
'终身'
]
}
}
}
}
/**
* 全局功能开关
* @description 用于控制实验性功能或未来扩展功能的开关
*
* @example 开启多币种功能:设置 MULTI_CURRENCY_ENABLED = true
*/
export const FEATURE_FLAGS = {
/**
* 多币种切换功能
* @description false: 方案 1 - 固定币种(当前实现)
* true: 方案 2 - 支持多币种切换(未来扩展)
* @type {boolean}
*/
MULTI_CURRENCY_ENABLED: false
}
/**
* 币种符号映射
* @description 币种代码到符号的映射关系
*/
export const CURRENCY_SYMBOLS = {
CNY: '¥', // 人民币
USD: '$', // 美元
HKD: 'HK$', // 港币
EUR: '€' // 欧元
}
/**
* 币种完整信息映射
* @description 币种代码到完整信息的映射(用于多币种模式)
*/
export const CURRENCY_MAP = {
CNY: { label: '人民币', symbol: '¥', value: 'CNY' },
USD: { label: '美元', symbol: '$', value: 'USD' },
HKD: { label: '港币', symbol: 'HK$', value: 'HKD' },
EUR: { label: '欧元', symbol: '€', value: 'EUR' }
}
/**
* 根据 form_sn 获取模版配置
* @param {string} formSn - 产品 API 返回的 form_sn 字段
* @returns {Object|null} 模版配置对象,未找到返回 null
*
* @example
* const config = getTemplateConfig('life-insurance-wiop3e')
* // 返回: { name: 'WIOP3E...', component: 'LifeInsuranceTemplate', config: {...} }
*/
export function getTemplateConfig(formSn) {
if (!formSn) {
console.warn('[plan-templates] form_sn 为空')
return null
}
const config = PLAN_TEMPLATES[formSn]
if (!config) {
console.error(`[plan-templates] 未找到模版配置: ${formSn}`)
return null
}
return config
}
/**
* 获取币种符号
* @param {string} currencyCode - 币种代码(CNY/USD/HKD/EUR)
* @returns {string} 币种符号
*
* @example
* const symbol = getCurrencySymbol('USD') // 返回: '$'
*/
export function getCurrencySymbol(currencyCode) {
return CURRENCY_SYMBOLS[currencyCode] || '¥'
}
......@@ -42,7 +42,7 @@
<view class="bg-white rounded-[32rpx] shadow-sm p-[32rpx] mb-[24rpx]">
<view class="flex justify-between items-center mb-[24rpx]">
<text class="text-gray-900 text-[32rpx] font-bold">热卖产品</text>
<view class="flex items-center text-blue-600" @tap="go('/pages/knowledge-base/index')">
<view class="flex items-center text-blue-600" @tap="go('/pages/product-center/index')">
<text class="text-[26rpx] mr-[4rpx]">查看更多</text>
<IconFont name="rectRight" size="12" />
</view>
......@@ -159,19 +159,14 @@
<!-- Bottom Tab Bar -->
<TabBar current="home" />
<!-- Plan Popup -->
<PlanPopup v-model:visible="showPlanPopup">
<SchemeA
v-if="currentScheme === 'A'"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
<SchemeB
v-if="currentScheme === 'B'"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</PlanPopup>
<!-- Plan Form Container -->
<!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</template>
......@@ -184,9 +179,7 @@ import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons';
import { useUserStore } from '@/stores/user';
import TabBar from '@/components/TabBar.vue';
import IconFont from '@/components/IconFont.vue';
import PlanPopup from '@/components/PlanPopup/index.vue';
import SchemeA from '@/components/PlanSchemes/SchemeA.vue';
import SchemeB from '@/components/PlanSchemes/SchemeB.vue';
import PlanFormContainer from '@/components/PlanFormContainer.vue';
import ListItemActions from '@/components/ListItemActions/index.vue';
import { listAPI } from '@/api/get_product';
import { weekHotAPI } from '@/api/file';
......@@ -198,26 +191,57 @@ const userStore = useUserStore();
// Plan Popup State
const showPlanPopup = ref(false);
const currentScheme = ref('A');
const selectedProduct = ref(null);
const openPlanPopup = (scheme) => {
currentScheme.value = scheme;
/**
* 打开计划书弹窗
* @description 根据产品ID找到对应的产品对象,并打开计划书表单
* @param {number} productId - 产品ID
*/
const openPlanPopup = (productId) => {
// 从热卖产品列表中找到对应的产品
const product = hotProducts.value.find(p => p.id === productId);
if (!product) {
Taro.showToast({
title: '产品不存在',
icon: 'none',
duration: 2000
});
return;
}
// 设置选中的产品
selectedProduct.value = product;
showPlanPopup.value = true;
};
/**
* 处理计划书提交
* @description 模拟提交计划书,跳转到结果页面
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log(`方案${currentScheme.value}提交:`, formData);
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
});
// 关闭弹窗
showPlanPopup.value = false;
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// });
// 模拟提交成功,跳转到结果页面
// TODO: 后续接入真实API
go('/pages/plan-submit-result/index', {
success: 'true'
});
......@@ -269,7 +293,7 @@ const fetchHomeIcons = async () => {
// 如果 API 调用失败,使用默认配置
loopNav.value = [
{ id: 'plan', icon: 'order', name: '计划书', route: '/pages/plan/index' },
{ id: 'knowledge-base', icon: 'category', name: '产品知识库', route: '/pages/knowledge-base/index' }
{ id: 'product-center', icon: 'category', name: '产品中心', route: '/pages/product-center/index' }
];
}
};
......@@ -284,17 +308,119 @@ const hotProducts = ref([]);
/**
* 获取热卖产品列表
*
* @description 调用产品列表API,recommend参数为hot
* @description ⚠️ 测试数据:后端接口和字段还没有准备好,暂时使用模拟数据进行测试
* 测试完成后需要移除,恢复使用真实的API调用
* Mock数据包含全部7种产品类型(2种人寿、3种重疾、4种储蓄)
*/
const fetchHotProducts = async () => {
try {
const res = await listAPI({
recommend: 'hot'
});
// TODO: 测试完成后,移除下面的 mock 数据,恢复使用真实 API
// const res = await listAPI({
// recommend: 'hot'
// });
// if (res.code === 1 && res.data && res.data.list) {
// hotProducts.value = res.data.list;
// }
// ⚠️ 测试数据开始 - 测试完成后需要移除 ⚠️
hotProducts.value = [
// 人寿保险产品(2种)
{
id: 1,
product_name: 'WIOP3E 盈传创富保障计划 3 - 优选版',
form_sn: 'life-insurance-wiop3e',
recommend: 'hot',
tags: [
{ id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 2,
product_name: 'WIOP3 - 盈传创富保障计划 3',
form_sn: 'life-insurance-wiop3',
recommend: 'hot',
tags: [
{ id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
// 重疾保险产品(3种)
{
id: 3,
product_name: 'MPC 守护无间重疾',
form_sn: 'critical-illness-mpc',
recommend: 'hot',
tags: [
{ id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
{ id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
]
},
{
id: 4,
product_name: 'MBC PRO 活跃人生重疾保 PRO',
form_sn: 'critical-illness-mbc-pro',
recommend: 'hot',
tags: [
{ id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
{ id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
]
},
{
id: 5,
product_name: 'MBC2 活跃人生重疾保 2',
form_sn: 'critical-illness-mbc2',
recommend: 'hot',
tags: [
{ id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
{ id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
]
},
// 储蓄型产品(4种)- GS, GC, FA, LV2
{
id: 6,
product_name: 'GS - 宏摯傳承保障計劃',
form_sn: 'savings-gs',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 7,
product_name: 'GC - 宏摯家傳承保險計劃',
form_sn: 'savings-gc',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 8,
product_name: 'FA - 宏浚傳承保障計劃',
form_sn: 'savings-fa',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
},
{
id: 9,
product_name: 'LV2 - 赤霞珠終身壽險計劃2',
form_sn: 'savings-lv2',
recommend: 'hot',
tags: [
{ id: 1, name: '储蓄型终身寿险', bg_color: '#E0E7FF', text_color: '#3730A3' },
{ id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
]
}
];
// ⚠️ 测试数据结束 - 测试完成后需要移除 ⚠️
if (res.code === 1 && res.data && res.data.list) {
hotProducts.value = res.data.list;
}
console.log('⚠️ 使用测试数据:热卖产品列表已 mock,包含全部7种产品类型');
} catch (err) {
console.error('获取热卖产品失败:', err);
}
......
/*
* @Date: 2026-01-29 21:53:42
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-31 10:53:44
* @LastEditTime: 2026-02-06 11:55:01
* @FilePath: /manulife-weapp/src/pages/knowledge-base/index.config.js
* @Description: 产品知识库页面配置文件
* @Description: 产品中心页面配置文件
*/
export default definePageConfig({
navigationBarTitleText: '产品知识库',
navigationBarTitleText: '产品中心',
navigationStyle: 'custom'
})
......
<!--
* @Date: 2026-01-31
* @Description: 产品知识库 - API 接口集成版本(含搜索功能)
* @Description: 产品中心 - API 接口集成版本(含搜索功能)
-->
<template>
<view class="h-screen bg-[#F9FAFB] flex flex-col">
<view class="bg-[#F9FAFB] z-10">
<NavHeader title="产品知识库" />
<NavHeader title="产品中心" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
......@@ -79,7 +79,7 @@
</view>
<!-- 动态标签 -->
<view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-auto">
<view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mb-[16rpx]">
<view
v-for="tag in item.tags.slice(0, 2)"
:key="tag.id"
......@@ -92,6 +92,27 @@
{{ tag.name }}
</view>
</view>
<!-- 按钮组 -->
<view class="flex gap-[12rpx] mt-auto">
<nut-button
plain
color="#2563EB"
size="small"
class="flex-1 !h-[56rpx] !rounded-[12rpx] !text-[22rpx] !m-0 !border-blue-600"
@tap.stop="handleProductClick(item)"
>
详情
</nut-button>
<nut-button
color="#2563EB"
size="small"
class="flex-1 !h-[56rpx] !rounded-[12rpx] !text-[22rpx] !m-0"
@tap.stop="openPlanPopup(item)"
>
计划书
</nut-button>
</view>
</view>
</view>
</view>
......@@ -111,6 +132,15 @@
<nut-empty description="暂无相关产品" image="empty" />
</view>
</scroll-view>
<!-- 计划书表单容器 -->
<!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="selectedProduct"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</view>
</template>
......@@ -119,6 +149,7 @@ import { ref, computed } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { listAPI } from '@/api/get_product'
......@@ -140,6 +171,10 @@ const categories = ref([]) // 从接口获取的分类列表
const products = ref([]) // 当前产品列表
const total = ref(0) // 产品总数
// 计划书弹窗状态
const showPlanPopup = ref(false)
const selectedProduct = ref(null)
/**
* 标签栏数据(根据接口返回的 categories 生成)
* @description 包含"全部"选项和接口返回的分类
......@@ -326,6 +361,47 @@ const { handleClick: handleProductClick } = useListItemClick({
})
/**
* 打开计划书弹窗
* @description 根据产品对象打开计划书表单
* @param {Object} product - 产品对象
*/
const openPlanPopup = (product) => {
selectedProduct.value = product
showPlanPopup.value = true
}
/**
* 处理计划书提交
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: selectedProduct.value.id,
product_name: selectedProduct.value.product_name,
form_sn: selectedProduct.value.form_sn,
form_data: formData
})
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: selectedProduct.value.id,
// template: selectedProduct.value.form_sn,
// form_data: formData
// })
// 模拟提交成功,跳转到结果页面
Taro.navigateTo({
url: '/pages/plan-submit-result/index?success=true'
})
}
/**
* 页面加载时获取数据
*/
useLoad(() => {
......
......@@ -98,6 +98,26 @@
<!-- TabBar -->
<!-- <TabBar /> -->
<!-- 计划书按钮 -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-[32rpx] py-[24rpx] flex items-center justify-center">
<nut-button
color="#2563EB"
class="!w-full !h-[88rpx] !rounded-[16rpx] !text-[28rpx] !font-bold"
@tap="openPlanPopup"
>
制作计划书
</nut-button>
</div>
<!-- 计划书表单容器 -->
<!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
<PlanFormContainer
v-model:visible="showPlanPopup"
:product="productDetail"
@close="showPlanPopup = false"
@submit="handlePlanSubmit"
/>
</div>
</template>
......@@ -106,6 +126,7 @@ import { ref } from 'vue'
import NavHeader from '@/components/NavHeader.vue'
import TabBar from '@/components/TabBar.vue'
import IconFont from '@/components/IconFont.vue'
import PlanFormContainer from '@/components/PlanFormContainer.vue'
import { useFileOperation } from '@/composables/useFileOperation'
import Taro, { useLoad } from '@tarojs/taro'
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
......@@ -113,6 +134,9 @@ import { detailAPI } from '@/api/get_product'
const { viewFile } = useFileOperation()
// 计划书弹窗状态
const showPlanPopup = ref(false)
// 接收页面参数
const productId = ref(null)
......@@ -181,6 +205,47 @@ const viewDocument = (doc) => {
})
}
/**
* 打开计划书弹窗
*
* @description 打开当前产品的计划书表单
*/
const openPlanPopup = () => {
showPlanPopup.value = true
}
/**
* 处理计划书提交
*
* @description 测试环境:前端不调用后端API,直接跳转到结果页
* 生产环境:需要调用 submitPlanAPI 提交表单数据
* @param {Object} formData - 表单数据
*/
const handlePlanSubmit = (formData) => {
console.log('计划书提交:', {
product_id: productDetail.value.id,
product_name: productDetail.value.product_name,
form_sn: productDetail.value.form_sn,
form_data: formData
})
// 关闭弹窗
showPlanPopup.value = false
// TODO: 后端接口还没有准备好,暂时不调用API
// 测试完成后需要对接 submitPlanAPI
// const res = await submitPlanAPI({
// product_id: productDetail.value.id,
// template: productDetail.value.form_sn,
// form_data: formData
// })
// 模拟提交成功,跳转到结果页面
Taro.navigateTo({
url: '/pages/plan-submit-result/index?success=true'
})
}
useLoad((options) => {
console.log('产品详情页参数:', options)
......
......@@ -382,7 +382,7 @@ const clearSearch = () => {
// Go to detail
const goToDetail = (item) => {
if (item.category === 'product') {
go('/pages/knowledge-base/index')
go('/pages/product-center/index')
} else {
go('/pages/material-list/index', { title: '搜索结果' })
}
......