hookehuyr

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

...@@ -17,3 +17,8 @@ unpackage/ ...@@ -17,3 +17,8 @@ unpackage/
17 .tmp/ 17 .tmp/
18 CLAUDE.md 18 CLAUDE.md
19 .claude/ 19 .claude/
20 +
21 +# Office documents
22 +*.docx
23 +*.xlsx
24 +*.pptx
......
...@@ -7,15 +7,21 @@ export {} ...@@ -7,15 +7,21 @@ export {}
7 7
8 declare module 'vue' { 8 declare module 'vue' {
9 export interface GlobalComponents { 9 export interface GlobalComponents {
10 + AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default']
11 + AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default']
12 + CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default']
13 + DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default']
10 DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default'] 14 DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default']
11 FilterTabs: typeof import('./src/components/FilterTabs.vue')['default'] 15 FilterTabs: typeof import('./src/components/FilterTabs.vue')['default']
12 'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default'] 16 'FilterTabs.example': typeof import('./src/components/FilterTabs.example.vue')['default']
13 IconFont: typeof import('./src/components/IconFont.vue')['default'] 17 IconFont: typeof import('./src/components/IconFont.vue')['default']
14 IndexNav: typeof import('./src/components/indexNav.vue')['default'] 18 IndexNav: typeof import('./src/components/indexNav.vue')['default']
19 + LifeInsuranceTemplate: typeof import('./src/components/PlanTemplates/LifeInsuranceTemplate.vue')['default']
15 ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default'] 20 ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default']
16 NavHeader: typeof import('./src/components/NavHeader.vue')['default'] 21 NavHeader: typeof import('./src/components/NavHeader.vue')['default']
17 NutAvatar: typeof import('@nutui/nutui-taro')['Avatar'] 22 NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
18 NutButton: typeof import('@nutui/nutui-taro')['Button'] 23 NutButton: typeof import('@nutui/nutui-taro')['Button']
24 + NutDatepicker: typeof import('@nutui/nutui-taro')['Datepicker']
19 NutEmpty: typeof import('@nutui/nutui-taro')['Empty'] 25 NutEmpty: typeof import('@nutui/nutui-taro')['Empty']
20 NutInput: typeof import('@nutui/nutui-taro')['Input'] 26 NutInput: typeof import('@nutui/nutui-taro')['Input']
21 NutPicker: typeof import('@nutui/nutui-taro')['Picker'] 27 NutPicker: typeof import('@nutui/nutui-taro')['Picker']
...@@ -27,17 +33,21 @@ declare module 'vue' { ...@@ -27,17 +33,21 @@ declare module 'vue' {
27 OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default'] 33 OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default']
28 PdfPreview: typeof import('./src/components/PdfPreview.vue')['default'] 34 PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
29 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] 35 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
36 + PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
30 PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default'] 37 PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
31 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] 38 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
32 QrCode: typeof import('./src/components/qrCode.vue')['default'] 39 QrCode: typeof import('./src/components/qrCode.vue')['default']
33 QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default'] 40 QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default']
41 + RadioGroup: typeof import('./src/components/PlanFields/RadioGroup.vue')['default']
34 RouterLink: typeof import('vue-router')['RouterLink'] 42 RouterLink: typeof import('vue-router')['RouterLink']
35 RouterView: typeof import('vue-router')['RouterView'] 43 RouterView: typeof import('vue-router')['RouterView']
44 + SavingsTemplate: typeof import('./src/components/SavingsTemplate.vue')['default']
36 SchemeA: typeof import('./src/components/PlanSchemes/SchemeA.vue')['default'] 45 SchemeA: typeof import('./src/components/PlanSchemes/SchemeA.vue')['default']
37 SchemeB: typeof import('./src/components/PlanSchemes/SchemeB.vue')['default'] 46 SchemeB: typeof import('./src/components/PlanSchemes/SchemeB.vue')['default']
38 SearchBar: typeof import('./src/components/SearchBar.vue')['default'] 47 SearchBar: typeof import('./src/components/SearchBar.vue')['default']
39 SectionCard: typeof import('./src/components/SectionCard.vue')['default'] 48 SectionCard: typeof import('./src/components/SectionCard.vue')['default']
40 SectionItem: typeof import('./src/components/SectionItem.vue')['default'] 49 SectionItem: typeof import('./src/components/SectionItem.vue')['default']
50 + SelectPicker: typeof import('./src/components/PlanFields/SelectPicker.vue')['default']
41 TabBar: typeof import('./src/components/TabBar.vue')['default'] 51 TabBar: typeof import('./src/components/TabBar.vue')['default']
42 } 52 }
43 } 53 }
......
This diff is collapsed. Click to expand it.
1 +## 一、人寿产品
2 +
3 +1. WIOP3E 盈传创富保障计划 3 - 优选版
4 +2. WIOP3 - 盈传创富保障计划 3
5 +
6 +### 核心信息
7 +
8 +性别、年龄、出生年月日、是否吸烟
9 +
10 +### 保额
11 +
12 +### 缴费年期
13 +
14 +1. 整付(0-75 岁)
15 +2. 5 年(0-70 岁)
16 +3. 10 年(0-70 岁)
17 +
18 +## 二、重疾产品
19 +
20 +1. MPC 守护无间重疾
21 +2. MBC PRO 活跃人生重疾保 PRO
22 +3. MBC2 活跃人生重疾保 2
23 +
24 +### 核心信息
25 +
26 +性别、年龄、出生年月日、是否吸烟
27 +
28 +### 保额
29 +
30 +### 缴费年期
31 +
32 +1. 10 年(15 日 - 65 岁)
33 +2. 20 年(15 日 - 65 岁)
34 +3. 25 年(15 日 - 60 岁)
...\ No newline at end of file ...\ No newline at end of file
1 +# 计划书功能测试实现记录
2 +
3 +**日期**: 2026-02-06
4 +**目的**: 前端测试计划书录入功能(后端接口尚未准备好)
5 +
6 +---
7 +
8 +## 📋 已完成的任务
9 +
10 +### ✅ 任务1: 添加计划书按钮
11 +
12 +#### 1.1 首页热卖产品卡片
13 +**文件**: `src/pages/index/index.vue`
14 +
15 +**修改内容**:
16 +- 替换了旧的 `PlanPopup` + `SchemeA/SchemeB` 系统
17 +- 集成新的 `PlanFormContainer` 组件
18 +- 添加了计划书弹窗状态管理 (`showPlanPopup`, `selectedProduct`)
19 +- 更新了 `openPlanPopup()` 函数,根据产品ID查找产品对象
20 +- 更新了 `handlePlanSubmit()` 函数
21 +
22 +**计划书按钮**: 已存在(第84-91行),无需新增
23 +
24 +#### 1.2 产品中心列表卡片
25 +**文件**: `src/pages/knowledge-base/index.vue`
26 +
27 +**修改内容**:
28 +- 在产品卡片上添加了"详情"和"计划书"两个按钮
29 +- 集成 `PlanFormContainer` 组件
30 +- 添加了计划书弹窗状态管理
31 +- 添加了 `openPlanPopup(product)` 函数
32 +- 添加了 `handlePlanSubmit(formData)` 函数
33 +
34 +**按钮布局**:
35 +```
36 +[详情] [计划书]
37 +```
38 +
39 +#### 1.3 产品详情页
40 +**文件**: `src/pages/product-detail/index.vue`
41 +
42 +**修改内容**:
43 +- 在页面底部添加了固定定位的"制作计划书"按钮
44 +- 集成 `PlanFormContainer` 组件
45 +- 添加了计划书弹窗状态管理
46 +- 添加了 `openPlanPopup()` 函数
47 +- 添加了 `handlePlanSubmit(formData)` 函数
48 +
49 +**按钮样式**: 全宽按钮,固定在页面底部
50 +
51 +---
52 +
53 +### ✅ 任务3: Mock 数据(热卖产品)
54 +
55 +**文件**: `src/pages/index/index.vue`
56 +**函数**: `fetchHotProducts()`
57 +
58 +**Mock 数据包含全部7种产品类型**:
59 +
60 +#### 人寿保险(2种)
61 +1. **WIOP3E 盈传创富保障计划 3 - 优选版**
62 + - form_sn: `life-insurance-wiop3e`
63 + - 标签: 终身寿险、美元
64 +
65 +2. **WIOP3 - 盈传创富保障计划 3**
66 + - form_sn: `life-insurance-wiop3`
67 + - 标签: 终身寿险、美元
68 +
69 +#### 重疾保险(3种)
70 +3. **MPC 守护无间重疾**
71 + - form_sn: `critical-illness-mpc`
72 + - 标签: 重疾保障、人民币
73 +
74 +4. **MBC PRO 活跃人生重疾保 PRO**
75 + - form_sn: `critical-illness-mbc-pro`
76 + - 标签: 重疾保障、人民币
77 +
78 +5. **MBC2 活跃人生重疾保 2**
79 + - form_sn: `critical-illness-mbc2`
80 + - 标签: 重疾保障、人民币
81 +
82 +#### 储蓄型产品(4种)
83 +6. **GS - 宏摯傳承保障計劃**
84 + - form_sn: `savings-gs`
85 + - 标签: 储蓄分红、美元
86 +
87 +7. **GC - 宏摯家傳承保險計劃**
88 + - form_sn: `savings-gc`
89 + - 标签: 储蓄分红、美元
90 +
91 +8. **FA - 宏浚傳承保障計劃**
92 + - form_sn: `savings-fa`
93 + - 标签: 储蓄分红、美元
94 +
95 +9. **LV2 - 赤霞珠終身壽險計劃2**
96 + - form_sn: `savings-lv2`
97 + - 标签: 储蓄型终身寿险、美元
98 +
99 +---
100 +
101 +## 🚫 待清理的测试数据
102 +
103 +### ⚠️ 重要提醒
104 +
105 +**测试完成后需要移除以下内容**:
106 +
107 +#### 1. 首页 Mock 数据
108 +**文件**: `src/pages/index/index.vue`
109 +**位置**: `fetchHotProducts()` 函数
110 +
111 +**清理步骤**:
112 +```javascript
113 +// 删除测试数据部分(⚠️ 测试数据开始 - 测试完成后需要移除 ⚠️)
114 +// 恢复真实API调用:
115 +const res = await listAPI({
116 + recommend: 'hot'
117 +});
118 +
119 +if (res.code === 1 && res.data && res.data.list) {
120 + hotProducts.value = res.data.list;
121 +}
122 +```
123 +
124 +#### 2. 提交 API 接口对接
125 +**文件**: `src/pages/index/index.vue`, `src/pages/product-detail/index.vue`, `src/pages/knowledge-base/index.vue`
126 +
127 +**当前状态**:
128 +```javascript
129 +// TODO: 后端接口还没有准备好,暂时不调用API
130 +// 测试完成后需要对接 submitPlanAPI
131 +```
132 +
133 +**对接步骤**:
134 +1. 导入 API: `import { submitPlanAPI } from '@/api/plan'`
135 +2. 替换 `handlePlanSubmit()` 中的 TODO 部分:
136 +```javascript
137 +const res = await submitPlanAPI({
138 + product_id: selectedProduct.value.id,
139 + template: selectedProduct.value.form_sn,
140 + form_data: formData
141 +});
142 +
143 +if (res.code === 1) {
144 + // 跳转到成功页面
145 + go('/pages/plan-submit-result/index', {
146 + success: 'true',
147 + plan_id: res.data.plan_id
148 + });
149 +}
150 +```
151 +
152 +---
153 +
154 +## 📊 测试覆盖范围
155 +
156 +### 模版类型测试
157 +-**LifeInsuranceTemplate**: WIOP3E, WIOP3
158 +-**CriticalIllnessTemplate**: MPC, MBC PRO, MBC2
159 +-**SavingsTemplate**: GS, GC, FA, LV2
160 +
161 +### 功能测试
162 +- ✅ 计划书按钮显示在多个页面
163 +- ✅ 点击按钮打开对应产品的计划书表单
164 +- ✅ 根据产品的 `form_sn` 自动加载正确的模版
165 +- ✅ 表单数据结构和验证
166 +
167 +### 页面集成测试
168 +- ✅ 首页 → 热卖产品卡片 → 计划书按钮
169 +- ✅ 产品中心 → 产品列表卡片 → 计划书按钮
170 +- ✅ 产品详情页 → 制作计划书按钮
171 +
172 +---
173 +
174 +## 🔍 后端接口需求
175 +
176 +### 必需字段
177 +
178 +产品 API 需要返回以下字段:
179 +
180 +```javascript
181 +{
182 + id: 1,
183 + product_name: "产品名称",
184 + form_sn: "life-insurance-wiop3e", // 关键:计划书模版标识
185 + recommend: "hot", // 可选:推荐标识
186 + tags: [ // 可选:产品标签
187 + {
188 + id: 1,
189 + name: "终身寿险",
190 + bg_color: "#DBEAFE",
191 + text_color: "#1E40AF"
192 + }
193 + ]
194 +}
195 +```
196 +
197 +### 提交计划书 API
198 +
199 +**端点**: `/srv/?a=submit_plan&t=submit`
200 +**方法**: POST
201 +**参数**:
202 +```javascript
203 +{
204 + product_id: number, // 产品ID
205 + template: string, // form_sn
206 + form_data: {
207 + // 基础字段(所有模版)
208 + gender: string, // "男" | "女"
209 + age: number, // 年龄
210 + birthday: string, // "YYYY-MM-DD"
211 + smoker: string, // "是" | "否"
212 + coverage: number, // 保额(分)
213 + payment_period: string, // 缴费年期
214 +
215 + // 储蓄产品专用字段
216 + withdrawal_plan: {
217 + mode: string, // "年龄指定金额" | "最高固定金额"
218 + start_age: number, // 开始年龄
219 + withdrawal_period: string, // 提取年期
220 +
221 + // mode = "年龄指定金额" 时的额外字段
222 + annual_amount: number, // 每年提取金额(分)
223 + currency: string, // 币种
224 + increase_rate: number // 增加率
225 + }
226 + }
227 +}
228 +```
229 +
230 +---
231 +
232 +## 📝 清理清单
233 +
234 +测试完成后,按以下顺序清理:
235 +
236 +- [ ] **步骤1**: 移除首页 Mock 数据,恢复真实 API
237 +- [ ] **步骤2**: 对接提交计划书 API
238 +- [ ] **步骤3**: 移除所有 `TODO: 后端接口还没有准备好` 的注释
239 +- [ ] **步骤4**: 移除 `console.log('⚠️ 使用测试数据...')` 等调试日志
240 +- [ ] **步骤5**: 测试完整的提交流程
241 +- [ ] **步骤6**: 删除本测试文档(或归档)
242 +
243 +---
244 +
245 +## 🧪 测试指南
246 +
247 +### 手动测试步骤
248 +
249 +1. **首页测试**:
250 + - 打开首页,查看"热卖产品"区域
251 + - 应显示9个产品卡片(2人寿 + 3重疾 + 4储蓄)
252 + - 点击任意产品的"计划书"按钮
253 + - 应打开对应的计划书表单模版
254 +
255 +2. **产品中心测试**:
256 + - 进入"产品中心"页面
257 + - 选择任意分类或搜索
258 + - 点击产品卡片上的"计划书"按钮
259 + - 应打开对应的计划书表单模版
260 +
261 +3. **产品详情页测试**:
262 + - 进入任意产品详情页
263 + - 点击底部"制作计划书"按钮
264 + - 应打开对应的计划书表单模版
265 +
266 +4. **表单测试**:
267 + - 人寿保险模版:填写性别、年龄、出生年月日等
268 + - 重疾保险模版:填写基础字段
269 + - 储蓄产品模版:填写基础字段 + 提取计划功能
270 +
271 +---
272 +
273 +**最后更新**: 2026-02-06
274 +**维护者**: Claude Code
1 +/**
2 + * 计划书 API 接口
3 + *
4 + * @description 计划书相关的 API 接口定义
5 + * @module api/plan
6 + * @author Claude Code
7 + * @created 2026-02-06
8 + */
9 +
10 +import { fn } from './fn'
11 +
12 +const Api = {
13 + Submit: '/srv/?a=submit_plan&t=submit'
14 +}
15 +
16 +/**
17 + * 提交计划书
18 + *
19 + * @description 提交计划书表单数据到后端,生成计划书 PDF
20 + * @param {Object} params - 请求参数
21 + * @param {number} params.product_id - 产品 ID
22 + * @param {string} params.template - 模版标识(form_sn)
23 + * @param {Object} params.form_data - 表单数据
24 + * @param {string} params.form_data.gender - 性别("男" | "女")
25 + * @param {number} params.form_data.age - 年龄
26 + * @param {string} params.form_data.birthday - 出生日期(格式:YYYY-MM-DD)
27 + * @param {string} params.form_data.smoker - 是否吸烟("是" | "否")
28 + * @param {number} params.form_data.coverage - 保额(单位:分)
29 + * @param {string} params.form_data.payment_period - 缴费年期
30 + * @returns {Promise<{
31 + * code: number; // 状态码
32 + * msg: string; // 消息
33 + * data: {
34 + * plan_id: number; // 计划书 ID
35 + * status: string; // 状态:processing | generated
36 + * estimated_time: number; // 预计生成时间(秒)
37 + * };
38 + * }>}
39 + *
40 + * @example
41 + * const res = await submitPlanAPI({
42 + * product_id: 1,
43 + * template: 'life-insurance-wiop3e',
44 + * form_data: {
45 + * gender: '男',
46 + * age: 18,
47 + * birthday: '1990-01-01',
48 + * smoker: '否',
49 + * coverage: 100000, // 1000.00 元(单位:分)
50 + * payment_period: '10 年(0-70 岁)'
51 + * }
52 + * })
53 + *
54 + * if (res.code === 1) {
55 + * console.log('计划书 ID:', res.data.plan_id)
56 + * console.log('预计生成时间:', res.data.estimated_time, '秒')
57 + * }
58 + */
59 +export const submitPlanAPI = (params) => fn({
60 + url: Api.Submit,
61 + method: 'POST',
62 + data: params
63 +})
64 +
65 +// 注意:查询计划书状态功能暂不需要
66 +// 计划书生成是半自动流程(小程序 → 公司 → 手动上传 → 用户查看)
67 +// 用户在"我的计划书"页面查看生成的计划书
...@@ -13,7 +13,7 @@ const pages = [ ...@@ -13,7 +13,7 @@ const pages = [
13 'pages/document-demo/index', 13 'pages/document-demo/index',
14 'pages/onboarding/index', 14 'pages/onboarding/index',
15 'pages/family-office/index', 15 'pages/family-office/index',
16 - 'pages/knowledge-base/index', 16 + 'pages/product-center/index',
17 'pages/product-detail/index', 17 'pages/product-detail/index',
18 'pages/category-list/index', 18 'pages/category-list/index',
19 'pages/material-list/index', 19 'pages/material-list/index',
......
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
5 +
6 + <!-- 触发区域 -->
7 + <div
8 + class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
9 + :class="{ 'bg-gray-50': showPicker }"
10 + @tap="openPicker"
11 + >
12 + <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
13 + {{ displayValue || placeholder }}
14 + </span>
15 + <IconFont name="right" size="14" color="#9CA3AF" />
16 + </div>
17 +
18 + <!-- Picker 弹窗 -->
19 + <nut-popup
20 + position="bottom"
21 + v-model:visible="showPicker"
22 + :z-index="9999"
23 + :overlay="true"
24 + >
25 + <nut-picker
26 + :columns="ageColumns"
27 + :default-value="defaultValue"
28 + @confirm="onConfirm"
29 + @cancel="showPicker = false"
30 + />
31 + </nut-popup>
32 + </div>
33 +</template>
34 +
35 +<script setup>
36 +/**
37 + * 年龄选择器组件
38 + *
39 + * @description 使用 NutUI Popup + Picker 实现年龄选择
40 + * - 显示格式:3位数字(如 018 表示 18 岁)
41 + * - 提交格式:数字(如 18)
42 + * - 年龄范围:0-120 岁
43 + * @author Claude Code
44 + * @example
45 + * <AgePicker
46 + * v-model="age"
47 + * label="年龄"
48 + * placeholder="请选择年龄"
49 + * />
50 + */
51 +import { ref, computed } from 'vue'
52 +import IconFont from '@/components/IconFont.vue'
53 +
54 +/**
55 + * 组件属性
56 + */
57 +const props = defineProps({
58 + /**
59 + * 标签文本
60 + * @type {string}
61 + */
62 + label: {
63 + type: String,
64 + default: ''
65 + },
66 +
67 + /**
68 + * 占位符文本
69 + * @type {string}
70 + */
71 + placeholder: {
72 + type: String,
73 + default: '请选择年龄'
74 + },
75 +
76 + /**
77 + * 绑定的值(数字)
78 + * @type {number}
79 + */
80 + modelValue: {
81 + type: Number,
82 + default: null
83 + }
84 +})
85 +
86 +/**
87 + * 组件事件
88 + */
89 +const emit = defineEmits([
90 + /**
91 + * 更新值事件
92 + * @event update:modelValue
93 + * @param {number} value - 选中的年龄(数字)
94 + */
95 + 'update:modelValue'
96 +])
97 +
98 +/**
99 + * 控制 Picker 显示
100 + * @type {Ref<boolean>}
101 + */
102 +const showPicker = ref(false)
103 +
104 +/**
105 + * 打开选择器
106 + */
107 +const openPicker = () => {
108 + showPicker.value = true
109 +}
110 +
111 +/**
112 + * 年龄选项(3位数字格式)
113 + * @description 生成 000-120 的年龄选项数组
114 + * @returns {Array<Array<{text: string, value: number}>>} Picker 列格式
115 + *
116 + * @example
117 + * // 返回值示例
118 + * [
119 + * [
120 + * { text: '000', value: 0 },
121 + * { text: '001', value: 1 },
122 + * ...
123 + * { text: '018', value: 18 },
124 + * ...
125 + * { text: '120', value: 120 }
126 + * ]
127 + * ]
128 + */
129 +const ageColumns = computed(() => {
130 + const ages = []
131 + for (let i = 0; i <= 120; i++) {
132 + // 0, 1, 2 -> '000', '001', '002'
133 + const ageStr = i.toString().padStart(3, '0')
134 + ages.push({ text: ageStr, value: i })
135 + }
136 + return [ages]
137 +})
138 +
139 +/**
140 + * 默认选中的值(3位数字格式)
141 + * @description 如果没有值,默认显示 018(18岁)
142 + * @returns {Array<string>} Picker 默认值格式
143 + *
144 + * @example
145 + * // modelValue = 18
146 + * defaultValue() // 返回: ['018']
147 + *
148 + * // modelValue = null
149 + * defaultValue() // 返回: ['018']
150 + */
151 +const defaultValue = computed(() => {
152 + const age = props.modelValue || 18
153 + return [age.toString().padStart(3, '0')]
154 +})
155 +
156 +/**
157 + * 显示的值(数字格式)
158 + * @description 将数字转换为字符串显示
159 + * @returns {string} 显示文本
160 + *
161 + * @example
162 + * // modelValue = 18
163 + * displayValue() // 返回: '18'
164 + *
165 + * // modelValue = null
166 + * displayValue() // 返回: ''
167 + */
168 +const displayValue = computed(() => {
169 + return props.modelValue ? props.modelValue.toString() : ''
170 +})
171 +
172 +/**
173 + * 确认选择
174 + * @param {Object} params - Picker 返回参数
175 + * @param {Array} params.selectedOptions - 选中的选项数组
176 + *
177 + * @example
178 + * // 用户选择 018
179 + * onConfirm({ selectedOptions: [{ text: '018', value: 18 }] })
180 + * // -> emit('update:modelValue', 18)
181 + */
182 +const onConfirm = ({ selectedOptions }) => {
183 + const age = selectedOptions[0]?.value
184 + if (age !== undefined) {
185 + emit('update:modelValue', age)
186 + }
187 + showPicker.value = false
188 +}
189 +</script>
190 +
191 +<style lang="less" scoped>
192 +/* 组件样式 */
193 +</style>
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
5 +
6 + <!-- 触发区域 -->
7 + <div
8 + class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
9 + :class="{ 'bg-gray-50': showDatePicker }"
10 + @tap="openDatePicker"
11 + >
12 + <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
13 + {{ displayValue || placeholder }}
14 + </span>
15 + <IconFont name="right" size="14" color="#9CA3AF" />
16 + </div>
17 +
18 + <!-- DatePicker 弹窗 -->
19 + <nut-datepicker
20 + v-model="showDatePicker"
21 + :min-date="minDate"
22 + :max-date="maxDate"
23 + @confirm="onConfirm"
24 + >
25 + </nut-datepicker>
26 + </div>
27 +</template>
28 +
29 +<script setup>
30 +/**
31 + * 日期选择器组件
32 + *
33 + * @description 使用 NutUI DatePicker 实现日期选择
34 + * - 支持年龄范围限制(minAge, maxAge)
35 + * - 格式:YYYY-MM-DD
36 + * - 可触发自动计算年龄
37 + * @author Claude Code
38 + * @example
39 + * <DatePicker
40 + * v-model="birthday"
41 + * label="出生年月日"
42 + * placeholder="请选择日期"
43 + * :min-age="0"
44 + * :max-age="120"
45 + * @change="onBirthdayChange"
46 + * />
47 + */
48 +import { ref, computed } from 'vue'
49 +import IconFont from '@/components/IconFont.vue'
50 +
51 +/**
52 + * 组件属性
53 + */
54 +const props = defineProps({
55 + /**
56 + * 标签文本
57 + * @type {string}
58 + */
59 + label: {
60 + type: String,
61 + default: ''
62 + },
63 +
64 + /**
65 + * 占位符文本
66 + * @type {string}
67 + */
68 + placeholder: {
69 + type: String,
70 + default: '请选择日期'
71 + },
72 +
73 + /**
74 + * 绑定的值(格式:YYYY-MM-DD)
75 + * @type {string}
76 + */
77 + modelValue: {
78 + type: String,
79 + default: ''
80 + },
81 +
82 + /**
83 + * 最小年龄(用于计算最大出生日期)
84 + * @type {number}
85 + * @default 0
86 + */
87 + minAge: {
88 + type: Number,
89 + default: 0
90 + },
91 +
92 + /**
93 + * 最大年龄(用于计算最小出生日期)
94 + * @type {number}
95 + * @default 120
96 + */
97 + maxAge: {
98 + type: Number,
99 + default: 120 }
100 +})
101 +
102 +/**
103 + * 组件事件
104 + */
105 +const emit = defineEmits([
106 + /**
107 + * 更新值事件
108 + * @event update:modelValue
109 + * @param {string} value - 选中的日期(格式:YYYY-MM-DD)
110 + */
111 + 'update:modelValue',
112 +
113 + /**
114 + * 值变化事件(可用于触发自动计算年龄)
115 + * @event change
116 + * @param {string} value - 选中的日期(格式:YYYY-MM-DD)
117 + */
118 + 'change'
119 +])
120 +
121 +/**
122 + * 控制 DatePicker 显示
123 + * @type {Ref<boolean>}
124 + */
125 +const showDatePicker = ref(false)
126 +
127 +/**
128 + * 打开日期选择器
129 + */
130 +const openDatePicker = () => {
131 + showDatePicker.value = true
132 +}
133 +
134 +/**
135 + * 计算最小可选日期(基于最大年龄)
136 + * @description maxAge 岁对应的出生日期
137 + * @type {ComputedRef<Date>}
138 + * @example
139 + * // maxAge = 75, 当前日期 = 2026-02-06
140 + * // minDate() // 返回: 1951-02-06
141 + */
142 +const minDate = computed(() => {
143 + const date = new Date()
144 + date.setFullYear(date.getFullYear() - props.maxAge)
145 + return date
146 +})
147 +
148 +/**
149 + * 计算最大可选日期(基于最小年龄)
150 + * @description minAge 岁对应的出生日期
151 + * @type {ComputedRef<Date>}
152 + * @example
153 + * // minAge = 0, 当前日期 = 2026-02-06
154 + * // maxDate() // 返回: 2026-02-06
155 + */
156 +const maxDate = computed(() => {
157 + const date = new Date()
158 + date.setFullYear(date.getFullYear() - props.minAge)
159 + return date
160 +})
161 +
162 +/**
163 + * 显示的值
164 + * @type {ComputedRef<string>}
165 + */
166 +const displayValue = computed(() => {
167 + return props.modelValue || ''
168 +})
169 +
170 +/**
171 + * 确认选择
172 + * @param {Object} values - DatePicker 返回的日期对象
173 + *
174 + * @example
175 + * // 用户选择 2020-01-01
176 + * onConfirm(new Date('2020-01-01'))
177 + * // -> emit('update:modelValue', '2020-01-01')
178 + * // -> emit('change', '2020-01-01')
179 + */
180 +const onConfirm = (values) => {
181 + const date = values
182 + const year = date.getFullYear()
183 + const month = String(date.getMonth() + 1).padStart(2, '0')
184 + const day = String(date.getDate()).padStart(2, '0')
185 +
186 + const formattedDate = `${year}-${month}-${day}`
187 + emit('update:modelValue', formattedDate)
188 + emit('change', formattedDate)
189 + showDatePicker.value = false
190 +}
191 +</script>
192 +
193 +<style lang="less" scoped>
194 +/* 组件样式 */
195 +</style>
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
5 +
6 + <!-- Radio Group -->
7 + <nut-radio-group v-model="selectedValue" direction="horizontal" class="mb-4">
8 + <nut-radio
9 + v-for="option in options"
10 + :key="option"
11 + :label="option"
12 + class="mr-8"
13 + >
14 + {{ option }}
15 + </nut-radio>
16 + </nut-radio-group>
17 + </div>
18 +</template>
19 +
20 +<script setup>
21 +/**
22 + * 单选组组件
23 + *
24 + * @description 使用 NutUI RadioGroup 实现单选功能
25 + * - 支持 v-model 双向绑定
26 + * - 横向排列
27 + * @author Claude Code
28 + * @example
29 + * <RadioGroup
30 + * v-model="gender"
31 + * label="性别"
32 + * :options="['男', '女']"
33 + * />
34 + */
35 +import { computed } from 'vue'
36 +
37 +/**
38 + * 组件属性
39 + */
40 +const props = defineProps({
41 + /**
42 + * 标签文本
43 + * @type {string}
44 + */
45 + label: {
46 + type: String,
47 + default: ''
48 + },
49 +
50 + /**
51 + * 选项数组
52 + * @type {Array<string>}
53 + * @example ['男', '女']
54 + * @example ['是', '否']
55 + */
56 + options: {
57 + type: Array,
58 + required: true
59 + },
60 +
61 + /**
62 + * 绑定的值
63 + * @type {string}
64 + */
65 + modelValue: {
66 + type: String,
67 + default: ''
68 + }
69 +})
70 +
71 +/**
72 + * 组件事件
73 + */
74 +const emit = defineEmits([
75 + /**
76 + * 更新值事件
77 + * @event update:modelValue
78 + * @param {string} value - 选中的选项
79 + */
80 + 'update:modelValue'
81 +])
82 +
83 +/**
84 + * 当前选中的值(用于 v-model)
85 + * @type {ComputedRef<string>}
86 + */
87 +const selectedValue = computed({
88 + get: () => props.modelValue,
89 + set: (val) => emit('update:modelValue', val)
90 +})
91 +</script>
92 +
93 +<style lang="less" scoped>
94 +/* 组件样式 */
95 +</style>
1 +<template>
2 + <div>
3 + <!-- 标签 -->
4 + <div v-if="label" class="text-sm text-gray-600 mb-2">{{ label }}</div>
5 +
6 + <!-- 触发区域 -->
7 + <div
8 + class="flex justify-between items-center border border-gray-200 rounded-lg p-3"
9 + :class="{ 'bg-gray-50': showPicker }"
10 + @tap="openPicker"
11 + >
12 + <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
13 + {{ displayValue || placeholder }}
14 + </span>
15 + <IconFont name="right" size="14" color="#9CA3AF" />
16 + </div>
17 +
18 + <!-- Picker 弹窗 -->
19 + <nut-popup
20 + position="bottom"
21 + v-model:visible="showPicker"
22 + :z-index="9999"
23 + :overlay="true"
24 + >
25 + <nut-picker
26 + :columns="pickerColumns"
27 + @confirm="onConfirm"
28 + @cancel="showPicker = false"
29 + />
30 + </nut-popup>
31 + </div>
32 +</template>
33 +
34 +<script setup>
35 +/**
36 + * 下拉选择器组件
37 + *
38 + * @description 使用 NutUI Picker 实现下拉选择功能
39 + * - key 和 value 相同(如"整付(0-75 岁)")
40 + * - 适用于缴费年期等场景
41 + * @author Claude Code
42 + * @example
43 + * <SelectPicker
44 + * v-model="paymentPeriod"
45 + * label="缴费年期"
46 + * placeholder="请选择缴费年期"
47 + * :options="['整付(0-75 岁)', '5 年(0-70 岁)']"
48 + * />
49 + */
50 +import { ref, computed } from 'vue'
51 +import IconFont from '@/components/IconFont.vue'
52 +
53 +/**
54 + * 组件属性
55 + */
56 +const props = defineProps({
57 + /**
58 + * 标签文本
59 + * @type {string}
60 + */
61 + label: {
62 + type: String,
63 + default: ''
64 + },
65 +
66 + /**
67 + * 占位符文本
68 + * @type {string}
69 + */
70 + placeholder: {
71 + type: String,
72 + default: '请选择'
73 + },
74 +
75 + /**
76 + * 绑定的值
77 + * @type {string}
78 + */
79 + modelValue: {
80 + type: String,
81 + default: ''
82 + },
83 +
84 + /**
85 + * 选项数组(key 和 value 相同)
86 + * @type {Array<string>}
87 + * @example ['整付(0-75 岁)', '5 年(0-70 岁)', '10 年(0-70 岁)']
88 + */
89 + options: {
90 + type: Array,
91 + required: true
92 + }
93 +})
94 +
95 +/**
96 + * 组件事件
97 + */
98 +const emit = defineEmits([
99 + /**
100 + * 更新值事件
101 + * @event update:modelValue
102 + * @param {string} value - 选中的选项
103 + */
104 + 'update:modelValue'
105 +])
106 +
107 +/**
108 + * 控制 Picker 显示
109 + * @type {Ref<boolean>}
110 + */
111 +const showPicker = ref(false)
112 +
113 +/**
114 + * 打开选择器
115 + */
116 +const openPicker = () => {
117 + showPicker.value = true
118 +}
119 +
120 +/**
121 + * 转换为 Picker 格式
122 + * @description 将选项数组转换为 Picker 需要的格式
123 + * @type {ComputedRef<Array<{text: string, value: string}>>}
124 + * @example
125 + * // options = ['整付(0-75 岁)', '5 年(0-70 岁)']
126 + * // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...]
127 + */
128 +const pickerColumns = computed(() => {
129 + return props.options.map(option => ({
130 + text: option,
131 + value: option // key 和 value 相同
132 + }))
133 +})
134 +
135 +/**
136 + * 显示的值
137 + * @type {ComputedRef<string>}
138 + */
139 +const displayValue = computed(() => {
140 + return props.modelValue || ''
141 +})
142 +
143 +/**
144 + * 确认选择
145 + * @param {Object} params - Picker 返回参数
146 + * @param {Array} params.selectedOptions - 选中的选项数组
147 + *
148 + * @example
149 + * // 用户选择 '整付(0-75 岁)'
150 + * onConfirm({ selectedOptions: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }] })
151 + * // -> emit('update:modelValue', '整付(0-75 岁)')
152 + */
153 +const onConfirm = ({ selectedOptions }) => {
154 + const value = selectedOptions[0]?.value
155 + if (value !== undefined) {
156 + emit('update:modelValue', value)
157 + }
158 + showPicker.value = false
159 +}
160 +</script>
161 +
162 +<style lang="less" scoped>
163 +/* 组件样式 */
164 +</style>
1 +<template>
2 + <!-- 使用 PlanPopup 容器组件 -->
3 + <PlanPopup :title="templateConfig?.name || '计划书'" @close="close" @submit="submit">
4 + <!-- 动态加载模版组件 -->
5 + <component
6 + :is="currentTemplateComponent"
7 + v-model="formData"
8 + :config="templateConfig"
9 + v-if="currentTemplateComponent"
10 + />
11 +
12 + <!-- 错误提示 -->
13 + <div v-else class="text-center text-gray-500 py-10">
14 + <p>⚠️ 未找到对应的计划书模版</p>
15 + <p class="text-sm mt-2">form_sn: {{ product?.form_sn }}</p>
16 + </div>
17 + </PlanPopup>
18 +</template>
19 +
20 +<script setup>
21 +/**
22 + * 计划书表单容器
23 + *
24 + * @description 根据产品的 form_sn 动态加载对应的计划书模版组件
25 + * - 自动识别产品并加载模版
26 + * - 支持后端 plan_config 动态配置
27 + * - 统一的表单提交处理
28 + * @author Claude Code
29 + * @example
30 + * <PlanFormContainer
31 + * v-model:visible="showPlanPopup"
32 + * :product="selectedProduct"
33 + * @close="handleClose"
34 + * @submit="handleSubmit"
35 + * />
36 + */
37 +import { ref, computed, watch } from 'vue'
38 +import PlanPopup from './PlanPopup/index.vue'
39 +import LifeInsuranceTemplate from './PlanTemplates/LifeInsuranceTemplate.vue'
40 +import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue'
41 +import SavingsTemplate from './SavingsTemplate.vue'
42 +import { PLAN_TEMPLATES } from '@/config/plan-templates'
43 +
44 +/**
45 + * 组件属性
46 + */
47 +const props = defineProps({
48 + /**
49 + * 是否显示弹窗
50 + * @type {boolean}
51 + */
52 + visible: {
53 + type: Boolean,
54 + default: false
55 + },
56 +
57 + /**
58 + * 产品对象
59 + * @type {Object}
60 + * @property {number} id - 产品 ID
61 + * @property {string} product_name - 产品名称
62 + * @property {string} form_sn - 模版标识(必需)
63 + * @property {Object} plan_config - 模版配置(可选,后端返回)
64 + */
65 + product: {
66 + type: Object,
67 + required: true
68 + }
69 +})
70 +
71 +/**
72 + * 组件事件
73 + */
74 +const emit = defineEmits([
75 + /**
76 + * 更新显示状态事件
77 + * @event update:visible
78 + * @param {boolean} value - 显示状态
79 + */
80 + 'update:visible',
81 +
82 + /**
83 + * 关闭事件
84 + * @event close
85 + */
86 + 'close',
87 +
88 + /**
89 + * 提交事件
90 + * @event submit
91 + * @param {Object} formData - 表单数据
92 + */
93 + 'submit'
94 +])
95 +
96 +/**
97 + * 当前模版配置
98 + * @description 根据 form_sn 从配置文件中查找,并合并后端 plan_config
99 + * @type {ComputedRef<Object|null>}
100 + *
101 + * @example
102 + * // product.form_sn = 'life-insurance-wiop3e'
103 + * // templateConfig() 返回: {
104 + * // name: 'WIOP3E...',
105 + * // component: 'LifeInsuranceTemplate',
106 + * // config: { currency: 'USD', ... }
107 + * // }
108 + */
109 +const templateConfig = computed(() => {
110 + if (!props.product?.form_sn) {
111 + console.warn('[PlanFormContainer] 产品缺少 form_sn 字段', props.product)
112 + return null
113 + }
114 +
115 + // 从配置文件中查找模版
116 + const config = PLAN_TEMPLATES[props.product.form_sn]
117 + if (!config) {
118 + console.error(`[PlanFormContainer] 未找到模版配置: ${props.product.form_sn}`)
119 + return null
120 + }
121 +
122 + // 合并配置:优先使用后端返回的 plan_config,否则使用配置文件中的默认配置
123 + return {
124 + ...config,
125 + config: {
126 + ...config.config,
127 + ...(props.product.plan_config || {})
128 + }
129 + }
130 +})
131 +
132 +/**
133 + * 当前模版组件
134 + * @description 根据 component 名称动态加载对应的组件
135 + * @type {ComputedRef<Component|null>}
136 + */
137 +const currentTemplateComponent = computed(() => {
138 + if (!templateConfig.value) return null
139 +
140 + const componentMap = {
141 + 'LifeInsuranceTemplate': LifeInsuranceTemplate,
142 + 'CriticalIllnessTemplate': CriticalIllnessTemplate,
143 + 'SavingsTemplate': SavingsTemplate
144 + }
145 +
146 + const componentName = templateConfig.value.component
147 + return componentMap[componentName] || null
148 +})
149 +
150 +/**
151 + * 表单数据
152 + * @type {Ref<Object>}
153 + */
154 +const formData = ref({})
155 +
156 +/**
157 + * 监听产品变化,重置表单数据
158 + */
159 +watch(
160 + () => props.product,
161 + (newProduct) => {
162 + if (newProduct) {
163 + // 重置表单数据
164 + formData.value = {}
165 + }
166 + },
167 + { immediate: true }
168 +)
169 +
170 +/**
171 + * 监听显示状态变化
172 + */
173 +watch(
174 + () => props.visible,
175 + (newVal) => {
176 + emit('update:visible', newVal)
177 + }
178 +)
179 +
180 +/**
181 + * 关闭弹窗
182 + */
183 +const close = () => {
184 + emit('close')
185 +}
186 +
187 +/**
188 + * 提交表单
189 + * @description 将表单数据和产品信息一起提交
190 + */
191 +const submit = () => {
192 + console.log('[PlanFormContainer] 提交计划书:', {
193 + product_id: props.product.id,
194 + product_name: props.product.product_name,
195 + form_sn: props.product.form_sn,
196 + form_data: formData.value
197 + })
198 +
199 + emit('submit', {
200 + product_id: props.product.id,
201 + form_sn: props.product.form_sn,
202 + form_data: formData.value
203 + })
204 +}
205 +</script>
206 +
207 +<style lang="less" scoped>
208 +/* 容器样式 */
209 +</style>
1 +<template>
2 + <div>
3 + <!-- 性别 -->
4 + <PlanFieldRadio
5 + v-model="form.gender"
6 + label="性别"
7 + :options="['男', '女']"
8 + />
9 +
10 + <!-- 年龄(根据出生日期自动计算,可编辑) -->
11 + <PlanFieldAgePicker
12 + v-model="form.age"
13 + label="年龄"
14 + placeholder="请选择出生日期自动计算"
15 + />
16 +
17 + <!-- 出生年月日 -->
18 + <PlanFieldDatePicker
19 + v-model="form.birthday"
20 + label="出生年月日"
21 + placeholder="请选择日期"
22 + @change="onBirthdayChange"
23 + />
24 +
25 + <!-- 是否吸烟 -->
26 + <PlanFieldRadio
27 + v-model="form.smoker"
28 + label="是否吸烟"
29 + :options="['是', '否']"
30 + />
31 +
32 + <!-- 保额 -->
33 + <PlanFieldAmount
34 + v-model="form.coverage"
35 + label="保额"
36 + placeholder="请输入保额"
37 + :currency="config.currency"
38 + />
39 +
40 + <!-- 缴费年期 -->
41 + <PlanFieldSelect
42 + v-model="form.payment_period"
43 + label="缴费年期"
44 + placeholder="请选择缴费年期"
45 + :options="config.payment_periods"
46 + />
47 +
48 + <!-- 保险期间 -->
49 + <div class="flex justify-between items-start mb-5">
50 + <span class="text-sm text-gray-600 mt-1.5">保险期间</span>
51 + <div class="bg-blue-50 rounded-md px-3 py-1.5">
52 + <span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
53 + </div>
54 + </div>
55 + </div>
56 +</template>
57 +
58 +<script setup>
59 +/**
60 + * 重疾保险计划书模版
61 + *
62 + * @description MPC/MBC PRO/MBC2 等重疾保险产品的计划书录入表单
63 + * - 支持出生日期自动计算年龄
64 + * - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
65 + * @author Claude Code
66 + * @example
67 + * <CriticalIllnessTemplate
68 + * v-model="formData"
69 + * :config="templateConfig"
70 + * />
71 + */
72 +import { reactive, watch } from 'vue'
73 +import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
74 +import PlanFieldAmount from '../PlanFields/AmountInput.vue'
75 +import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
76 +import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
77 +import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
78 +
79 +/**
80 + * 组件属性
81 + */
82 +const props = defineProps({
83 + /**
84 + * 表单数据对象
85 + * @type {Object}
86 + */
87 + modelValue: {
88 + type: Object,
89 + default: () => ({})
90 + },
91 +
92 + /**
93 + * 模版配置
94 + * @type {Object}
95 + * @property {string} currency - 币种代码
96 + * @property {Array<string>} payment_periods - 缴费年期选项
97 + * @property {Object} age_range - 年龄范围 { min, max }
98 + * @property {string} insurance_period - 保险期间
99 + */
100 + config: {
101 + type: Object,
102 + required: true
103 + }
104 +})
105 +
106 +/**
107 + * 组件事件
108 + */
109 +const emit = defineEmits([
110 + /**
111 + * 更新表单数据事件
112 + * @event update:modelValue
113 + * @param {Object} value - 表单数据
114 + */
115 + 'update:modelValue'
116 +])
117 +
118 +/**
119 + * 表单数据
120 + * @type {Object}
121 + */
122 +const form = reactive(props.modelValue || {})
123 +
124 +/**
125 + * 监听表单数据变化,同步到父组件
126 + */
127 +watch(
128 + () => form,
129 + (newVal) => emit('update:modelValue', newVal),
130 + { deep: true }
131 +)
132 +
133 +/**
134 + * 出生日期变化时自动计算年龄
135 + * @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
136 + *
137 + * @description 用户选择出生日期后,自动计算并填充年龄字段
138 + * 计算公式:当前年份 - 出生年份
139 + */
140 +const onBirthdayChange = (birthday) => {
141 + if (birthday) {
142 + const birthYear = new Date(birthday).getFullYear()
143 + const currentYear = new Date().getFullYear()
144 + const calculatedAge = currentYear - birthYear
145 +
146 + // 自动填充年龄字段
147 + form.age = calculatedAge
148 + }
149 +}
150 +</script>
151 +
152 +<style lang="less" scoped>
153 +/* 模版样式 */
154 +</style>
1 +<template>
2 + <div>
3 + <!-- 性别 -->
4 + <PlanFieldRadio
5 + v-model="form.gender"
6 + label="性别"
7 + :options="['男', '女']"
8 + />
9 +
10 + <!-- 年龄(根据出生日期自动计算,可编辑) -->
11 + <PlanFieldAgePicker
12 + v-model="form.age"
13 + label="年龄"
14 + placeholder="请选择出生日期自动计算"
15 + />
16 +
17 + <!-- 出生年月日 -->
18 + <PlanFieldDatePicker
19 + v-model="form.birthday"
20 + label="出生年月日"
21 + placeholder="请选择日期"
22 + @change="onBirthdayChange"
23 + />
24 +
25 + <!-- 是否吸烟 -->
26 + <PlanFieldRadio
27 + v-model="form.smoker"
28 + label="是否吸烟"
29 + :options="['是', '否']"
30 + />
31 +
32 + <!-- 保额 -->
33 + <PlanFieldAmount
34 + v-model="form.coverage"
35 + label="保额"
36 + placeholder="请输入保额"
37 + :currency="config.currency"
38 + />
39 +
40 + <!-- 缴费年期 -->
41 + <PlanFieldSelect
42 + v-model="form.payment_period"
43 + label="缴费年期"
44 + placeholder="请选择缴费年期"
45 + :options="config.payment_periods"
46 + />
47 +
48 + <!-- 保险期间 -->
49 + <div class="flex justify-between items-start mb-5">
50 + <span class="text-sm text-gray-600 mt-1.5">保险期间</span>
51 + <div class="bg-blue-50 rounded-md px-3 py-1.5">
52 + <span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
53 + </div>
54 + </div>
55 + </div>
56 +</template>
57 +
58 +<script setup>
59 +/**
60 + * 人寿保险计划书模版
61 + *
62 + * @description WIOP3E/WIOP3 等人寿保险产品的计划书录入表单
63 + * - 支持出生日期自动计算年龄
64 + * - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
65 + * @author Claude Code
66 + * @example
67 + * <LifeInsuranceTemplate
68 + * v-model="formData"
69 + * :config="templateConfig"
70 + * />
71 + */
72 +import { reactive, watch, toRefs } from 'vue'
73 +import PlanFieldAgePicker from '../PlanFields/AgePicker.vue'
74 +import PlanFieldAmount from '../PlanFields/AmountInput.vue'
75 +import PlanFieldDatePicker from '../PlanFields/DatePicker.vue'
76 +import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
77 +import PlanFieldSelect from '../PlanFields/SelectPicker.vue'
78 +
79 +/**
80 + * 组件属性
81 + */
82 +const props = defineProps({
83 + /**
84 + * 表单数据对象
85 + * @type {Object}
86 + */
87 + modelValue: {
88 + type: Object,
89 + default: () => ({})
90 + },
91 +
92 + /**
93 + * 模版配置
94 + * @type {Object}
95 + * @property {string} currency - 币种代码
96 + * @property {Array<string>} payment_periods - 缴费年期选项
97 + * @property {Object} age_range - 年龄范围 { min, max }
98 + * @property {string} insurance_period - 保险期间
99 + */
100 + config: {
101 + type: Object,
102 + required: true
103 + }
104 +})
105 +
106 +/**
107 + * 组件事件
108 + */
109 +const emit = defineEmits([
110 + /**
111 + * 更新表单数据事件
112 + * @event update:modelValue
113 + * @param {Object} value - 表单数据
114 + */
115 + 'update:modelValue'
116 +])
117 +
118 +/**
119 + * 表单数据
120 + * @type {Object}
121 + */
122 +const form = reactive(props.modelValue || {})
123 +
124 +/**
125 + * 监听表单数据变化,同步到父组件
126 + */
127 +watch(
128 + () => form,
129 + (newVal) => emit('update:modelValue', newVal),
130 + { deep: true }
131 +)
132 +
133 +/**
134 + * 出生日期变化时自动计算年龄
135 + * @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
136 + *
137 + * @description 用户选择出生日期后,自动计算并填充年龄字段
138 + * 计算公式:当前年份 - 出生年份
139 + */
140 +const onBirthdayChange = (birthday) => {
141 + if (birthday) {
142 + const birthYear = new Date(birthday).getFullYear()
143 + const currentYear = new Date().getFullYear()
144 + const calculatedAge = currentYear - birthYear
145 +
146 + // 自动填充年龄字段
147 + form.age = calculatedAge
148 + }
149 +}
150 +</script>
151 +
152 +<style lang="less" scoped>
153 +/* 模版样式 */
154 +</style>
1 +<template>
2 + <div>
3 + <!-- 性别 -->
4 + <PlanFieldRadio
5 + v-model="form.gender"
6 + label="性别"
7 + :options="['男', '女']"
8 + />
9 +
10 + <!-- 年龄(根据出生日期自动计算,可编辑) -->
11 + <PlanFieldAgePicker
12 + v-model="form.age"
13 + label="年龄"
14 + placeholder="请选择出生日期自动计算"
15 + />
16 +
17 + <!-- 出生年月日 -->
18 + <PlanFieldDatePicker
19 + v-model="form.birthday"
20 + label="出生年月日"
21 + placeholder="请选择日期"
22 + @change="onBirthdayChange"
23 + />
24 +
25 + <!-- 是否吸烟 -->
26 + <PlanFieldRadio
27 + v-model="form.smoker"
28 + label="是否吸烟"
29 + :options="['是', '否']"
30 + />
31 +
32 + <!-- 保额 -->
33 + <PlanFieldAmount
34 + v-model="form.coverage"
35 + label="保额"
36 + placeholder="请输入保额"
37 + :currency="config.currency"
38 + />
39 +
40 + <!-- 缴费年期 -->
41 + <PlanFieldSelect
42 + v-model="form.payment_period"
43 + label="缴费年期"
44 + placeholder="请选择缴费年期"
45 + :options="config.payment_periods"
46 + />
47 +
48 + <!-- 保险期间 -->
49 + <div class="flex justify-between items-start mb-5">
50 + <span class="text-sm text-gray-600 mt-1.5">保险期间</span>
51 + <div class="bg-blue-50 rounded-md px-3 py-1.5">
52 + <span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
53 + </div>
54 + </div>
55 +
56 + <!-- ====== 提取计划功能(储蓄产品专用)====== -->
57 + <div v-if="config.withdrawal_plan?.enabled" class="mt-6 pt-6 border-t border-gray-200">
58 + <div class="text-base font-medium text-gray-900 mb-4">提取计划</div>
59 +
60 + <!-- 提取方式选择 -->
61 + <PlanFieldRadio
62 + v-model="form.withdrawal_plan.mode"
63 + label="提取方式"
64 + :options="config.withdrawal_plan.withdrawal_modes"
65 + />
66 +
67 + <!-- 开始年龄 -->
68 + <PlanFieldAgePicker
69 + v-model="form.withdrawal_plan.start_age"
70 + label="开始年龄"
71 + placeholder="请选择开始提取年龄"
72 + />
73 +
74 + <!-- 提取年期 -->
75 + <PlanFieldSelect
76 + v-model="form.withdrawal_plan.withdrawal_period"
77 + label="提取年期"
78 + placeholder="请选择提取年期"
79 + :options="config.withdrawal_plan.withdrawal_periods"
80 + />
81 +
82 + <!-- 方式1:年龄指定金额 - 额外字段 -->
83 + <template v-if="form.withdrawal_plan.mode === '年龄指定金额'">
84 + <!-- 每年提取金额 -->
85 + <PlanFieldAmount
86 + v-model="form.withdrawal_plan.annual_amount"
87 + label="每年提取金额"
88 + placeholder="请输入金额"
89 + :currency="form.withdrawal_plan.currency || config.withdrawal_plan.default_currency"
90 + />
91 +
92 + <!-- 币种 -->
93 + <div class="mb-5">
94 + <div class="text-sm text-gray-600 mb-2">币种</div>
95 + <div class="flex gap-2">
96 + <button
97 + v-for="curr in currencyOptions"
98 + :key="curr.value"
99 + :class="[
100 + 'px-4 py-2 rounded-lg text-sm border transition-colors',
101 + (form.withdrawal_plan.currency || config.withdrawal_plan.default_currency) === curr.value
102 + ? 'bg-blue-600 text-white border-blue-600'
103 + : 'bg-white text-gray-600 border-gray-200'
104 + ]"
105 + @tap="selectCurrency(curr.value)"
106 + >
107 + {{ curr.label }}
108 + </button>
109 + </div>
110 + </div>
111 +
112 + <!-- 增加率 -->
113 + <div class="mb-5">
114 + <div class="text-sm text-gray-600 mb-2">增加率(%</div>
115 + <nut-input
116 + v-model="form.withdrawal_plan.increase_rate"
117 + type="digit"
118 + placeholder="请输入增加率"
119 + class="border border-gray-200 rounded-lg"
120 + />
121 + </div>
122 + </template>
123 + </div>
124 + </div>
125 +</template>
126 +
127 +<script setup>
128 +/**
129 + * 储蓄型保险计划书模版
130 + *
131 + * @description GS/GC/FA/LV2 等储蓄型保险产品的计划书录入表单
132 + * - 支持出生日期自动计算年龄
133 + * - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
134 + * - 提取计划功能:年龄指定金额、最高固定金额
135 + * @author Claude Code
136 + * @example
137 + * <SavingsTemplate
138 + * v-model="formData"
139 + * :config="templateConfig"
140 + * />
141 + */
142 +import { reactive, watch, computed } from 'vue'
143 +import PlanFieldAgePicker from './PlanFields/AgePicker.vue'
144 +import PlanFieldAmount from './PlanFields/AmountInput.vue'
145 +import PlanFieldDatePicker from './PlanFields/DatePicker.vue'
146 +import PlanFieldRadio from './PlanFields/RadioGroup.vue'
147 +import PlanFieldSelect from './PlanFields/SelectPicker.vue'
148 +
149 +/**
150 + * 组件属性
151 + */
152 +const props = defineProps({
153 + /**
154 + * 表单数据对象
155 + * @type {Object}
156 + */
157 + modelValue: {
158 + type: Object,
159 + default: () => ({})
160 + },
161 +
162 + /**
163 + * 模版配置
164 + * @type {Object}
165 + * @property {string} currency - 币种代码
166 + * @property {Array<string>} payment_periods - 缴费年期选项
167 + * @property {Object} age_range - 年龄范围 { min, max }
168 + * @property {string} insurance_period - 保险期间
169 + * @property {Object} withdrawal_plan - 提取计划配置
170 + */
171 + config: {
172 + type: Object,
173 + required: true
174 + }
175 +})
176 +
177 +/**
178 + * 组件事件
179 + */
180 +const emit = defineEmits([
181 + /**
182 + * 更新表单数据事件
183 + * @event update:modelValue
184 + * @param {Object} value - 表单数据
185 + */
186 + 'update:modelValue'
187 +])
188 +
189 +/**
190 + * 表单数据
191 + * @type {Object}
192 + */
193 +const form = reactive(props.modelValue || {
194 + // 初始化提取计划数据
195 + withdrawal_plan: {
196 + mode: '年龄指定金额',
197 + start_age: null,
198 + withdrawal_period: null,
199 + annual_amount: null,
200 + currency: props.config?.withdrawal_plan?.default_currency || 'HKD',
201 + increase_rate: 0
202 + }
203 +})
204 +
205 +/**
206 + * 币种选项(用于提取计划)
207 + * @type {ComputedRef<Array>}
208 + */
209 +const currencyOptions = computed(() => {
210 + const CURRENCY_MAP = {
211 + HKD: { label: '港币', value: 'HKD' },
212 + USD: { label: '美元', value: 'USD' },
213 + CNY: { label: '人民币', value: 'CNY' }
214 + }
215 +
216 + const supportedCurrencies = props.config?.withdrawal_plan?.currencies || ['HKD']
217 + return supportedCurrencies
218 + .map(code => CURRENCY_MAP[code])
219 + .filter(Boolean)
220 +})
221 +
222 +/**
223 + * 监听表单数据变化,同步到父组件
224 + */
225 +watch(
226 + () => form,
227 + (newVal) => emit('update:modelValue', newVal),
228 + { deep: true }
229 +)
230 +
231 +/**
232 + * 出生日期变化时自动计算年龄
233 + * @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
234 + *
235 + * @description 用户选择出生日期后,自动计算并填充年龄字段
236 + * 计算公式:当前年份 - 出生年份
237 + */
238 +const onBirthdayChange = (birthday) => {
239 + if (birthday) {
240 + const birthYear = new Date(birthday).getFullYear()
241 + const currentYear = new Date().getFullYear()
242 + const calculatedAge = currentYear - birthYear
243 +
244 + // 自动填充年龄字段
245 + form.age = calculatedAge
246 + }
247 +}
248 +
249 +/**
250 + * 选择币种(用于提取计划)
251 + * @param {string} currencyCode - 币种代码
252 + */
253 +const selectCurrency = (currencyCode) => {
254 + if (form.withdrawal_plan) {
255 + form.withdrawal_plan.currency = currencyCode
256 + }
257 +}
258 +</script>
259 +
260 +<style lang="less" scoped>
261 +/* 模版样式 */
262 +</style>
1 +/**
2 + * 计划书模版配置
3 + *
4 + * @description 定义产品 form_sn 到模版组件和配置的映射关系
5 + * @module config/plan-templates
6 + * @author Claude Code
7 + * @created 2026-02-06
8 + */
9 +
10 +/**
11 + * 计划书模版配置映射
12 + * @description form_sn 为产品 API 返回的字段,用于标识该产品使用的计划书模版
13 + *
14 + * @example
15 + * // 产品 API 返回
16 + * {
17 + * id: 1,
18 + * product_name: "WIOP3E 盈传创富保障计划 3 - 优选版",
19 + * form_sn: "life-insurance-wiop3e" // 对应下面的配置 key
20 + * }
21 + */
22 +export const PLAN_TEMPLATES = {
23 + // 人寿保险产品 - WIOP3E
24 + 'life-insurance-wiop3e': {
25 + name: 'WIOP3E 盈传创富保障计划 3 - 优选版',
26 + component: 'LifeInsuranceTemplate',
27 + config: {
28 + currency: 'USD', // 币种:USD/CNY/HKD/EUR
29 + payment_periods: [
30 + // 缴费年期选项
31 + '整付(0-75 岁)',
32 + '5 年(0-70 岁)',
33 + '10 年(0-70 岁)'
34 + ],
35 + age_range: { min: 0, max: 75 }, // 年龄范围
36 + insurance_period: '终身' // 保险期间
37 + }
38 + },
39 +
40 + // 人寿保险产品 - WIOP3
41 + 'life-insurance-wiop3': {
42 + name: 'WIOP3 - 盈传创富保障计划 3',
43 + component: 'LifeInsuranceTemplate',
44 + config: {
45 + currency: 'USD',
46 + payment_periods: [
47 + '整付(0-75 岁)',
48 + '5 年(0-70 岁)',
49 + '10 年(0-70 岁)'
50 + ],
51 + age_range: { min: 0, max: 75 },
52 + insurance_period: '终身'
53 + }
54 + },
55 +
56 + // 重疾保险产品 - MPC
57 + 'critical-illness-mpc': {
58 + name: 'MPC 守护无间重疾',
59 + component: 'CriticalIllnessTemplate',
60 + config: {
61 + currency: 'CNY',
62 + payment_periods: [
63 + '10 年(15 日 - 65 岁)',
64 + '20 年(15 日 - 65 岁)',
65 + '25 年(15 日 - 60 岁)'
66 + ],
67 + age_range: { min: 0, max: 65 },
68 + insurance_period: '终身'
69 + }
70 + },
71 +
72 + // 重疾保险产品 - MBC PRO
73 + 'critical-illness-mbc-pro': {
74 + name: 'MBC PRO 活跃人生重疾保 PRO',
75 + component: 'CriticalIllnessTemplate',
76 + config: {
77 + currency: 'CNY',
78 + payment_periods: [
79 + '10 年(15 日 - 65 岁)',
80 + '20 年(15 日 - 65 岁)',
81 + '25 年(15 日 - 60 岁)'
82 + ],
83 + age_range: { min: 0, max: 65 },
84 + insurance_period: '终身'
85 + }
86 + },
87 +
88 + // 重疾保险产品 - MBC2
89 + 'critical-illness-mbc2': {
90 + name: 'MBC2 活跃人生重疾保 2',
91 + component: 'CriticalIllnessTemplate',
92 + config: {
93 + currency: 'CNY',
94 + payment_periods: [
95 + '10 年(15 日 - 65 岁)',
96 + '20 年(15 日 - 65 岁)',
97 + '25 年(15 日 - 60 岁)'
98 + ],
99 + age_range: { min: 0, max: 65 },
100 + insurance_period: '终身'
101 + }
102 + },
103 +
104 + // ====== 储蓄型产品(统一逻辑) ======
105 +
106 + // GS - 宏摯傳承保障計劃
107 + 'savings-gs': {
108 + name: '宏摯傳承保障計劃',
109 + component: 'SavingsTemplate',
110 + category: 'savings', // 储蓄型产品
111 + config: {
112 + currency: 'USD', // 默认美元
113 + payment_periods: [
114 + '整付(0-100 岁)',
115 + '5 年(0-80 岁)',
116 + '10 年(0-75 岁)'
117 + ],
118 + age_range: { min: 0, max: 100 },
119 + insurance_period: '终身',
120 + // 提取计划配置
121 + withdrawal_plan: {
122 + enabled: true,
123 + currencies: ['HKD', 'USD', 'CNY'], // 支持的币种
124 + default_currency: 'HKD',
125 + withdrawal_modes: [
126 + '年龄指定金额', // 方式1
127 + '最高固定金额' // 方式2
128 + ],
129 + withdrawal_periods: [
130 + '1年',
131 + '2年',
132 + '3年',
133 + '5年',
134 + '10年',
135 + '15年',
136 + '20年',
137 + '终身'
138 + ]
139 + }
140 + }
141 + },
142 +
143 + // GC - 宏摯家傳承保險計劃
144 + 'savings-gc': {
145 + name: '宏摯家傳承保險計劃',
146 + component: 'SavingsTemplate',
147 + category: 'savings',
148 + config: {
149 + currency: 'USD',
150 + payment_periods: [
151 + '整付(0-100 岁)',
152 + '5 年(0-80 岁)',
153 + '10 年(0-75 岁)'
154 + ],
155 + age_range: { min: 0, max: 100 },
156 + insurance_period: '终身',
157 + withdrawal_plan: {
158 + enabled: true,
159 + currencies: ['HKD', 'USD', 'CNY'],
160 + default_currency: 'HKD',
161 + withdrawal_modes: ['年龄指定金额', '最高固定金额'],
162 + withdrawal_periods: [
163 + '1年',
164 + '2年',
165 + '3年',
166 + '5年',
167 + '10年',
168 + '15年',
169 + '20年',
170 + '终身'
171 + ]
172 + }
173 + }
174 + },
175 +
176 + // FA - 宏浚傳承保障計劃
177 + 'savings-fa': {
178 + name: '宏浚傳承保障計劃',
179 + component: 'SavingsTemplate',
180 + category: 'savings',
181 + config: {
182 + currency: 'USD',
183 + payment_periods: [
184 + '整付(0-100 岁)',
185 + '5 年(0-80 岁)',
186 + '10 年(0-75 岁)'
187 + ],
188 + age_range: { min: 0, max: 100 },
189 + insurance_period: '终身',
190 + withdrawal_plan: {
191 + enabled: true,
192 + currencies: ['HKD', 'USD', 'CNY'],
193 + default_currency: 'HKD',
194 + withdrawal_modes: ['年龄指定金额', '最高固定金额'],
195 + withdrawal_periods: [
196 + '1年',
197 + '2年',
198 + '3年',
199 + '5年',
200 + '10年',
201 + '15年',
202 + '20年',
203 + '终身'
204 + ]
205 + }
206 + }
207 + },
208 +
209 + // LV2 - 赤霞珠終身壽險計劃2(储蓄型终身寿险)
210 + 'savings-lv2': {
211 + name: '赤霞珠終身壽險計劃2',
212 + component: 'SavingsTemplate',
213 + category: 'savings',
214 + config: {
215 + currency: 'USD',
216 + payment_periods: [
217 + '整付(0-100 岁)',
218 + '5 年(0-80 岁)',
219 + '10 年(0-75 岁)'
220 + ],
221 + age_range: { min: 0, max: 100 },
222 + insurance_period: '终身',
223 + withdrawal_plan: {
224 + enabled: true,
225 + currencies: ['HKD', 'USD', 'CNY'],
226 + default_currency: 'HKD',
227 + withdrawal_modes: ['年龄指定金额', '最高固定金额'],
228 + withdrawal_periods: [
229 + '1年',
230 + '2年',
231 + '3年',
232 + '5年',
233 + '10年',
234 + '15年',
235 + '20年',
236 + '终身'
237 + ]
238 + }
239 + }
240 + }
241 +}
242 +
243 +/**
244 + * 全局功能开关
245 + * @description 用于控制实验性功能或未来扩展功能的开关
246 + *
247 + * @example 开启多币种功能:设置 MULTI_CURRENCY_ENABLED = true
248 + */
249 +export const FEATURE_FLAGS = {
250 + /**
251 + * 多币种切换功能
252 + * @description false: 方案 1 - 固定币种(当前实现)
253 + * true: 方案 2 - 支持多币种切换(未来扩展)
254 + * @type {boolean}
255 + */
256 + MULTI_CURRENCY_ENABLED: false
257 +}
258 +
259 +/**
260 + * 币种符号映射
261 + * @description 币种代码到符号的映射关系
262 + */
263 +export const CURRENCY_SYMBOLS = {
264 + CNY: '¥', // 人民币
265 + USD: '$', // 美元
266 + HKD: 'HK$', // 港币
267 + EUR: '€' // 欧元
268 +}
269 +
270 +/**
271 + * 币种完整信息映射
272 + * @description 币种代码到完整信息的映射(用于多币种模式)
273 + */
274 +export const CURRENCY_MAP = {
275 + CNY: { label: '人民币', symbol: '¥', value: 'CNY' },
276 + USD: { label: '美元', symbol: '$', value: 'USD' },
277 + HKD: { label: '港币', symbol: 'HK$', value: 'HKD' },
278 + EUR: { label: '欧元', symbol: '€', value: 'EUR' }
279 +}
280 +
281 +/**
282 + * 根据 form_sn 获取模版配置
283 + * @param {string} formSn - 产品 API 返回的 form_sn 字段
284 + * @returns {Object|null} 模版配置对象,未找到返回 null
285 + *
286 + * @example
287 + * const config = getTemplateConfig('life-insurance-wiop3e')
288 + * // 返回: { name: 'WIOP3E...', component: 'LifeInsuranceTemplate', config: {...} }
289 + */
290 +export function getTemplateConfig(formSn) {
291 + if (!formSn) {
292 + console.warn('[plan-templates] form_sn 为空')
293 + return null
294 + }
295 +
296 + const config = PLAN_TEMPLATES[formSn]
297 + if (!config) {
298 + console.error(`[plan-templates] 未找到模版配置: ${formSn}`)
299 + return null
300 + }
301 +
302 + return config
303 +}
304 +
305 +/**
306 + * 获取币种符号
307 + * @param {string} currencyCode - 币种代码(CNY/USD/HKD/EUR)
308 + * @returns {string} 币种符号
309 + *
310 + * @example
311 + * const symbol = getCurrencySymbol('USD') // 返回: '$'
312 + */
313 +export function getCurrencySymbol(currencyCode) {
314 + return CURRENCY_SYMBOLS[currencyCode] || '¥'
315 +}
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
42 <view class="bg-white rounded-[32rpx] shadow-sm p-[32rpx] mb-[24rpx]"> 42 <view class="bg-white rounded-[32rpx] shadow-sm p-[32rpx] mb-[24rpx]">
43 <view class="flex justify-between items-center mb-[24rpx]"> 43 <view class="flex justify-between items-center mb-[24rpx]">
44 <text class="text-gray-900 text-[32rpx] font-bold">热卖产品</text> 44 <text class="text-gray-900 text-[32rpx] font-bold">热卖产品</text>
45 - <view class="flex items-center text-blue-600" @tap="go('/pages/knowledge-base/index')"> 45 + <view class="flex items-center text-blue-600" @tap="go('/pages/product-center/index')">
46 <text class="text-[26rpx] mr-[4rpx]">查看更多</text> 46 <text class="text-[26rpx] mr-[4rpx]">查看更多</text>
47 <IconFont name="rectRight" size="12" /> 47 <IconFont name="rectRight" size="12" />
48 </view> 48 </view>
...@@ -159,19 +159,14 @@ ...@@ -159,19 +159,14 @@
159 <!-- Bottom Tab Bar --> 159 <!-- Bottom Tab Bar -->
160 <TabBar current="home" /> 160 <TabBar current="home" />
161 161
162 - <!-- Plan Popup --> 162 + <!-- Plan Form Container -->
163 - <PlanPopup v-model:visible="showPlanPopup"> 163 + <!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
164 - <SchemeA 164 + <PlanFormContainer
165 - v-if="currentScheme === 'A'" 165 + v-model:visible="showPlanPopup"
166 - @close="showPlanPopup = false" 166 + :product="selectedProduct"
167 - @submit="handlePlanSubmit" 167 + @close="showPlanPopup = false"
168 - /> 168 + @submit="handlePlanSubmit"
169 - <SchemeB 169 + />
170 - v-if="currentScheme === 'B'"
171 - @close="showPlanPopup = false"
172 - @submit="handlePlanSubmit"
173 - />
174 - </PlanPopup>
175 </view> 170 </view>
176 </template> 171 </template>
177 172
...@@ -184,9 +179,7 @@ import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'; ...@@ -184,9 +179,7 @@ import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons';
184 import { useUserStore } from '@/stores/user'; 179 import { useUserStore } from '@/stores/user';
185 import TabBar from '@/components/TabBar.vue'; 180 import TabBar from '@/components/TabBar.vue';
186 import IconFont from '@/components/IconFont.vue'; 181 import IconFont from '@/components/IconFont.vue';
187 -import PlanPopup from '@/components/PlanPopup/index.vue'; 182 +import PlanFormContainer from '@/components/PlanFormContainer.vue';
188 -import SchemeA from '@/components/PlanSchemes/SchemeA.vue';
189 -import SchemeB from '@/components/PlanSchemes/SchemeB.vue';
190 import ListItemActions from '@/components/ListItemActions/index.vue'; 183 import ListItemActions from '@/components/ListItemActions/index.vue';
191 import { listAPI } from '@/api/get_product'; 184 import { listAPI } from '@/api/get_product';
192 import { weekHotAPI } from '@/api/file'; 185 import { weekHotAPI } from '@/api/file';
...@@ -198,26 +191,57 @@ const userStore = useUserStore(); ...@@ -198,26 +191,57 @@ const userStore = useUserStore();
198 191
199 // Plan Popup State 192 // Plan Popup State
200 const showPlanPopup = ref(false); 193 const showPlanPopup = ref(false);
201 -const currentScheme = ref('A'); 194 +const selectedProduct = ref(null);
202 195
203 -const openPlanPopup = (scheme) => { 196 +/**
204 - currentScheme.value = scheme; 197 + * 打开计划书弹窗
198 + * @description 根据产品ID找到对应的产品对象,并打开计划书表单
199 + * @param {number} productId - 产品ID
200 + */
201 +const openPlanPopup = (productId) => {
202 + // 从热卖产品列表中找到对应的产品
203 + const product = hotProducts.value.find(p => p.id === productId);
204 +
205 + if (!product) {
206 + Taro.showToast({
207 + title: '产品不存在',
208 + icon: 'none',
209 + duration: 2000
210 + });
211 + return;
212 + }
213 +
214 + // 设置选中的产品
215 + selectedProduct.value = product;
205 showPlanPopup.value = true; 216 showPlanPopup.value = true;
206 }; 217 };
207 218
208 /** 219 /**
209 * 处理计划书提交 220 * 处理计划书提交
210 - * @description 模拟提交计划书,跳转到结果页面 221 + * @description 测试环境:前端不调用后端API,直接跳转到结果页
222 + * 生产环境:需要调用 submitPlanAPI 提交表单数据
211 * @param {Object} formData - 表单数据 223 * @param {Object} formData - 表单数据
212 */ 224 */
213 const handlePlanSubmit = (formData) => { 225 const handlePlanSubmit = (formData) => {
214 - console.log(`方案${currentScheme.value}提交:`, formData); 226 + console.log('计划书提交:', {
227 + product_id: selectedProduct.value.id,
228 + product_name: selectedProduct.value.product_name,
229 + form_sn: selectedProduct.value.form_sn,
230 + form_data: formData
231 + });
215 232
216 // 关闭弹窗 233 // 关闭弹窗
217 showPlanPopup.value = false; 234 showPlanPopup.value = false;
218 235
236 + // TODO: 后端接口还没有准备好,暂时不调用API
237 + // 测试完成后需要对接 submitPlanAPI
238 + // const res = await submitPlanAPI({
239 + // product_id: selectedProduct.value.id,
240 + // template: selectedProduct.value.form_sn,
241 + // form_data: formData
242 + // });
243 +
219 // 模拟提交成功,跳转到结果页面 244 // 模拟提交成功,跳转到结果页面
220 - // TODO: 后续接入真实API
221 go('/pages/plan-submit-result/index', { 245 go('/pages/plan-submit-result/index', {
222 success: 'true' 246 success: 'true'
223 }); 247 });
...@@ -269,7 +293,7 @@ const fetchHomeIcons = async () => { ...@@ -269,7 +293,7 @@ const fetchHomeIcons = async () => {
269 // 如果 API 调用失败,使用默认配置 293 // 如果 API 调用失败,使用默认配置
270 loopNav.value = [ 294 loopNav.value = [
271 { id: 'plan', icon: 'order', name: '计划书', route: '/pages/plan/index' }, 295 { id: 'plan', icon: 'order', name: '计划书', route: '/pages/plan/index' },
272 - { id: 'knowledge-base', icon: 'category', name: '产品知识库', route: '/pages/knowledge-base/index' } 296 + { id: 'product-center', icon: 'category', name: '产品中心', route: '/pages/product-center/index' }
273 ]; 297 ];
274 } 298 }
275 }; 299 };
...@@ -284,17 +308,119 @@ const hotProducts = ref([]); ...@@ -284,17 +308,119 @@ const hotProducts = ref([]);
284 /** 308 /**
285 * 获取热卖产品列表 309 * 获取热卖产品列表
286 * 310 *
287 - * @description 调用产品列表API,recommend参数为hot 311 + * @description ⚠️ 测试数据:后端接口和字段还没有准备好,暂时使用模拟数据进行测试
312 + * 测试完成后需要移除,恢复使用真实的API调用
313 + * Mock数据包含全部7种产品类型(2种人寿、3种重疾、4种储蓄)
288 */ 314 */
289 const fetchHotProducts = async () => { 315 const fetchHotProducts = async () => {
290 try { 316 try {
291 - const res = await listAPI({ 317 + // TODO: 测试完成后,移除下面的 mock 数据,恢复使用真实 API
292 - recommend: 'hot' 318 + // const res = await listAPI({
293 - }); 319 + // recommend: 'hot'
320 + // });
321 + // if (res.code === 1 && res.data && res.data.list) {
322 + // hotProducts.value = res.data.list;
323 + // }
324 +
325 + // ⚠️ 测试数据开始 - 测试完成后需要移除 ⚠️
326 + hotProducts.value = [
327 + // 人寿保险产品(2种)
328 + {
329 + id: 1,
330 + product_name: 'WIOP3E 盈传创富保障计划 3 - 优选版',
331 + form_sn: 'life-insurance-wiop3e',
332 + recommend: 'hot',
333 + tags: [
334 + { id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' },
335 + { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
336 + ]
337 + },
338 + {
339 + id: 2,
340 + product_name: 'WIOP3 - 盈传创富保障计划 3',
341 + form_sn: 'life-insurance-wiop3',
342 + recommend: 'hot',
343 + tags: [
344 + { id: 1, name: '终身寿险', bg_color: '#DBEAFE', text_color: '#1E40AF' },
345 + { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
346 + ]
347 + },
348 + // 重疾保险产品(3种)
349 + {
350 + id: 3,
351 + product_name: 'MPC 守护无间重疾',
352 + form_sn: 'critical-illness-mpc',
353 + recommend: 'hot',
354 + tags: [
355 + { id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
356 + { id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
357 + ]
358 + },
359 + {
360 + id: 4,
361 + product_name: 'MBC PRO 活跃人生重疾保 PRO',
362 + form_sn: 'critical-illness-mbc-pro',
363 + recommend: 'hot',
364 + tags: [
365 + { id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
366 + { id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
367 + ]
368 + },
369 + {
370 + id: 5,
371 + product_name: 'MBC2 活跃人生重疾保 2',
372 + form_sn: 'critical-illness-mbc2',
373 + recommend: 'hot',
374 + tags: [
375 + { id: 1, name: '重疾保障', bg_color: '#FCE7F3', text_color: '#9F1239' },
376 + { id: 2, name: '人民币', bg_color: '#D1FAE5', text_color: '#065F46' }
377 + ]
378 + },
379 + // 储蓄型产品(4种)- GS, GC, FA, LV2
380 + {
381 + id: 6,
382 + product_name: 'GS - 宏摯傳承保障計劃',
383 + form_sn: 'savings-gs',
384 + recommend: 'hot',
385 + tags: [
386 + { id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
387 + { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
388 + ]
389 + },
390 + {
391 + id: 7,
392 + product_name: 'GC - 宏摯家傳承保險計劃',
393 + form_sn: 'savings-gc',
394 + recommend: 'hot',
395 + tags: [
396 + { id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
397 + { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
398 + ]
399 + },
400 + {
401 + id: 8,
402 + product_name: 'FA - 宏浚傳承保障計劃',
403 + form_sn: 'savings-fa',
404 + recommend: 'hot',
405 + tags: [
406 + { id: 1, name: '储蓄分红', bg_color: '#E0E7FF', text_color: '#3730A3' },
407 + { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
408 + ]
409 + },
410 + {
411 + id: 9,
412 + product_name: 'LV2 - 赤霞珠終身壽險計劃2',
413 + form_sn: 'savings-lv2',
414 + recommend: 'hot',
415 + tags: [
416 + { id: 1, name: '储蓄型终身寿险', bg_color: '#E0E7FF', text_color: '#3730A3' },
417 + { id: 2, name: '美元', bg_color: '#FEF3C7', text_color: '#92400E' }
418 + ]
419 + }
420 + ];
421 + // ⚠️ 测试数据结束 - 测试完成后需要移除 ⚠️
294 422
295 - if (res.code === 1 && res.data && res.data.list) { 423 + console.log('⚠️ 使用测试数据:热卖产品列表已 mock,包含全部7种产品类型');
296 - hotProducts.value = res.data.list;
297 - }
298 } catch (err) { 424 } catch (err) {
299 console.error('获取热卖产品失败:', err); 425 console.error('获取热卖产品失败:', err);
300 } 426 }
......
1 /* 1 /*
2 * @Date: 2026-01-29 21:53:42 2 * @Date: 2026-01-29 21:53:42
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-31 10:53:44 4 + * @LastEditTime: 2026-02-06 11:55:01
5 * @FilePath: /manulife-weapp/src/pages/knowledge-base/index.config.js 5 * @FilePath: /manulife-weapp/src/pages/knowledge-base/index.config.js
6 - * @Description: 产品知识库页面配置文件 6 + * @Description: 产品中心页面配置文件
7 */ 7 */
8 export default definePageConfig({ 8 export default definePageConfig({
9 - navigationBarTitleText: '产品知识库', 9 + navigationBarTitleText: '产品中心',
10 navigationStyle: 'custom' 10 navigationStyle: 'custom'
11 }) 11 })
......
1 <!-- 1 <!--
2 * @Date: 2026-01-31 2 * @Date: 2026-01-31
3 - * @Description: 产品知识库 - API 接口集成版本(含搜索功能) 3 + * @Description: 产品中心 - API 接口集成版本(含搜索功能)
4 --> 4 -->
5 <template> 5 <template>
6 <view class="h-screen bg-[#F9FAFB] flex flex-col"> 6 <view class="h-screen bg-[#F9FAFB] flex flex-col">
7 <view class="bg-[#F9FAFB] z-10"> 7 <view class="bg-[#F9FAFB] z-10">
8 - <NavHeader title="产品知识库" /> 8 + <NavHeader title="产品中心" />
9 9
10 <!-- Search Bar --> 10 <!-- Search Bar -->
11 <view class="px-[24rpx] py-[16rpx] bg-white"> 11 <view class="px-[24rpx] py-[16rpx] bg-white">
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
79 </view> 79 </view>
80 80
81 <!-- 动态标签 --> 81 <!-- 动态标签 -->
82 - <view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mt-auto"> 82 + <view v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-[8rpx] mb-[16rpx]">
83 <view 83 <view
84 v-for="tag in item.tags.slice(0, 2)" 84 v-for="tag in item.tags.slice(0, 2)"
85 :key="tag.id" 85 :key="tag.id"
...@@ -92,6 +92,27 @@ ...@@ -92,6 +92,27 @@
92 {{ tag.name }} 92 {{ tag.name }}
93 </view> 93 </view>
94 </view> 94 </view>
95 +
96 + <!-- 按钮组 -->
97 + <view class="flex gap-[12rpx] mt-auto">
98 + <nut-button
99 + plain
100 + color="#2563EB"
101 + size="small"
102 + class="flex-1 !h-[56rpx] !rounded-[12rpx] !text-[22rpx] !m-0 !border-blue-600"
103 + @tap.stop="handleProductClick(item)"
104 + >
105 + 详情
106 + </nut-button>
107 + <nut-button
108 + color="#2563EB"
109 + size="small"
110 + class="flex-1 !h-[56rpx] !rounded-[12rpx] !text-[22rpx] !m-0"
111 + @tap.stop="openPlanPopup(item)"
112 + >
113 + 计划书
114 + </nut-button>
115 + </view>
95 </view> 116 </view>
96 </view> 117 </view>
97 </view> 118 </view>
...@@ -111,6 +132,15 @@ ...@@ -111,6 +132,15 @@
111 <nut-empty description="暂无相关产品" image="empty" /> 132 <nut-empty description="暂无相关产品" image="empty" />
112 </view> 133 </view>
113 </scroll-view> 134 </scroll-view>
135 +
136 + <!-- 计划书表单容器 -->
137 + <!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
138 + <PlanFormContainer
139 + v-model:visible="showPlanPopup"
140 + :product="selectedProduct"
141 + @close="showPlanPopup = false"
142 + @submit="handlePlanSubmit"
143 + />
114 </view> 144 </view>
115 </template> 145 </template>
116 146
...@@ -119,6 +149,7 @@ import { ref, computed } from 'vue' ...@@ -119,6 +149,7 @@ import { ref, computed } from 'vue'
119 import Taro, { useLoad, useReachBottom } from '@tarojs/taro' 149 import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
120 import NavHeader from '@/components/NavHeader.vue' 150 import NavHeader from '@/components/NavHeader.vue'
121 import SearchBar from '@/components/SearchBar.vue' 151 import SearchBar from '@/components/SearchBar.vue'
152 +import PlanFormContainer from '@/components/PlanFormContainer.vue'
122 import { useListItemClick, ListType } from '@/composables/useListItemClick' 153 import { useListItemClick, ListType } from '@/composables/useListItemClick'
123 import { listAPI } from '@/api/get_product' 154 import { listAPI } from '@/api/get_product'
124 155
...@@ -140,6 +171,10 @@ const categories = ref([]) // 从接口获取的分类列表 ...@@ -140,6 +171,10 @@ const categories = ref([]) // 从接口获取的分类列表
140 const products = ref([]) // 当前产品列表 171 const products = ref([]) // 当前产品列表
141 const total = ref(0) // 产品总数 172 const total = ref(0) // 产品总数
142 173
174 +// 计划书弹窗状态
175 +const showPlanPopup = ref(false)
176 +const selectedProduct = ref(null)
177 +
143 /** 178 /**
144 * 标签栏数据(根据接口返回的 categories 生成) 179 * 标签栏数据(根据接口返回的 categories 生成)
145 * @description 包含"全部"选项和接口返回的分类 180 * @description 包含"全部"选项和接口返回的分类
...@@ -326,6 +361,47 @@ const { handleClick: handleProductClick } = useListItemClick({ ...@@ -326,6 +361,47 @@ const { handleClick: handleProductClick } = useListItemClick({
326 }) 361 })
327 362
328 /** 363 /**
364 + * 打开计划书弹窗
365 + * @description 根据产品对象打开计划书表单
366 + * @param {Object} product - 产品对象
367 + */
368 +const openPlanPopup = (product) => {
369 + selectedProduct.value = product
370 + showPlanPopup.value = true
371 +}
372 +
373 +/**
374 + * 处理计划书提交
375 + * @description 测试环境:前端不调用后端API,直接跳转到结果页
376 + * 生产环境:需要调用 submitPlanAPI 提交表单数据
377 + * @param {Object} formData - 表单数据
378 + */
379 +const handlePlanSubmit = (formData) => {
380 + console.log('计划书提交:', {
381 + product_id: selectedProduct.value.id,
382 + product_name: selectedProduct.value.product_name,
383 + form_sn: selectedProduct.value.form_sn,
384 + form_data: formData
385 + })
386 +
387 + // 关闭弹窗
388 + showPlanPopup.value = false
389 +
390 + // TODO: 后端接口还没有准备好,暂时不调用API
391 + // 测试完成后需要对接 submitPlanAPI
392 + // const res = await submitPlanAPI({
393 + // product_id: selectedProduct.value.id,
394 + // template: selectedProduct.value.form_sn,
395 + // form_data: formData
396 + // })
397 +
398 + // 模拟提交成功,跳转到结果页面
399 + Taro.navigateTo({
400 + url: '/pages/plan-submit-result/index?success=true'
401 + })
402 +}
403 +
404 +/**
329 * 页面加载时获取数据 405 * 页面加载时获取数据
330 */ 406 */
331 useLoad(() => { 407 useLoad(() => {
......
...@@ -98,6 +98,26 @@ ...@@ -98,6 +98,26 @@
98 98
99 <!-- TabBar --> 99 <!-- TabBar -->
100 <!-- <TabBar /> --> 100 <!-- <TabBar /> -->
101 +
102 + <!-- 计划书按钮 -->
103 + <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">
104 + <nut-button
105 + color="#2563EB"
106 + class="!w-full !h-[88rpx] !rounded-[16rpx] !text-[28rpx] !font-bold"
107 + @tap="openPlanPopup"
108 + >
109 + 制作计划书
110 + </nut-button>
111 + </div>
112 +
113 + <!-- 计划书表单容器 -->
114 + <!-- 测试数据:后端接口和字段还没有准备好,使用 PlanFormContainer 进行的前端测试 -->
115 + <PlanFormContainer
116 + v-model:visible="showPlanPopup"
117 + :product="productDetail"
118 + @close="showPlanPopup = false"
119 + @submit="handlePlanSubmit"
120 + />
101 </div> 121 </div>
102 </template> 122 </template>
103 123
...@@ -106,6 +126,7 @@ import { ref } from 'vue' ...@@ -106,6 +126,7 @@ import { ref } from 'vue'
106 import NavHeader from '@/components/NavHeader.vue' 126 import NavHeader from '@/components/NavHeader.vue'
107 import TabBar from '@/components/TabBar.vue' 127 import TabBar from '@/components/TabBar.vue'
108 import IconFont from '@/components/IconFont.vue' 128 import IconFont from '@/components/IconFont.vue'
129 +import PlanFormContainer from '@/components/PlanFormContainer.vue'
109 import { useFileOperation } from '@/composables/useFileOperation' 130 import { useFileOperation } from '@/composables/useFileOperation'
110 import Taro, { useLoad } from '@tarojs/taro' 131 import Taro, { useLoad } from '@tarojs/taro'
111 import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons' 132 import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
...@@ -113,6 +134,9 @@ import { detailAPI } from '@/api/get_product' ...@@ -113,6 +134,9 @@ import { detailAPI } from '@/api/get_product'
113 134
114 const { viewFile } = useFileOperation() 135 const { viewFile } = useFileOperation()
115 136
137 +// 计划书弹窗状态
138 +const showPlanPopup = ref(false)
139 +
116 // 接收页面参数 140 // 接收页面参数
117 const productId = ref(null) 141 const productId = ref(null)
118 142
...@@ -181,6 +205,47 @@ const viewDocument = (doc) => { ...@@ -181,6 +205,47 @@ const viewDocument = (doc) => {
181 }) 205 })
182 } 206 }
183 207
208 +/**
209 + * 打开计划书弹窗
210 + *
211 + * @description 打开当前产品的计划书表单
212 + */
213 +const openPlanPopup = () => {
214 + showPlanPopup.value = true
215 +}
216 +
217 +/**
218 + * 处理计划书提交
219 + *
220 + * @description 测试环境:前端不调用后端API,直接跳转到结果页
221 + * 生产环境:需要调用 submitPlanAPI 提交表单数据
222 + * @param {Object} formData - 表单数据
223 + */
224 +const handlePlanSubmit = (formData) => {
225 + console.log('计划书提交:', {
226 + product_id: productDetail.value.id,
227 + product_name: productDetail.value.product_name,
228 + form_sn: productDetail.value.form_sn,
229 + form_data: formData
230 + })
231 +
232 + // 关闭弹窗
233 + showPlanPopup.value = false
234 +
235 + // TODO: 后端接口还没有准备好,暂时不调用API
236 + // 测试完成后需要对接 submitPlanAPI
237 + // const res = await submitPlanAPI({
238 + // product_id: productDetail.value.id,
239 + // template: productDetail.value.form_sn,
240 + // form_data: formData
241 + // })
242 +
243 + // 模拟提交成功,跳转到结果页面
244 + Taro.navigateTo({
245 + url: '/pages/plan-submit-result/index?success=true'
246 + })
247 +}
248 +
184 useLoad((options) => { 249 useLoad((options) => {
185 console.log('产品详情页参数:', options) 250 console.log('产品详情页参数:', options)
186 251
......
...@@ -382,7 +382,7 @@ const clearSearch = () => { ...@@ -382,7 +382,7 @@ const clearSearch = () => {
382 // Go to detail 382 // Go to detail
383 const goToDetail = (item) => { 383 const goToDetail = (item) => {
384 if (item.category === 'product') { 384 if (item.category === 'product') {
385 - go('/pages/knowledge-base/index') 385 + go('/pages/product-center/index')
386 } else { 386 } else {
387 go('/pages/material-list/index', { title: '搜索结果' }) 387 go('/pages/material-list/index', { title: '搜索结果' })
388 } 388 }
......