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']
}
}
......
This diff is collapsed. Click to expand it.
## 一、人寿产品
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: '搜索结果' })
}
......