hookehuyr

feat(plan): 同步 develop 分支最新代码 - 计划书模块完整更新

- 添加文档解析工具 (scripts/parse-docs.js)
  - 优化计划书配置定位与 Schema 文档
  - 完整计划书字段验证、分组、转换系统
  - 添加计划书表单重构 (PlanFormContainer)
  - 补充计划书状态管理与颜色标识
  - 优化计划书模板 (储蓄/人寿/重疾)
  - 统一权限检查与文件操作反馈
  - 同步文档更新 (README/PLAN/CHANGELOG)

详见 docs/CHANGELOG.md

Co-Authored-By: Claude Code
Showing 57 changed files with 3099 additions and 977 deletions
{
"types": ["feat", "fix", "perf"],
"skip": {
"changelog": true
}
}
......@@ -19,6 +19,60 @@ pnpm build:weapp # 构建生产版本(微信小程序)
pnpm lint # 运行 ESLint
```
### Git 工作流
#### 从 develop 创建功能分支
```bash
# 1. 切换到 develop(确保最新)
git checkout develop
git pull
# 2. 创建功能分支
git checkout -b feature/功能名称
# 3. 开发完成后,合并回 develop
git checkout develop
git merge feature/功能名称
# 4. 删除功能分支(可选)
git branch -d feature/功能名称
```
**分支命名规范**
- `feature/xxx` - 新功能
- `fix/xxx` - Bug 修复
- `refactor/xxx` - 重构
#### 版本自动更新(已实现)
**规则**:遵循 Semantic Versioning
- `feat` - MINOR 版本更新(1.0.0 → 1.1.0)
- `fix` - PATCH 版本更新(1.0.0 → 1.0.1)
- `perf` - MINOR 版本更新
- `docs/style/refactor/test/chore` - 不更新
**实现方式**
-`commit-msg` hook �用 `scripts/update-version.sh` 自动更新
- ✅ 更新后的 `package.json` 自动加入暂存区
- ✅ 支持 `feat(version):` 格式跳过版本更新
**使用示例**
```bash
# 在当前功能分支开发
git checkout -b feature/new-page
# ... 开发代码 ...
git add .
git commit -m "feat(page): 添加新页面"
# 合并回 develop
git checkout develop
git merge feature/new-page
# 删除分支(可选)
git branch -d feature/new-page
```
### 其他平台构建
```bash
pnpm dev:alipay # 支付宝小程序开发
......
......@@ -76,7 +76,7 @@ export const submitFormAPI = (params) => {
export default {
pages: [
'pages/index/index', // 首页
'pages/auth/index', // 认证页(必须保留)
'pages/login/index', // 登录页(必须保留)
'pages/your-page/index', // 🔧 添加您的页面
],
tabBar: {
......@@ -120,9 +120,9 @@ pnpm dev:h5
- **静默认证**:应用启动时自动执行
- **401 自动刷新**:接口返回 401 时自动刷新会话
- **授权页回跳**:认证完成后自动返回原页面
- **登录页回跳**:登录完成后自动返回原页面
**重要**:后端需提供 `/srv/?a=openid_wxapp` 接口
**重要**:后端需提供 `/srv/?a=openid` 接口
### 🌐 网络请求
......@@ -203,7 +203,7 @@ export const useUserStore = defineStore('user', {
### Q: 认证流程不工作?
1. 检查后端 `/srv/?a=openid_wxapp` 接口是否正常
1. 检查后端 `/srv/?a=openid` 接口是否正常
2. 检查 `src/utils/config.js` 中的 `BASE_URL` 是否正确
3. 查看微信开发者工具控制台错误信息
......
This diff is collapsed. Click to expand it.
This diff could not be displayed because it is too large.
......@@ -102,31 +102,27 @@
- 缴费年期:各产品不同(详见配置文件)
- **提取计划功能**(所有储蓄产品通用):
**三层结构**
**字段结构说明**
**第一层**:是否希望生成一份容许减少名义金额的提取说明?(是/否)
**字段1**:是否希望生成一份容许减少名义金额的提取说明?(是/否)
- 独立字段,不影响下面的提取方案配置
- 仅用于标识是否需要生成说明文档
**第二层**(选择"是"时显示):
- 提取选项(二选一):
1. 指定提取金额
2. 最高固定提取金额
**字段2**:提取选项(二选一):
- 指定提取金额
- 最高固定提取金额
**第三层**(根据第二层选择显示不同字段)
**字段3-N**:根据字段2的选择显示不同字段
**A. 指定提取金额模式**
- 提取方式(二选一)
- 提取方式:
1. 按年岁
2. 按保单年度
- **按年岁**字段(3个):
- 由几岁开始(withdrawal_start_age)
- 提取期(年)(withdrawal_period)
- 每年递增提取之百分比(%)(increase_rate)
- **按保单年度**字段(2个):
- 由几岁开始(withdrawal_start_age)
- 提取期(年)(withdrawal_period)
**B. 最高固定提取金额模式**(2个字段):
- 按年岁:由几岁开始(withdrawal_start_age)
- 提取期(年)(withdrawal_period)
......@@ -138,8 +134,7 @@
**字段清理逻辑**
- 切换提取方式时,自动清除不相关字段
- 切换"按年岁"和"按保单年度"时,清除 annual_amount 和 increase_rate
- 选择"否"(不启用提取计划)时,清除所有提取计划相关字段
- "是否希望生成说明"字段不影响任何其他字段
---
......@@ -387,15 +382,17 @@ src/
**业务场景**:储蓄型产品(GS/GC/FA/LV2)支持提取计划功能
**三层结构**
**字段结构**
#### 第一层:启用确认
#### 字段1:是否生成说明(独立字段)
**问题**:是否希望生成一份容许减少名义金额的提取说明?
**选项**:是 / 否(默认:否)
#### 第二层:提取选项(第一层选择"是"时显示)
**说明**:此字段为独立配置,不影响下面的提取方案
#### 字段2:提取选项
**问题**:提取选项
......@@ -403,7 +400,7 @@ src/
1. 指定提取金额
2. 最高固定提取金额
#### 第三层:具体字段(根据第二层选择显示不同字段)
#### 字段3-N:具体配置字段(根据字段2选择显示不同字段)
##### A. 指定提取金额模式
......@@ -411,31 +408,19 @@ src/
**选项**
1. 按年岁
2. 按保单年度
**按年岁字段**(3个):
```javascript
{
withdrawal_enabled: '是',
withdrawal_mode: '指定提取金额',
specified_amount_type: '按年岁',
withdrawal_method: '按年岁',
withdrawal_start_age: 60, // 由几岁开始
withdrawal_period: '10年', // 提取期(年)
increase_rate: '5' // 每年递增提取之百分比(%)
}
```
**按保单年度字段**(2个):
```javascript
{
withdrawal_enabled: '是',
withdrawal_mode: '指定提取金额',
specified_amount_type: '按保单年度',
withdrawal_start_age: 60, // 由几岁开始
withdrawal_period: '10年' // 提取期(年)
}
```
##### B. 最高固定提取金额模式(2个字段)
```javascript
......@@ -452,89 +437,43 @@ src/
- 无需"每年提取金额"字段(小程序端不需要)
- 字段清理逻辑:切换模式时自动清除不相关字段
**组件设计(三层结构)**
**组件设计(独立字段结构)**
```vue
<template>
<div>
<!-- 第一层:启用确认 -->
<!-- 字段1:是否生成说明(独立字段) -->
<PlanFieldRadio
v-model="form.withdrawal_enabled"
label="是否希望生成一份容许减少名义金额的提取说明?"
:options="['是', '否']"
/>
<!-- 第二层 + 第三层:仅当选择"是"时显示 -->
<template v-if="form.withdrawal_enabled === '是'">
<h3>款项提取(容许减少名义金额)</h3>
<!-- 字段2:款项提取配置(始终显示) -->
<h3>款项提取(容许减少名义金额)</h3>
<!-- 提取选项 -->
<PlanFieldRadio
v-model="form.withdrawal_mode"
label="提取选项"
:options="['指定提取金额', '最高固定提取金额']"
@change="onWithdrawalModeChange"
/>
<!-- 第二层:提取选项 -->
<!-- 指定提取金额模式 -->
<template v-if="form.withdrawal_mode === '指定提取金额'">
<!-- 子选项:提取方式 -->
<PlanFieldRadio
v-model="form.withdrawal_mode"
label="提取选项"
:options="['指定提取金额', '最高固定提取金额']"
@change="onWithdrawalModeChange"
v-model="form.withdrawal_method"
label="提取方式"
:options="['按年岁']"
/>
<!-- 第三层 A:指定提取金额模式 -->
<template v-if="form.withdrawal_mode === '指定提取金额'">
<!-- 子选项:提取方式 -->
<PlanFieldRadio
v-model="form.specified_amount_type"
label="提取方式"
:options="['按年岁', '按保单年度']"
/>
<!-- 按年岁字段 -->
<template v-if="form.specified_amount_type === '按年岁'">
<PlanFieldAgePicker
v-model="form.withdrawal_start_age"
label="由几岁开始"
placeholder="请输入开始提取年龄"
/>
<PlanFieldSelect
v-model="form.withdrawal_period"
label="提取期(年)"
placeholder="请选择提取期"
:options="withdrawalPeriods"
/>
<!-- 每年递增提取之百分比 -->
<div>
<div class="text-sm text-gray-700 mb-2">
每年递增提取之百分比(%)
</div>
<nut-input
v-model="form.increase_rate"
type="digit"
placeholder="请输入递增百分比"
/>
</div>
</template>
<!-- 按保单年度字段 -->
<template v-if="form.specified_amount_type === '按保单年度'">
<PlanFieldAgePicker
v-model="form.withdrawal_start_age"
label="由几岁开始"
placeholder="请输入开始提取年龄"
/>
<PlanFieldSelect
v-model="form.withdrawal_period"
label="提取期(年)"
placeholder="请选择提取期"
:options="withdrawalPeriods"
/>
</template>
</template>
<!-- 第三层 B:最高固定提取金额模式 -->
<template v-if="form.withdrawal_mode === '最高固定提取金额'">
<!-- 按年岁字段 -->
<template v-if="form.withdrawal_method === '按年岁'">
<PlanFieldAgePicker
v-model="form.withdrawal_start_age"
label="按年岁:由几岁开始"
label="由几岁开始"
placeholder="请输入开始提取年龄"
/>
......@@ -544,8 +483,36 @@ src/
placeholder="请选择提取期"
:options="withdrawalPeriods"
/>
<!-- 每年递增提取之百分比 -->
<div>
<div class="text-sm text-gray-700 mb-2">
每年递增提取之百分比(%)
</div>
<nut-input
v-model="form.increase_rate"
type="digit"
placeholder="请输入递增百分比"
/>
</div>
</template>
</template>
<!-- 最高固定提取金额模式 -->
<template v-if="form.withdrawal_mode === '最高固定提取金额'">
<PlanFieldAgePicker
v-model="form.withdrawal_start_age"
label="按年岁:由几岁开始"
placeholder="请输入开始提取年龄"
/>
<PlanFieldSelect
v-model="form.withdrawal_period"
label="提取期(年)"
placeholder="请选择提取期"
:options="withdrawalPeriods"
/>
</template>
</div>
</template>
......@@ -554,29 +521,17 @@ src/
const onWithdrawalModeChange = (mode) => {
if (mode === '最高固定提取金额') {
// 最高固定金额模式不需要指定金额的相关字段
delete form.specified_amount_type
delete form.withdrawal_method
delete form.increase_rate
}
}
// 监听提取方式变化
watch(() => form.specified_amount_type, (newType) => {
watch(() => form.withdrawal_method, (newType) => {
// 两种方式都不需要 annual_amount 和 increase_rate(小程序端不需要)
delete form.annual_amount
delete form.increase_rate
})
// 监听启用状态变化
watch(() => form.withdrawal_enabled, (newValue) => {
if (newValue === '否') {
// 清除所有提取计划相关字段
delete form.withdrawal_mode
delete form.specified_amount_type
delete form.withdrawal_start_age
delete form.withdrawal_period
delete form.increase_rate
}
})
</script>
```
......
......@@ -292,7 +292,7 @@ console.log(product.form_sn) // 应该有值,如 "life-insurance-wiop3e"
- 第一层:是否启用(是/否)
- 第二层:提取选项(指定提取金额/最高固定提取金额)
- 第三层:具体方式(按年岁/按保单年度
- 第三层:具体方式(按年岁)
确保按顺序选择,相关字段会自动显示。
......
# 计划书表单 Schema 使用文档
## 1. 文档目标
用于说明计划书表单的 Schema 配置规范、字段类型、联动规则与提交映射,便于后续新增或扩展不同保险类型时快速落地。
## 2. 核心思路
- 统一由 Schema 描述字段渲染、校验与联动
- 统一由 submit_mapping 处理字段到 API 字段的映射与金额转换
- 模板组件只负责“渲染与校验”,不再硬编码字段逻辑
## 3. Schema 结构
```javascript
// Schema 基础结构
const form_schema = {
// 基础字段
base_fields: [
{
id: 'customer_name',
key: 'customer_name',
type: 'name',
label: '申请人',
placeholder: '请输入申请人',
required: true
}
],
// 提取计划字段(可选)
withdrawal_fields: [],
// 联动清空规则(可选)
reset_map: {}
}
```
## 4. 字段类型说明
| type | 组件 | 说明 |
| --- | --- | --- |
| name | NameInput | 姓名输入 |
| radio | RadioGroup | 单选 |
| date | DatePickerGlobal | 日期选择 |
| amount | AmountKeyboard | 金额键盘输入(内部存分) |
| age | AgePickerGlobal | 年龄选择 |
| select | SelectPickerGlobal | 下拉选择 |
| payment_period | PaymentPeriodRadio | 缴费年期 |
| percentage | NutInput | 百分比输入 |
## 5. 字段属性说明
```javascript
// 字段属性示例
{
id: 'coverage',
key: 'coverage',
type: 'amount',
label: '年缴保费',
placeholder: '请输入年缴保费',
input_label: '请输入年缴保费金额',
required: true,
// 可从配置读取币种
currency_from: 'currency',
// 控制显示条件
show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }],
// 默认值
default: '否',
// 标题分组
section_title: '款项提取(允许减少名义金额)'
}
```
## 6. 联动规则与清空逻辑
```javascript
// 提取模式切换后,按规则清空脏字段
const reset_map = {
withdrawal_mode: {
'最高固定提取金额': ['annual_withdrawal_amount', 'annual_increase_percentage', 'withdrawal_start_age', 'withdrawal_period'],
'指定提取金额': ['withdrawal_start_age', 'withdrawal_period']
}
}
```
## 7. 提交字段映射
```javascript
// submit_mapping 示例(金额字段统一从分转元)
const submit_mapping = {
coverage: { api_field: 'annual_premium', transform: 'fen_to_yuan' },
annual_withdrawal_amount: { api_field: 'annual_withdrawal_amount', transform: 'fen_to_yuan' },
withdrawal_mode: { api_field: 'withdrawal_option' }
}
```
## 8. 使用示例
```vue
<!-- 储蓄型模板使用示例 -->
<template>
<SavingsTemplate v-model="form_data" :config="template_config" />
</template>
<script setup>
// 表单数据
const form_data = ref({})
// 模板配置(通常来自 plan-templates.js)
const template_config = {
currency: 'USD',
payment_periods: ['整付', '5 年'],
withdrawal_plan: {
enabled: true,
default_currency: 'USD',
withdrawal_periods: ['1年', '2年', '终身']
},
form_schema: {},
submit_mapping: {}
}
</script>
```
## 8.1 人寿/重疾模板使用示例
```vue
<template>
<LifeInsuranceTemplate v-model="form_data" :config="template_config" />
</template>
<script setup>
const form_data = ref({})
const template_config = {
currency: 'USD',
payment_periods: ['整付(0-75 岁)', '5 年(0-70 岁)'],
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
</script>
```
## 9. 新增保险类型流程
1.`src/config/plan-templates.js` 新增产品项(配置 form_sn)
2. 为该产品选择已有模板组件或新增模板组件
3. 定义 `form_schema``submit_mapping`
4. 在模板组件内使用 Schema 渲染(仅需接入通用逻辑)
5. 验证校验与提交映射
## 10. 新增产品配置示例
```javascript
// 示例:新增储蓄类产品配置
'savings-new': {
name: '示例储蓄产品',
component: 'SavingsTemplate',
category: 'savings',
config: {
currency: 'USD',
payment_periods: ['整付', '5 年'],
withdrawal_plan: {
enabled: true,
default_currency: 'USD',
withdrawal_periods: ['1年', '2年', '终身']
},
form_schema: savingsFormSchema,
submit_mapping: savingsSubmitMapping
}
}
```
```javascript
// 示例:新增人寿/重疾类产品配置
'life-insurance-new': {
name: '示例人寿产品',
component: 'LifeInsuranceTemplate',
config: {
currency: 'USD',
payment_periods: ['整付(0-75 岁)'],
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
}
```
## 11. 常见扩展点
- 新字段:仅在 form_schema 增加字段并补充 submit_mapping
- 新联动:在 show_when 与 reset_map 中定义条件
- 新模板:复用现有字段组件,保持 schema 结构一致
## 12. 计划书模块入口与配置地图
### 12.1 页面入口
- 产品详情:`src/pages/product-detail/index.vue`(按钮打开计划书弹窗)
- 产品中心:`src/pages/product-center/index.vue`(列表内“计划书”按钮)
- 搜索页:`src/pages/search/index.vue`(搜索结果卡片“计划书”按钮)
- 计划书列表:`src/pages/plan/index.vue`(查看/删除计划书)
- 提交结果页:`src/pages/plan-submit-result/index.vue`
### 12.2 组件与模板
- 弹窗容器:`src/components/plan/PlanPopupNew.vue`
- 计划书容器:`src/components/plan/PlanFormContainer.vue`
- 模板组件:
- `src/components/plan/PlanTemplates/LifeInsuranceTemplate.vue`
- `src/components/plan/PlanTemplates/CriticalIllnessTemplate.vue`
- `src/components/plan/PlanTemplates/SavingsTemplate.vue`
- 字段组件:`src/components/plan/PlanFields/*`
### 12.3 配置与数据处理
- 模板映射:`src/config/plan-templates.js`
- 字段定义与映射:`src/config/plan-fields.js`
- 字段转换函数:`src/utils/planFieldTransformers.js`
- 字段转换入口:`src/composables/useFieldValueTransform.js`
- 字段联动规则:`src/composables/useFieldDependencies.js`
- 字段校验工具:`src/utils/planFieldValidation.js`
- 订单状态常量:`src/config/constants/orderStatus.js`
### 12.4 API 入口
- 计划书 API:`src/api/plan.js`
- 新增:`addAPI`
- 列表:`listAPI`
- 删除:`deleteAPI`
- 查看:`viewAPI`
### 12.5 技术书/附件预览关联
- 产品详情附件列表:`src/pages/product-detail/index.vue`
- 文件预览能力:`src/composables/useFileOperation.js`
## 13. 计划书模块使用流程
1. 产品详情/产品中心/搜索页获取产品对象(至少包含 `id``form_sn`,可选 `plan_config`
2. 打开 `PlanFormContainer` 并传入 `product`
3. `PlanFormContainer` 根据 `form_sn``plan-templates` 选择模板并合并 `plan_config`
4. 模板组件基于 `form_schema` 渲染字段,调用自身 `validate` 完成校验
5. 提交时使用 `submit_mapping` 生成请求参数,并通过 `addAPI` 提交
6. 提交完成后通过 `usePlanSubmit` 跳转到提交结果页
7. 在计划书列表中用 `listAPI` 拉取数据,使用 `viewAPI` 标记为已查看
## 14. 计划书容器使用示例
```vue
<template>
<PlanFormContainer
v-model:visible="show_plan_popup"
:product="selected_product"
@close="show_plan_popup = false"
@submit="handle_plan_submit"
/>
</template>
<script setup>
import { ref } from 'vue'
import PlanFormContainer from '@/components/plan/PlanFormContainer.vue'
import { usePlanSubmit } from '@/composables/usePlanSubmit'
const show_plan_popup = ref(false)
const selected_product = ref(null)
const { handlePlanSubmit: handle_plan_submit } = usePlanSubmit({
getPopupState: () => show_plan_popup.value,
setPopupState: (state) => { show_plan_popup.value = state },
pageName: 'Plan Entry'
})
</script>
```
# 臻奇智荟圈小程序 - 前端开发计划(调整版)
## ⚠️ 当前说明
本计划为历史版本,当前业务与路由以 `src/app.config.js` 为准,AI 模块已改为外部配置,不再内置页面。
## 📋 项目概览
### 项目信息
......
......@@ -9,7 +9,7 @@
- **设计宽度**: 750px(自定义组件)/ 375px(NutUI组件)
### 开发目标
基于现有 Taro 4 + Vue 3 模板,开发服务保险团队内部同事的微信小程序,实现计划书生成、资料库管理、AI问答三大核心功能
基于现有 Taro 4 + Vue 3 模板,开发服务保险团队内部同事的微信小程序,实现产品与资料浏览、计划书管理、搜索与消息通知、反馈闭环等核心能力
---
......@@ -19,126 +19,110 @@
```
src/
├── api/ # API接口定义
│ ├── index.js # API入口
│ ├── fn.js # 请求包装器
│ ├── order.js # 订单相关API
│ ├── material.js # 资料库相关API
│ ├── ai.js # AI问答相关API
│ └── user.js # 用户相关API
├── assets/ # 静态资源
│ └── images/ # 图片资源
├── components/ # 公共组件
│ ├── page-container/ # 页面容器组件
│ ├── order-card/ # 订单卡片组件
│ ├── material-item/ # 资料列表项组件
│ ├── file-preview/ # 文件预览组件(PDF/视频/图片)
│ └── chat-message/ # 聊天消息组件
├── composables/ # 组合式函数
│ ├── useAuth.js # 认证相关
│ ├── useRequest.js # 请求封装
│ └── useUpload.js # 文件上传
├── pages/ # 页面
│ ├── index/ # 首页(工作台)
│ ├── order/
│ │ ├── submit/ # 提交计划书申请
│ │ ├── list/ # 我的订单列表
│ │ └── detail/ # 订单详情
│ ├── material/
│ │ ├── index/ # 资料库首页
│ │ ├── list/ # 资料列表
│ │ ├── detail/ # 资料详情(PDF/视频预览)
│ │ └── search/ # 资料搜索
│ ├── ai/
│ │ └── chat/ # AI问答对话页面
│ ├── notifications/ # 消息通知列表
│ ├── profile/ # 个人中心
│ └── auth/ # 授权登录(已有)
├── stores/ # 状态管理
│ ├── main.js # 主Store
│ ├── router.js # 路由Store(已有)
│ ├── user.js # 用户信息Store
│ ├── order.js # 订单Store
│ └── material.js # 资料库Store
├── utils/ # 工具函数
│ ├── authRedirect.js # 认证跳转(已有)
│ ├── request.js # 请求封装(已有)
│ ├── tools.js # 工具函数(已有)
│ ├── config.js # 配置文件(已有)
│ ├── validate.js # 表单验证
│ └── format.js # 格式化工具
├── hooks/ # Hooks
│ └── useGo.js # 导航Hook(已有)
├── app.config.js # 应用配置(路由、tabBar等)
└── app.js # 应用入口
├── api/ # API 接口层
├── assets/ # 静态资源
├── components/ # 通用组件
│ ├── cards/ # 卡片组件
│ ├── documents/ # 文档预览组件
│ ├── forms/ # 表单组件
│ ├── icons/ # 图标组件
│ ├── list/ # 列表组件
│ ├── navigation/ # 导航组件
│ └── plan/ # 计划书相关组件
├── composables/ # 组合式函数
├── config/ # 功能与权限配置
├── hooks/ # hooks
├── pages/ # 页面组件
│ ├── index/ # 首页
│ ├── product-center/ # 产品中心
│ ├── product-detail/ # 产品详情
│ ├── category-list/ # 分类列表
│ ├── material-list/ # 资料列表
│ ├── week-hot-material/ # 周热门资料
│ ├── signing/ # 签单相关
│ ├── family-office/ # 家办业务
│ ├── plan/ # 计划书列表
│ ├── plan-submit-result/ # 计划书提交结果
│ ├── search/ # 搜索
│ ├── document-preview/ # 文档预览
│ ├── document-demo/ # 文档演示
│ ├── message/ # 消息列表
│ ├── message-detail/ # 消息详情
│ ├── feedback/ # 意见反馈
│ ├── feedback-list/ # 反馈列表
│ ├── favorites/ # 我的收藏
│ ├── mine/ # 我的
│ ├── avatar/ # 头像编辑
│ ├── help-center/ # 帮助中心
│ ├── login/ # 登录
│ ├── onboarding/ # 引导页
│ ├── video-player/ # 视频播放
│ └── webview/ # WebView 承载页
├── app.config.js # 页面路由配置
└── app.js # 应用入口
```
### 页面路由规划
| 页面路径 | 页面名称 | 需要登录 | 说明 |
|---------|---------|---------|------|
| /pages/index/index | 首页/工作台 | ✅ | 展示快捷入口、待处理订单、最新资料 |
| /pages/order/submit | 提交计划书申请 | ✅ | 表单提交页面 |
| /pages/order/list | 我的订单 | ✅ | 订单列表(按状态筛选) |
| /pages/order/detail | 订单详情 | ✅ | 查看订单详情、PDF预览、海报查看 |
| /pages/material/index | 资料库首页 | ✅ | 分类导航、热门资料 |
| /pages/material/list | 资料列表 | ✅ | 按分类查看资料 |
| /pages/material/detail | 资料详情 | ✅ | PDF/视频/图片预览(禁止下载) |
| /pages/material/search | 资料搜索 | ✅ | 搜索资料 |
| /pages/ai/chat | AI问答 | ✅ | 对话式AI交互 |
| /pages/notifications | 消息通知 | ✅ | 系统消息列表 |
| /pages/profile | 个人中心 | ✅ | 用户信息、设置 |
| /pages/auth/index | 授权登录 | ❌ | 微信登录(已有) |
| /pages/index/index | 首页 | ✅ | 导航入口、产品与资料推荐 |
| /pages/search/index | 搜索 | ✅ | 全局搜索与分类结果 |
| /pages/webview/index | WebView | ✅ | 承载外部 H5 |
| /pages/document-preview/index | 文档预览 | ✅ | PDF/Office 预览 |
| /pages/document-demo/index | 文档演示 | ✅ | 预览演示页面 |
| /pages/onboarding/index | 引导页 | ❌ | 首次引导 |
| /pages/family-office/index | 家办业务 | ✅ | 家办资料入口 |
| /pages/product-center/index | 产品中心 | ✅ | 产品聚合与筛选 |
| /pages/product-detail/index | 产品详情 | ✅ | 产品信息与附件 |
| /pages/category-list/index | 分类列表 | ✅ | 分类聚合列表 |
| /pages/material-list/index | 资料列表 | ✅ | 分类资料列表 |
| /pages/week-hot-material/index | 周热门资料 | ✅ | 热门资料聚合 |
| /pages/signing/index | 签单相关 | ✅ | 签单资料入口 |
| /pages/mine/index | 我的 | ✅ | 个人入口 |
| /pages/plan/index | 计划书 | ✅ | 计划书列表 |
| /pages/plan-submit-result/index | 计划书提交结果 | ✅ | 提交完成与引导 |
| /pages/favorites/index | 收藏 | ✅ | 收藏管理 |
| /pages/avatar/index | 头像编辑 | ✅ | 头像与信息编辑 |
| /pages/feedback-list/index | 反馈列表 | ✅ | 历史反馈 |
| /pages/feedback/index | 意见反馈 | ✅ | 反馈提交 |
| /pages/login/index | 登录 | ❌ | 登录与回跳 |
| /pages/help-center/index | 帮助中心 | ✅ | 常见问题与入口 |
| /pages/message/index | 消息列表 | ✅ | 消息通知 |
| /pages/message-detail/index | 消息详情 | ✅ | 消息详情与计划书状态 |
| /pages/video-player/index | 视频播放 | ✅ | 视频播放页面 |
### TabBar 配置
```javascript
// app.config.js
tabBar: {
color: '#999999',
selectedColor: '#007AFF',
backgroundColor: '#ffffff',
borderStyle: 'black',
list: [
{
pagePath: 'pages/index/index',
text: '工作台',
iconPath: 'assets/images/tab-home.png',
selectedIconPath: 'assets/images/tab-home-active.png'
},
{
pagePath: 'pages/material/index/index',
text: '资料库',
iconPath: 'assets/images/tab-material.png',
selectedIconPath: 'assets/images/tab-material-active.png'
},
{
pagePath: 'pages/ai/chat/index',
text: 'AI助手',
iconPath: 'assets/images/tab-ai.png',
selectedIconPath: 'assets/images/tab-ai-active.png'
},
{
pagePath: 'pages/profile/index',
text: '我的',
iconPath: 'assets/images/tab-profile.png',
selectedIconPath: 'assets/images/tab-profile-active.png'
}
]
}
```
当前采用自定义 TabBar 组件(`src/components/navigation/TabBar.vue`),原生 `tabBar` 未启用,路由以 `app.config.js` 为准。
---
## 📱 核心功能模块设计
## ✅ 当前功能模块概览
### 模块1:产品与资料
- 产品中心、产品详情、分类列表、资料列表、周热门资料、签单相关、家办业务
- 文档预览与视频播放作为统一内容承载页面
### 模块2:计划书流程
- 计划书列表与状态展示
- 提交结果页与消息详情联动
### 模块3:搜索与消息
- 搜索结果统一入口
- 消息列表与详情承载计划书状态更新
### 模块4:个人中心与反馈
- 我的、收藏、头像、帮助中心
- 反馈提交与反馈历史列表
---
---
## 🗃️ 历史规划(已停用)
以下内容为历史规划记录,已与当前业务实现不一致,阅读时请以“当前功能模块概览”和 `app.config.js` 为准。
### 模块1:计划书生成模块
......
......@@ -12,18 +12,31 @@
### 项目定位
服务保险团队内部同事的轻量化微信小程序,核心解决三大痛点:
1. 计划书快速生成+状态实时反馈
2. 沉淀内部培训、服务资料,打造专属知识库
3. AI智能问答功能
1. 计划书管理与状态实时反馈
2. 产品与资料沉淀、统一检索与消息通知
3. 反馈闭环与个人中心能力
### 核心技术决策
- **不对接保险公司官方API**:规避高成本、高门槛问题
- **采用半人工方式**:前端提交+后台人工协同的低成本落地方案
- **AI功能**:采用腾讯元宝AI,建立团队私有的知识库
- **AI能力**:采用腾讯元宝AI进行外部配置,不在小程序内置页面
---
## 🎯 需求分析
## ✅ 当前业务概览
### 核心模块
1. 产品与资料:产品中心、资料分类、周热门、签单与家办入口
2. 计划书:计划书列表、提交结果与消息联动
3. 搜索与消息:全局搜索、消息列表与详情
4. 个人中心与反馈:我的、收藏、头像、帮助中心、意见反馈
### 当前路由基准
`src/app.config.js` 为准,涉及页面包含首页、搜索、文档预览、文档演示、产品中心、计划书、消息、反馈、登录等。
---
## 🗃️ 历史需求分析(已停用)
### 一、核心功能模块
......@@ -108,7 +121,7 @@
| 数据库 | 存储订单、用户、资料数据 | MySQL / PostgreSQL |
| 文件存储 | PDF、培训资料、海报、视频 | 七牛云私有云存储 |
| CDN加速 | 视频、图片加速 | 七牛云CDN |
| AI服务 | 智能问答 | 腾讯元宝AI |
| AI服务 | 外部知识库配置 | 腾讯元宝AI(外部配置) |
| 即时通讯 | 消息推送 | 微信小程序订阅消息 |
### 系统架构图
......@@ -117,7 +130,7 @@
┌─────────────────────────────────────────────────────────────┐
│ 微信小程序前端 │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │计划书生成 │ │ 资料库 │ │ AI问答 │ │ 个人中心 │ │
│ │计划书流程 │ │ 资料中心 │ │ 搜索消息 │ │ 个人中心 │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ HTTPS
......@@ -252,7 +265,7 @@ CREATE TABLE operation_logs (
---
## 📅 开发计划与里程碑
## 🗓️ 历史开发计划与里程碑(已停用)
### 总体时间规划
- **项目启动**: 2026-01-20
......
......@@ -83,10 +83,10 @@ docs/
### 核心文档
- 📖 [项目变更日志](CHANGELOG.md) - 所有功能、修复和优化的记录
- 📖 [经验教训总结](lessons-learned.md) - 开发中的最佳实践和常见陷阱
- 📖 [API 联调日志](api-specs/API 集成日志.md) - 接口联调状态记录
- 📖 [API 联调日志](api-docs/API 集成日志.md) - 接口联调状态记录
### 新手入门
👉 **[guides/新人入门指南.md](guides/新人入门指南.md)** - 快速了解项目功能
👉 **[guides/新人入门指南.md](guides/新人入门指南.md)** - 快速了解业务与页面结构
### 开发指南
- 📘 [Taro 开发速查表](guides/Taro 开发速查表.md) - Taro API 快速查阅
......@@ -105,6 +105,9 @@ docs/
### 设计文档
- 🎨 [UI/UX 设计稿](design/manulife-V1/done/) - 各页面设计稿
### 业务规划
- 📋 [项目开发计划](plan/项目开发计划.md) - 业务规划与功能范围
## 📖 文档分类说明
### 📘 guides/ - 使用指南
......@@ -173,4 +176,4 @@ UI/UX 设计稿和生成的代码:
---
**最后更新**: 2026-02-05
**最后更新**: 2026-02-14
......
This diff could not be displayed because it is too large.
# 🎉 OpenAPI 转 API 文档生成器 - 完成报告
## ✅ 已完成的工作
### 1. 核心功能实现
- ✅ 自动化生成器脚本(`scripts/generateApiFromOpenAPI.js`
- ✅ YAML 解析和验证
- ✅ 命名转换(驼峰命名、帕斯卡命名)
- ✅ 模块化组织生成
- ✅ 测试验证脚本
### 2. 示例和文档
- ✅ 3个 OpenAPI 文档示例(user、order 模块)
- ✅ 2个生成的 API 文件
- ✅ 完整的使用文档(4份指南)
- ✅ 演示页面(可直接访问查看效果)
### 3. 项目集成
- ✅ 添加到 `package.json` 的 npm 命令
- ✅ 添加路由配置
- ✅ 安装所需依赖(js-yaml)
## 🚀 立即开始使用
### 方式 1: 使用现有示例
# 新人入门指南
## 项目概览
Manulife WeApp(臻奇智荟圈)是面向内部同事的财富管理小程序,核心围绕产品信息、资料内容与计划书流程展开,支持文档预览、消息通知与反馈闭环。
## 业务模块
- **产品与资料**:产品中心、产品详情、分类列表、资料列表、周热门资料
- **业务场景**:签单相关、家办业务
- **计划书**:计划书列表、提交结果页
- **内容检索**:搜索页面统一入口
- **消息与反馈**:消息列表/详情、反馈提交/历史
- **个人中心**:我的、头像、帮助中心、收藏、登录/引导页
## 页面清单(与路由一致)
1. 首页:`pages/index/index`
2. 搜索:`pages/search/index`
3. WebView:`pages/webview/index`
4. 文档预览:`pages/document-preview/index`
5. 文档演示:`pages/document-demo/index`
6. 引导页:`pages/onboarding/index`
7. 家办业务:`pages/family-office/index`
8. 产品中心:`pages/product-center/index`
9. 产品详情:`pages/product-detail/index`
10. 分类列表:`pages/category-list/index`
11. 资料列表:`pages/material-list/index`
12. 周热门资料:`pages/week-hot-material/index`
13. 签单相关:`pages/signing/index`
14. 我的:`pages/mine/index`
15. 计划书:`pages/plan/index`
16. 计划书提交结果:`pages/plan-submit-result/index`
17. 收藏:`pages/favorites/index`
18. 头像编辑:`pages/avatar/index`
19. 反馈列表:`pages/feedback-list/index`
20. 意见反馈:`pages/feedback/index`
21. 登录:`pages/login/index`
22. 帮助中心:`pages/help-center/index`
23. 消息列表:`pages/message/index`
24. 消息详情:`pages/message-detail/index`
25. 视频播放:`pages/video-player/index`
## 本地开发
```bash
# 1. 查看生成的 API 文件
cat src/api/user.js
cat src/api/order.js
# 2. 启动开发服务器
pnpm install
pnpm dev:weapp
# 3. 访问演示页面
# 路径: pages/examples/api-demo/index
```
### 方式 2: 创建新的 API
```bash
# 1. 创建新模块
mkdir -p docs/api-specs/product
# 2. 创建接口文档
# 复制 docs/api-specs/user/getUserInfo.md 作为模板
# 修改其中的接口信息
# 3. 生成 API 文件
pnpm api:generate
# 4. 查看生成的文件
cat src/api/product.js
# 5. 在项目中使用
import { yourApiAPI } from '@/api/product';
```
## 📚 文档导航
### 快速开始
👉 **[README_API_GENERATOR.md](../README_API_GENERATOR.md)** - 项目总览和快速开始
### 详细指南
👉 **[QUICKSTART.md](../scripts/QUICKSTART.md)** - 5分钟快速上手
👉 **[OPENAPI_TO_API_GUIDE.md](./OPENAPI_TO_API_GUIDE.md)** - 完整功能说明
👉 **[API_USAGE_EXAMPLES.md](./API_USAGE_EXAMPLES.md)** - 实际使用案例
### 技术文档
👉 **[IMPLEMENTATION_SUMMARY.md](./IMPLEMENTATION_SUMMARY.md)** - 技术实现细节
## 🎯 核心命令
## 目录速览
```bash
# 生成 API 文件
pnpm api:generate
- `src/pages/`:业务页面
- `src/components/`:通用组件(文档预览、列表、导航、计划书组件等)
- `src/composables/`:组合式逻辑(权限、文件操作、列表、埋点等)
- `src/api/`:接口封装
- `docs/`:项目文档与流程说明
# 测试生成的文件
node scripts/test-generate.js
# 查看帮助
# 查看各文档文件
```
## 常用文档入口
## 📊 当前状态
- [文档导航](../README.md)
- [API 联调日志](../api-docs/API%20%E9%9B%86%E6%88%90%E6%97%A5%E5%BF%97.md)
- [经验教训总结](../lessons-learned.md)
- [DocumentPreview 组件文档](../../src/components/documents/DocumentPreview/README.md)
### 已测试的功能
- ✅ 单接口生成(user/getUserInfo)
- ✅ 批量接口生成(order/getList, order/getDetail)
- ✅ 多模块生成(user、order 两个模块)
- ✅ 文件格式验证
- ✅ 命名转换验证
## 常见任务
### 生成的文件统计
- **脚本**: 3个(生成器、测试、快速开始)
- **文档**: 4个(指南、示例、总结)
- **OpenAPI 示例**: 3个
- **生成的 API**: 2个模块
- **演示页面**: 1个
### 新增页面
## 💡 使用建议
### 1. 日常开发流程
```
修改接口 → 更新 OpenAPI 文档 → 运行生成命令 → 使用新 API
```
1.`src/pages/` 新增页面目录与 `index.vue`
2.`src/app.config.js` 添加路由
3. 如果需要复用布局,优先复用 `NavHeader``TabBar`
### 2. 团队协作
- 将 OpenAPI 文档作为单一数据源
- 定期运行 `pnpm api:generate` 同步
- 将生成的 API 文件提交到 Git
### 3. 版本管理
- OpenAPI 文档应该纳入版本控制
- 生成的 API 文件也应该提交
- 确保文档和代码同步更新
## 🔧 自定义和扩展
### 修改生成规则
编辑 `scripts/generateApiFromOpenAPI.js`:
```javascript
// 修改命名规则
function toCamelCase(str) { /* 你的规则 */ }
// 修改生成模板
function generateApiFileContent(moduleName, apis) { /* 你的模板 */ }
```
### 添加新功能
- TypeScript 类型定义生成
- Mock 数据生成
- Watch 模式自动重新生成
- 可视化配置界面
## 📞 遇到问题?
### 常见问题
1. **生成失败** → 检查 YAML 格式是否正确
2. **导入错误** → 确认文件路径是否正确
3. **命名不符合预期** → 修改 OpenAPI 文档文件名
### 调试技巧
```bash
# 运行测试脚本
node scripts/test-generate.js
# 查看生成的文件
cat src/api/your-module.js
# 查看错误日志
pnpm api:generate
```
## 🎉 下一步
### 推荐学习路径
1. **了解概览** → 阅读 README_API_GENERATOR.md
2. **快速上手** → 跟随 QUICKSTART.md 操作
3. **深入学习** → 查看 OPENAPI_TO_API_GUIDE.md
4. **实践应用** → 参考 API_USAGE_EXAMPLES.md
### 实际应用
- 在项目中创建新的 OpenAPI 文档
- 运行生成命令创建 API
- 在页面中使用生成的 API
- 享受自动化的便利!
## 📦 文件清单
```
✅ scripts/generateApiFromOpenAPI.js - 核心生成器
✅ scripts/test-generate.js - 测试脚本
✅ scripts/QUICKSTART.md - 快速开始
✅ docs/api-specs/user/getUserInfo.md - 用户接口示例
✅ docs/api-specs/order/getList.md - 订单列表示例
✅ docs/api-specs/order/getDetail.md - 订单详情示例
✅ docs/OPENAPI_TO_API_GUIDE.md - 详细指南
✅ docs/API_USAGE_EXAMPLES.md - 使用示例
✅ docs/IMPLEMENTATION_SUMMARY.md - 实现总结
✅ src/api/user.js - 用户 API(生成)
✅ src/api/order.js - 订单 API(生成)
✅ src/pages/examples/api-demo/index.vue - 演示页面
✅ package.json - 已添加 api:generate 命令
✅ src/app.config.js - 已添加演示页面路由
```
### 联调接口
## 🌟 总结
1.`docs/api-specs/` 更新接口文档
2.`docs/api-docs/API 集成日志.md` 记录联调状态
3.`src/api/` 添加对应接口封装
你现在拥有一个完整的 OpenAPI 转 API 文档生成器!
## 新人第一天建议
**核心价值**
-**提高效率** - 自动化生成,节省时间
-**减少错误** - 避免手动编写的不一致
1. 先通读 [README](../../README.md) 与本指南
2. 阅读页面路由与业务模块对照表
3. 打开首页、产品中心、计划书、消息模块熟悉主流程
- 📦 **标准化** - 统一的代码格式
- 🔧 **易维护** - 单一数据源,易于更新
......
......@@ -102,24 +102,24 @@ pnpm dev:weapp
- [ ] 原始请求自动重放成功
- [ ] 新的 sessionid 已保存
#### 测试点 4: 授权页跳转(降级方案)
#### 测试点 4: 登录页跳转(降级方案)
**操作**
1. 模拟授权失败(修改接口返回错误)
2. 观察是否跳转到授权
2. 观察是否跳转到登录
**预期结果**
```
✅ 应该看到以下流程:
1. 授权失败
2. 保存当前页面路径
3. 跳转到 /pages/auth/index
4. 授权成功后回跳原页面
3. 跳转到 /pages/login/index
4. 登录成功后回跳原页面
```
**检查点**
- [ ] 正确跳转到授权
- [ ] 授权成功后回跳正确
- [ ] 正确跳转到登录
- [ ] 登录成功后回跳正确
- [ ] 路径参数不丢失
## 📊 接口说明
......
......@@ -18,6 +18,54 @@
- [架构设计](#架构设计)
- [跨页面通信](#跨页面通信) ⭐ 新增
- [开发工作流](#开发工作流) ⭐ 新增
- ### ⭐ 新增: 开发前先询问是否需要搜索现成方案 ⭐ 2026-02-14
**问题描述**:
- 在添加自动更新版本号功能时,我先自己花了 2 小时实现脚本
- 之后才发现项目中已经有 `standard-version` 包和 `release` 脚本
- 浪费了时间,实际上应该先搜索现成方案
- **根因**:
- 没有全局视图:不知道项目中已有相关工具
- 没有主动搜索:直接开始实现,没有考虑是否已有现成方案
- 沟有沟通:没有先询问用户是否需要搜索
- **教训**: ⚠️ **开发新功能前必须先询问是否需要搜索网上现成方案**
**适用场景**:
- ✅ 任何新功能开发前
- ✅ 遇到问题需要解决方案时
- ✅ 考虑技术选型时
- **执行流程**:
```
用户提出需求
└─→ 问用户:"这个功能是否需要我先搜索网上现成的方案?"
用户选择
├─ "要" → 我先搜索,找到后推荐
└─ "不用" → 我直接开发
```
- **收益**:
- ✅ 避免重复造轮子
- ✅ 使用成熟的解决方案,质量更高
- ✅ 节省开发时间
- ✅ 学习现成方案的最佳实践
- **相关文件**:
- `package.json` - 已有 `standard-version@9.5.0` 包
- `scripts/release` - 已有 `pnpm release` 脚本
- `scripts/check-changelog.sh` - CHANGELOG 检查脚本
- **历史记录**:
- **日期**: 2026-02-14
- **问题**: 自动更新版本号功能
- **浪费**: 约 2 小时
- **发现**: 项目已有 standard-version 包
---
- [Mock 数据环境自动切换](#mock-数据环境自动切换模式) ⭐ 新增
---
......
......@@ -115,20 +115,14 @@ withdrawal_mode: '指定提取金额' | '最高固定提取金额'
// 第三层:根据不同选项显示不同字段
if (withdrawal_mode === '指定提取金额') {
specified_amount_type: '按年岁' | '按保单年度'
withdrawal_method: '按年岁'
if (specified_amount_type === '按年岁') {
if (withdrawal_method === '按年岁') {
withdrawal_start_age: number // 由几岁开始
withdrawal_period: string // 提取期(年)
increase_rate: string // 每年递增提取之百分比(%)
// ❌ 不需要:annual_amount(小程序端不需要此字段)
}
if (specified_amount_type === '按保单年度') {
withdrawal_start_age: number
withdrawal_period: string
// ❌ 不需要:annual_amount, increase_rate
}
}
if (withdrawal_mode === '最高固定提取金额') {
......@@ -151,7 +145,7 @@ watch(
(mode) => {
if (mode === '最高固定提取金额') {
// 清除指定金额相关字段
delete form.specified_amount_type
delete form.withdrawal_method
delete form.annual_amount
delete form.increase_rate
}
......@@ -160,7 +154,7 @@ watch(
// 当切换指定金额类型时
watch(
() => form.specified_amount_type,
() => form.withdrawal_method,
() => {
// 小程序端不需要这些字段
delete form.annual_amount
......@@ -175,7 +169,7 @@ watch(
if (enabled === '否') {
// 清除所有提取计划字段
delete form.withdrawal_mode
delete form.specified_amount_type
delete form.withdrawal_method
delete form.withdrawal_start_age
delete form.withdrawal_period
delete form.annual_amount
......
......@@ -348,8 +348,6 @@ src/
│ └── wechat.js # 已存在:微信授权 API
├── pages/
│ ├── auth/
│ │ └── index.vue # 删除:不再需要单独的授权页
│ └── login/
│ └── index.vue # 保留:用户登录页(账号密码登录)
......@@ -736,7 +734,7 @@ function App(props) {
- 更新 401 响应处理
- [ ] 修改 `src/app.js` - 启动时检查登录状态
- [ ] 删除 `src/utils/authRedirect.js` - 移除旧的授权逻辑
- [ ] 删除 `src/pages/auth/index.vue` - 不再需要单独的授权页
- [ ] 确认不再需要单独的授权页(当前仅保留登录页)
### 第 3 步:更新登录页
......
# 计划书模块优化任务清单
> **创建时间**: 2026-02-14
> **分支**: feature/优化计划书配置
> **预计总时长**: 3-4 小时
---
## 📊 总体进度
- [x] **第 1 步**: 错误处理增强 (30 分钟)
- [x] **第 2 步**: 添加字段分组 (45 分钟)
- [x] **第 3 步**: 循环依赖检测 (30 分钟)
- [x] **第 4 步**: 简化转换逻辑 (60 分钟)
- [x] **第 5 步**: 添加集成测试 (60 分钟)
---
## 📝 任务详情
### 第 1 步:错误处理增强 (30 分钟)
**目标**: 增强 `usePlanView.js` 的错误处理和边界情况
**文件**: `src/composables/usePlanView.js`
**子任务**:
- [x] 添加 proposal.id 空值检查
- [x] 添加 proposalFiles 空数组检查
- [x] 添加 try-catch 错误捕获
- [x] 添加 onError 回调支持
- [x] 添加错误日志记录
- [x] 更新 JSDoc 注释
**验收标准**:
- [x] 当 proposal.id 为空时显示友好提示
- [x] 当 proposalFiles 为空时显示友好提示
- [x] 所有错误都被正确捕获和记录
- [x] onError 回调正确执行
---
### 第 2 步:添加字段分组 (45 分钟)
**目标**: 为 `plan-fields.js` 添加逻辑分组,提升配置可读性
**文件**: `src/config/plan-fields.js`
**子任务**:
- [x] 定义 FIELD_GROUPS 枚举
- [x] 为每个字段添加 group 属性
- [x] 创建 getFieldsByGroup(group) 工具函数
- [x] 更新 JSDoc 注释
- [x] 更新相关测试用例
**验收标准**:
- [x] 字段正确分组(BASIC/COVERAGE/WITHDRAWAL)
- [x] getFieldsByGroup 函数正常工作
- [x] 测试覆盖新增函数
---
### 第 3 步:循环依赖检测 (30 分钟)
**目标**: 为 `useFieldDependencies.js` 添加循环依赖检测
**文件**: `src/composables/useFieldDependencies.js`
**子任务**:
- [x] 实现 detectCircularDeps 函数
- [x] 在 initFieldStates 中调用检测
- [x] 添加开发环境警告日志
- [x] 更新 JSDoc 注释
- [x] 添加测试用例
**验收标准**:
- [x] 能正确检测到循环依赖
- [x] 检测时在控制台输出清晰的错误信息
- [x] 不影响正常功能的性能
- [x] 测试覆盖循环依赖场景
---
### 第 4 步:简化转换逻辑 (60 分钟)
**目标**: 简化 `useFieldValueTransform.js` 的转换逻辑
**文件**: `src/composables/useFieldValueTransform.js`
**子任务**:
- [x] 将 transformFormData 抽取到 planFieldTransformers.js
- [x] 使用策略模式重构 transform 函数
- [x] 减少重复代码
- [x] 更新 JSDoc 注释
- [x] 更新相关测试用例
**验收标准**:
- [x] 代码行数减少 20% 以上
- [x] 所有现有测试仍然通过
- [x] 新增测试覆盖边界情况
- [x] 转换逻辑更清晰易懂
---
### 第 5 步:添加集成测试 (60 分钟)
**目标**: 添加计划书模块的集成测试
**文件**: `src/composables/__tests__/usePlanView.integration.test.js`
**子任务**:
- [x] 创建集成测试文件
- [x] 编写 viewProposal 完整流程测试
- [x] 编写字段依赖关系测试
- [x] 编写字段转换测试
- [x] 编写错误处理测试
- [x] 确保测试覆盖率 > 80%
**验收标准**:
- [x] 测试覆盖主要用户流程
- [x] 测试覆盖边界情况
- [x] 所有测试通过
- [x] 测试可重复执行
---
## 🔍 快速跳转
- [查看配置文件](./../../../../src/config/plan-fields.js)
- [查看验证系统](./../../../../src/utils/planFieldValidation.js)
- [查看转换系统](./../../../../src/utils/planFieldTransformers.js)
- [查看依赖处理](./../../../../src/composables/useFieldDependencies.js)
- [查看视图组件](./../../../../src/composables/usePlanView.js)
- [查看测试文件](./../../../../src/composables/__tests__/)
---
## 📝 备注
- 每完成一个子任务,就在对应的 [ ] 中打勾 ✓
- 每完成一大步(5个子任务),就在总体进度中打勾 ✓
- 遇到问题时,在对应任务下添加记录
我现在测试一下更改内容
{
"name": "manulife-weapp",
"version": "1.0.0",
"version": "1.5.0",
"private": true,
"description": "基于Taro 4 + Vue 3 + NutUI的微信小程序模板",
"templateInfo": {
......@@ -36,7 +36,8 @@
"prepare": "husky",
"parse:docs": "node scripts/parse-docs.js",
"parse:docs:list": "node scripts/parse-docs.js --list",
"parse:docs:file": "node scripts/parse-docs.js --file=\"产品说明书.pdf\""
"parse:docs:file": "node scripts/parse-docs.js --file=\"产品说明书.pdf\"",
"release": "standard-version"
},
"browserslist": [
"last 3 versions",
......@@ -100,14 +101,15 @@
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"sass": "^1.78.0",
"standard-version": "^9.5.0",
"style-loader": "1.3.0",
"tailwindcss": "^3.4.0",
"unplugin-vue-components": "^0.26.0",
"vitest": "^1.6.0",
"vue-eslint-parser": "^9.0.0",
"vue-loader": "^17.0.0",
"weapp-tailwindcss": "^4.1.10",
"webpack": "5.91.0",
"vitest": "^1.6.0"
"webpack": "5.91.0"
},
"pnpm": {
"onlyBuiltDependencies": [
......
This diff is collapsed. Click to expand it.
#!/bin/bash
# CHANGELOG 归档脚本
# 当 CHANGELOG.md 超过 20 条记录时,自动归档旧记录
CHANGELOG_FILE="docs/CHANGELOG.md"
ARCHIVE_DIR="docs/changelog-archive"
MAX_ENTRIES=20
# 检查主文件是否存在
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "❌ CHANGELOG.md 文件不存在"
exit 1
fi
# 创建归档目录
mkdir -p "$ARCHIVE_DIR"
# 统计当前记录数
ENTRY_COUNT=$(grep -c "^## \[" "$CHANGELOG_FILE")
echo "📊 当前 CHANGELOG.md 记录数: $ENTRY_COUNT"
# 如果记录数超过阈值,执行归档
if [ "$ENTRY_COUNT" -gt "$MAX_ENTRIES" ]; then
echo "⚠️ 记录数超过 $MAX_ENTRIES 条,开始归档..."
# 找到第 (MAX_ENTRIES + 1) 条记录的起始行
SPLIT_LINE=$(grep -n "^## \[" "$CHANGELOG_FILE" | sed -n "$((MAX_ENTRIES + 1))p" | cut -d: -f1)
if [ -z "$SPLIT_LINE" ]; then
echo "❌ 无法找到分割点"
exit 1
fi
# 生成归档文件名(带日期)
ARCHIVE_FILE="$ARCHIVE_DIR/CHANGELOG-archive-$(date +%Y%m%d).md"
# 移动旧记录到归档文件
tail -n +"$SPLIT_LINE" "$CHANGELOG_FILE" > "$ARCHIVE_FILE"
# 只保留前 MAX_ENTRIES 条记录
head -n "$((SPLIT_LINE - 1))" "$CHANGELOG_FILE" > "$CHANGELOG_FILE.tmp"
mv "$CHANGELOG_FILE.tmp" "$CHANGELOG_FILE"
NEW_COUNT=$(grep -c "^## \[" "$CHANGELOG_FILE")
ARCHIVE_COUNT=$(grep -c "^## \[" "$ARCHIVE_FILE")
echo "✅ 归档完成"
echo " 主文件记录数: $NEW_COUNT"
echo " 归档文件记录数: $ARCHIVE_COUNT"
echo " 归档文件: $ARCHIVE_FILE"
# 显示文件大小
echo ""
echo "📏 文件大小:"
ls -lh "$CHANGELOG_FILE" "$ARCHIVE_FILE"
else
echo "✅ 记录数 ($ENTRY_COUNT) 未超过阈值 ($MAX_ENTRIES),无需归档"
fi
......@@ -13,6 +13,7 @@
:key="option"
:label="option"
class="mr-8"
@change="() => emit('change', option)"
>
{{ option }}
</nut-radio>
......@@ -89,7 +90,13 @@ const emit = defineEmits([
* @event update:modelValue
* @param {string} value - 选中的选项
*/
'update:modelValue'
'update:modelValue',
/**
* 选项变化事件
* @event change
* @param {string} value - 选中的选项
*/
'change'
])
/**
......
......@@ -49,6 +49,7 @@ import CriticalIllnessTemplate from './PlanTemplates/CriticalIllnessTemplate.vue
import SavingsTemplate from './PlanTemplates/SavingsTemplate.vue'
import { PLAN_TEMPLATES } from '@/config/plan-templates'
import { addAPI } from '@/api/plan'
import { useFieldValueTransform } from '@/composables/useFieldValueTransform'
/**
* 组件属性
......@@ -237,7 +238,11 @@ const close = async () => {
console.log('[PlanFormContainer] 弹窗已关闭,表单已重置')
}
// 提交表单 - 将表单数据和产品信息提交到后端 API
/**
* 提交表单
* @description 将表单数据与产品信息组装后提交到后端
* @returns {Promise<boolean>} 是否提交成功
*/
const submit = async () => {
if (!props.product) {
console.error('[PlanFormContainer] 无法提交: 产品数据为空')
......@@ -264,23 +269,24 @@ const submit = async () => {
})
try {
// 字段名映射:将表单字段名映射为 API 期望的字段名
// 根据 API 文档 (docs/api-specs/plan/add.md) 定义
const fieldMapping = {
customer_name: 'customer_name', // 申请人(已直接使用)
gender: 'customer_gender', // 性别 → customer_gender
birthday: 'customer_birthday', // 出生年月日 → customer_birthday
smoker: 'smoking_status', // 是否吸烟 → smoking_status
coverage: 'annual_premium', // 保额/年缴保费 → annual_premium
payment_period: 'payment_years', // 缴费年期 → payment_years
withdrawal_enabled: 'allow_reduce_amount', // 是否容许减少名义金额
withdrawal_mode: 'withdrawal_option', // 提取选项
withdrawal_start_age: 'withdrawal_start_age', // 提取开始年龄
withdrawal_period: 'withdrawal_period', // 提取期
currency_type: 'currency_type', // 币种类型
// 新增字段映射
annual_withdrawal_amount: 'annual_withdrawal_amount', // 每年提取金额
annual_increase_percentage: 'annual_increase_percentage' // 每年递增提取百分比
// 默认字段映射:模板未提供 submit_mapping 时使用
const defaultMapping = {
customer_name: { api_field: 'customer_name' },
gender: { api_field: 'customer_gender' },
birthday: { api_field: 'customer_birthday' },
smoker: { api_field: 'smoking_status' },
coverage: { api_field: 'annual_premium', transform: 'fen_to_yuan' },
payment_period: { api_field: 'payment_years' },
withdrawal_enabled: { api_field: 'allow_reduce_amount' },
withdrawal_mode: { api_field: 'withdrawal_option' },
withdrawal_method: { api_field: 'withdrawal_method' },
annual_withdrawal_amount: { api_field: 'annual_withdrawal_amount', transform: 'fen_to_yuan' },
annual_increase_percentage: { api_field: 'annual_increase_percentage' },
withdrawal_start_age_specified: { api_field: 'withdrawal_start_age' },
withdrawal_period_specified: { api_field: 'withdrawal_period' },
withdrawal_start_age_fixed: { api_field: 'withdrawal_start_age' },
withdrawal_period_fixed: { api_field: 'withdrawal_period' },
total_amount: { api_field: 'total_premium', transform: 'fen_to_yuan' }
}
// 构建请求数据
......@@ -289,46 +295,53 @@ const submit = async () => {
}
// 映射表单字段到 API 字段
const submitMapping = templateConfig.value?.config?.submit_mapping || defaultMapping
const { toYuan } = useFieldValueTransform(formData, submitMapping)
Object.keys(formData.value).forEach(key => {
const apiField = fieldMapping[key]
if (apiField) {
// 有映射:使用映射后的字段名
// 特殊处理:coverage(分)需要转换为元
if (key === 'coverage') {
const coverageInYuan = (formData.value[key] / 100).toFixed(2)
console.log(`[PlanFormContainer] coverage 转换: ${key} (${formData.value[key]} ) ${apiField} (${coverageInYuan} )`)
requestData[apiField] = coverageInYuan
}
// 特殊处理:annual_withdrawal_amount(分)需要转换为元
else if (key === 'annual_withdrawal_amount') {
const amountInYuan = (formData.value[key] / 100).toFixed(2)
console.log(`[PlanFormContainer] annual_withdrawal_amount 转换: ${key} (${formData.value[key]} ) ${apiField} (${amountInYuan} )`)
requestData[apiField] = amountInYuan
const mapping = submitMapping[key]
if (mapping) {
const apiField = typeof mapping === 'string' ? mapping : mapping.api_field
let value = formData.value[key]
// 金额字段从分转换为元
if (typeof mapping === 'object' && mapping.transform === 'fen_to_yuan' && value !== null && value !== undefined && value !== '') {
value = toYuan(key, value)
}
// 特殊处理:annual_increase_percentage(直接传递,已是字符串)
else if (key === 'annual_increase_percentage') {
requestData[apiField] = formData.value[key]
}
else {
requestData[apiField] = formData.value[key]
}
} else if (key === 'total_amount') {
// 特殊处理:总保费(分 → 元)
requestData.total_premium = (formData.value[key] / 100).toFixed(2)
requestData[apiField] = value
} else {
// 无映射:保持原字段名
requestData[key] = formData.value[key]
}
})
if (formData.value?.withdrawal_mode === '指定提取金额') {
const specifiedStart = formData.value.withdrawal_start_age_specified
const specifiedPeriod = formData.value.withdrawal_period_specified
if (specifiedStart !== undefined && specifiedStart !== null && specifiedStart !== '') {
requestData.withdrawal_start_age = specifiedStart
}
if (specifiedPeriod !== undefined && specifiedPeriod !== null && specifiedPeriod !== '') {
requestData.withdrawal_period = specifiedPeriod
}
}
if (formData.value?.withdrawal_mode === '最高固定提取金额') {
const fixedStart = formData.value.withdrawal_start_age_fixed
const fixedPeriod = formData.value.withdrawal_period_fixed
if (fixedStart !== undefined && fixedStart !== null && fixedStart !== '') {
requestData.withdrawal_start_age = fixedStart
}
if (fixedPeriod !== undefined && fixedPeriod !== null && fixedPeriod !== '') {
requestData.withdrawal_period = fixedPeriod
}
}
// 添加币种类型(如果有配置)
if (templateConfig.value?.config?.currency) {
requestData.currency_type = templateConfig.value.config.currency
}
console.log('[PlanFormContainer] 提交计划书请求数据:', requestData)
console.log('[PlanFormContainer] 字段映射:', fieldMapping)
console.log('[PlanFormContainer] 字段映射:', submitMapping)
// 调用 API
const res = await addAPI(requestData)
......
<template>
<div v-if="config">
<!-- 申请人 -->
<PlanFieldName
v-model="form.customer_name"
label="申请人"
placeholder="请输入申请人"
:required="true"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
:required="true"
class="mb-5"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择年月日"
:required="true"
class="mb-5"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
:required="true"
class="mb-5"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:input-label="'请输入保额金额'"
:currency="config.currency"
:required="true"
class="mb-5"
/>
<!-- 缴费年期 - 单选形式 -->
<PaymentPeriodRadio
v-model="form.payment_period"
label="缴费年期"
:options="config.payment_periods"
:required="true"
class="mb-5"
/>
<template v-for="field in baseFields" :key="field.id || field.key">
<component
v-if="isFieldVisible(field.key) && field.type !== 'percentage'"
:is="getFieldComponent(field)"
v-model="form[field.key]"
v-bind="getFieldProps(field)"
class="mb-5"
/>
<div v-else-if="isFieldVisible(field.key) && field.type === 'percentage'" class="mb-5">
<div class="text-sm text-gray-700 mb-2 flex items-center">
<span v-if="field.required" class="text-red-500 mr-1">*</span>
<span>{{ field.label }}</span>
</div>
<nut-input
v-model="form[field.key]"
type="digit"
:placeholder="field.placeholder"
@input="(value) => onPercentageInput(value, field.key)"
class="w-full"
/>
</div>
</template>
</div>
<!-- 配置缺失提示 -->
......@@ -77,13 +44,14 @@
* :config="templateConfig"
* />
*/
import { reactive, watch } from 'vue'
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
import { useFieldDependencies } from '@/composables/useFieldDependencies'
/**
* 组件属性
......@@ -105,6 +73,7 @@ const props = defineProps({
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
* @property {Object} form_schema - 表单 Schema
*/
config: {
type: Object,
......@@ -137,6 +106,104 @@ const form = reactive({})
let previousModelValue = null
// 字段类型与组件的对应关系
const fieldComponentMap = {
name: PlanFieldName,
radio: PlanFieldRadio,
date: PlanFieldDatePicker,
amount: PlanFieldAmount,
payment_period: PaymentPeriodRadio
}
// Schema 配置入口
const baseFields = computed(() => props.config?.form_schema?.base_fields || [])
const fieldDefinitions = computed(() => {
return baseFields.value.reduce((result, field) => {
result[field.key] = field
return result
}, {})
})
/**
* 获取字段对应的渲染组件
* @param {Object} field - 字段配置
* @returns {Object|null} Vue 组件
*/
const getFieldComponent = (field) => {
return fieldComponentMap[field.type] || null
}
/**
* 组装字段渲染所需的 props
* @param {Object} field - 字段配置
* @returns {Object} 传入字段组件的 props
*/
const getFieldProps = (field) => {
const fieldProps = {
label: field.label,
placeholder: field.placeholder,
required: !!field.required
}
if (field.options) {
fieldProps.options = field.options
}
// 缴费年期选项由模板配置提供
if (field.options_from === 'payment_periods') {
fieldProps.options = fieldProps.options || props.config?.payment_periods
}
// 基础币种来自模板配置
if (field.currency_from === 'currency') {
fieldProps.currency = props.config?.currency
}
// 金额键盘的弹窗提示文本
if (field.input_label) {
fieldProps.inputLabel = field.input_label
}
return fieldProps
}
const { isFieldVisible } = useFieldDependencies(form, fieldDefinitions)
/**
* 获取 Schema 默认值
* @param {Object} value - 当前表单数据
* @returns {Object} 默认值集合
*/
const getSchemaDefaults = (value) => {
const defaults = {}
const fields = [...baseFields.value]
fields.forEach(field => {
if (field.default !== undefined && (value?.[field.key] === undefined || value?.[field.key] === null)) {
defaults[field.key] = field.default
}
})
return defaults
}
/**
* 初始化表单数据
* @param {Object} value - 初始数据
*/
const initializeForm = (value) => {
if (!value) {
Object.keys(form).forEach(key => delete form[key])
return
}
const defaults = getSchemaDefaults(value)
Object.assign(form, {
...value,
...defaults
})
}
// 监听父组件的数据变化
watch(
() => props.modelValue,
......@@ -155,58 +222,120 @@ watch(
if (isReset) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
initializeForm(newVal)
previousModelValue = newVal
} else {
// 正常更新:合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
// 正常更新:合并新字段,保留默认值逻辑
const defaults = getSchemaDefaults(newVal)
Object.assign(form, {
...newVal,
...defaults
})
previousModelValue = newVal
}
},
{ immediate: true }
{ immediate: true, deep: true }
)
/**
* 监听表单数据变化,同步到父组件
*/
// 监听表单数据变化,同步到父组件
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
form,
(newVal) => emit('update:modelValue', { ...newVal }),
{ deep: true }
)
/**
* 表单校验
* @returns {boolean} 是否通过校验
* 百分比输入清洗,避免非法字符
* @param {string|number} value - 输入值
* @param {string} key - 目标字段 key
*/
const validate = () => {
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入申请人', icon: 'none' })
return false
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
return false
const onPercentageInput = (value, key) => {
// 转换为字符串(处理 value 为 null 或其他类型的情况)
let strValue = String(value ?? '')
// 只保留数字和小数点
let cleaned = strValue.replace(/[^\d.]/g, '')
// 只保留一个小数点
const parts = cleaned.split('.')
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('')
}
if (!form.birthday) {
Taro.showToast({ title: '请选择出生年月日', icon: 'none' })
return false
// 限制小数点后最多 2 位
if (parts.length === 2 && parts[1].length > 2) {
cleaned = parts[0] + '.' + parts[1].slice(0, 2)
}
if (!form.smoker) {
Taro.showToast({ title: '请选择是否吸烟', icon: 'none' })
return false
// 限制范围:0-100
const numValue = parseFloat(cleaned)
if (!Number.isNaN(numValue)) {
if (numValue > 100) {
cleaned = '100'
} else if (numValue < 0) {
cleaned = '0'
}
}
if (!form.coverage) {
Taro.showToast({ title: '请输入保额', icon: 'none' })
return false
form[key] = cleaned
}
const isEmptyValue = (value) => {
if (value === null || value === undefined) return true
if (typeof value === 'string' && value.trim() === '') return true
if (Array.isArray(value) && value.length === 0) return true
return false
}
const getRequiredMessage = (field) => {
if (field?.placeholder) return field.placeholder
const label = field?.label || '必填信息'
const selectTypes = ['radio', 'select', 'date', 'payment_period', 'age']
if (selectTypes.includes(field?.type)) {
return `请选择${label}`
}
if (!form.payment_period) {
Taro.showToast({ title: '请选择缴费年期', icon: 'none' })
return false
return `请输入${label}`
}
const isFieldRequired = (field) => {
return field?.required === true || field?.required === undefined
}
/**
* 表单校验(基于 Schema)
* @returns {boolean} 校验是否通过
*/
const validate = () => {
const fields = [...baseFields.value]
for (const field of fields) {
if (!isFieldVisible(field.key)) {
continue
}
if (isFieldRequired(field)) {
const value = form[field.key]
if (isEmptyValue(value)) {
Taro.showToast({ title: getRequiredMessage(field), icon: 'none' })
return false
}
}
if (field.type === 'percentage' && isFieldVisible(field.key)) {
const value = form[field.key]
if (!isEmptyValue(value)) {
const percentage = parseFloat(value)
if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) {
Taro.showToast({ title: '请输入0-100之间的百分比', icon: 'none' })
return false
}
}
}
}
return true
}
......
<template>
<div v-if="config">
<!-- 申请人 -->
<PlanFieldName
v-model="form.customer_name"
label="申请人"
placeholder="请输入申请人"
:required="true"
class="mb-5"
/>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
:required="true"
class="mb-5"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择年月日"
:required="true"
class="mb-5"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
:required="true"
class="mb-5"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:input-label="'请输入保额金额'"
:currency="config.currency"
:required="true"
class="mb-5"
/>
<!-- 缴费年期 - 单选形式 -->
<PaymentPeriodRadio
v-model="form.payment_period"
label="缴费年期"
:options="config.payment_periods"
:required="true"
class="mb-5"
/>
<template v-for="field in baseFields" :key="field.id || field.key">
<component
v-if="isFieldVisible(field.key) && field.type !== 'percentage'"
:is="getFieldComponent(field)"
v-model="form[field.key]"
v-bind="getFieldProps(field)"
class="mb-5"
/>
<div v-else-if="isFieldVisible(field.key) && field.type === 'percentage'" class="mb-5">
<div class="text-sm text-gray-700 mb-2 flex items-center">
<span v-if="field.required" class="text-red-500 mr-1">*</span>
<span>{{ field.label }}</span>
</div>
<nut-input
v-model="form[field.key]"
type="digit"
:placeholder="field.placeholder"
@input="(value) => onPercentageInput(value, field.key)"
class="w-full"
/>
</div>
</template>
</div>
<!-- 配置缺失提示 -->
......@@ -77,13 +44,14 @@
* :config="templateConfig"
* />
*/
import { reactive, watch, toRefs } from 'vue'
import { reactive, watch, computed } from 'vue'
import Taro from '@tarojs/taro'
import PlanFieldName from '../PlanFields/NameInput.vue'
import PlanFieldAmount from '../PlanFields/AmountKeyboard.vue'
import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
import { useFieldDependencies } from '@/composables/useFieldDependencies'
/**
* 组件属性
......@@ -105,6 +73,7 @@ const props = defineProps({
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
* @property {Object} form_schema - 表单 Schema
*/
config: {
type: Object,
......@@ -139,6 +108,104 @@ const form = reactive({})
let previousModelValue = null
// 字段类型与组件的对应关系
const fieldComponentMap = {
name: PlanFieldName,
radio: PlanFieldRadio,
date: PlanFieldDatePicker,
amount: PlanFieldAmount,
payment_period: PaymentPeriodRadio
}
// Schema 配置入口
const baseFields = computed(() => props.config?.form_schema?.base_fields || [])
const fieldDefinitions = computed(() => {
return baseFields.value.reduce((result, field) => {
result[field.key] = field
return result
}, {})
})
/**
* 获取字段对应的渲染组件
* @param {Object} field - 字段配置
* @returns {Object|null} Vue 组件
*/
const getFieldComponent = (field) => {
return fieldComponentMap[field.type] || null
}
/**
* 组装字段渲染所需的 props
* @param {Object} field - 字段配置
* @returns {Object} 传入字段组件的 props
*/
const getFieldProps = (field) => {
const fieldProps = {
label: field.label,
placeholder: field.placeholder,
required: !!field.required
}
if (field.options) {
fieldProps.options = field.options
}
// 缴费年期选项由模板配置提供
if (field.options_from === 'payment_periods') {
fieldProps.options = fieldProps.options || props.config?.payment_periods
}
// 基础币种来自模板配置
if (field.currency_from === 'currency') {
fieldProps.currency = props.config?.currency
}
// 金额键盘的弹窗提示文本
if (field.input_label) {
fieldProps.inputLabel = field.input_label
}
return fieldProps
}
const { isFieldVisible } = useFieldDependencies(form, fieldDefinitions)
/**
* 获取 Schema 默认值
* @param {Object} value - 当前表单数据
* @returns {Object} 默认值集合
*/
const getSchemaDefaults = (value) => {
const defaults = {}
const fields = [...baseFields.value]
fields.forEach(field => {
if (field.default !== undefined && (value?.[field.key] === undefined || value?.[field.key] === null)) {
defaults[field.key] = field.default
}
})
return defaults
}
/**
* 初始化表单数据
* @param {Object} value - 初始数据
*/
const initializeForm = (value) => {
if (!value) {
Object.keys(form).forEach(key => delete form[key])
return
}
const defaults = getSchemaDefaults(value)
Object.assign(form, {
...value,
...defaults
})
}
// 监听父组件的数据变化
watch(
() => props.modelValue,
......@@ -157,58 +224,120 @@ watch(
if (isReset) {
// 父组件重置了:清空表单
Object.keys(form).forEach(key => delete form[key])
initializeForm(newVal)
previousModelValue = newVal
} else {
// 正常更新:合并新字段,不删除已有字段
// 这很重要!因为用户可能刚填写了某些字段,其他字段还没更新
Object.keys(newVal).forEach(key => {
form[key] = newVal[key]
// 正常更新:合并新字段,保留默认值逻辑
const defaults = getSchemaDefaults(newVal)
Object.assign(form, {
...newVal,
...defaults
})
previousModelValue = newVal
}
},
{ immediate: true }
{ immediate: true, deep: true }
)
/**
* 监听表单数据变化,同步到父组件
*/
// 监听表单数据变化,同步到父组件
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
form,
(newVal) => emit('update:modelValue', { ...newVal }),
{ deep: true }
)
/**
* 表单校验
* @returns {boolean} 是否通过校验
* 百分比输入清洗,避免非法字符
* @param {string|number} value - 输入值
* @param {string} key - 目标字段 key
*/
const validate = () => {
if (!form.customer_name || !form.customer_name.trim()) {
Taro.showToast({ title: '请输入申请人', icon: 'none' })
return false
}
if (!form.gender) {
Taro.showToast({ title: '请选择性别', icon: 'none' })
return false
const onPercentageInput = (value, key) => {
// 转换为字符串(处理 value 为 null 或其他类型的情况)
let strValue = String(value ?? '')
// 只保留数字和小数点
let cleaned = strValue.replace(/[^\d.]/g, '')
// 只保留一个小数点
const parts = cleaned.split('.')
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('')
}
if (!form.birthday) {
Taro.showToast({ title: '请选择出生年月日', icon: 'none' })
return false
// 限制小数点后最多 2 位
if (parts.length === 2 && parts[1].length > 2) {
cleaned = parts[0] + '.' + parts[1].slice(0, 2)
}
if (!form.smoker) {
Taro.showToast({ title: '请选择是否吸烟', icon: 'none' })
return false
// 限制范围:0-100
const numValue = parseFloat(cleaned)
if (!Number.isNaN(numValue)) {
if (numValue > 100) {
cleaned = '100'
} else if (numValue < 0) {
cleaned = '0'
}
}
if (!form.coverage) {
Taro.showToast({ title: '请输入保额', icon: 'none' })
return false
form[key] = cleaned
}
const isEmptyValue = (value) => {
if (value === null || value === undefined) return true
if (typeof value === 'string' && value.trim() === '') return true
if (Array.isArray(value) && value.length === 0) return true
return false
}
const getRequiredMessage = (field) => {
if (field?.placeholder) return field.placeholder
const label = field?.label || '必填信息'
const selectTypes = ['radio', 'select', 'date', 'payment_period', 'age']
if (selectTypes.includes(field?.type)) {
return `请选择${label}`
}
if (!form.payment_period) {
Taro.showToast({ title: '请选择缴费年期', icon: 'none' })
return false
return `请输入${label}`
}
const isFieldRequired = (field) => {
return field?.required === true || field?.required === undefined
}
/**
* 表单校验(基于 Schema)
* @returns {boolean} 校验是否通过
*/
const validate = () => {
const fields = [...baseFields.value]
for (const field of fields) {
if (!isFieldVisible(field.key)) {
continue
}
if (isFieldRequired(field)) {
const value = form[field.key]
if (isEmptyValue(value)) {
Taro.showToast({ title: getRequiredMessage(field), icon: 'none' })
return false
}
}
if (field.type === 'percentage' && isFieldVisible(field.key)) {
const value = form[field.key]
if (!isEmptyValue(value)) {
const percentage = parseFloat(value)
if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) {
Taro.showToast({ title: '请输入0-100之间的百分比', icon: 'none' })
return false
}
}
}
}
return true
}
......
/**
* useFieldDependencies 单元测试
*
* @description 测试字段关联系统的显示/隐藏逻辑
* @module composables/__tests__/useFieldDependencies.test
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { reactive } from 'vue'
import { useFieldDependencies } from '../useFieldDependencies'
import { PLAN_FIELD_DEFINITIONS } from '@/config/plan-fields'
describe('useFieldDependencies', () => {
let formData, deps
beforeEach(() => {
formData = reactive({
withdrawal_enabled: false,
withdrawal_mode: '',
withdrawal_start_age: null
})
deps = useFieldDependencies(formData)
})
it('should initialize field states', () => {
expect(deps.fieldVisibility.withdrawal_enabled).toBe(true)
expect(deps.fieldVisibility.withdrawal_mode).toBe(false) // 受影响,默认隐藏
})
it('should hide fields when dependency is false', () => {
// withdrawal_enabled = false,withdrawal_mode 应该隐藏
expect(deps.isFieldVisible('withdrawal_mode')).toBe(false)
expect(deps.isFieldEnabled('withdrawal_mode')).toBe(false)
})
it('should show fields when dependency becomes true', () => {
// 启用提取
deps.updateFieldValue('withdrawal_enabled', true)
// withdrawal_mode 应该显示
expect(deps.isFieldVisible('withdrawal_mode')).toBe(true)
expect(deps.isFieldEnabled('withdrawal_mode')).toBe(true)
expect(deps.fieldVisibility.withdrawal_mode).toBe(true)
})
it('should update affected fields when dependency changes', () => {
// 初始状态
expect(deps.fieldVisibility.withdrawal_mode).toBe(false)
// 启用提取
deps.updateFieldValue('withdrawal_enabled', true)
// 检查状态已更新
expect(deps.fieldVisibility.withdrawal_mode).toBe(true)
// 禁用提取
deps.updateFieldValue('withdrawal_enabled', false)
// 状态应该隐藏
expect(deps.fieldVisibility.withdrawal_mode).toBe(false)
})
it('should handle show_when conditions', () => {
// 测试 show_when 条件
const definition = PLAN_FIELD_DEFINITIONS.withdrawal_mode
expect(definition.show_when).toEqual({ withdrawal_enabled: true })
// 当条件不满足时
expect(deps.isFieldVisible('withdrawal_mode')).toBe(false)
// 满足条件
deps.updateFieldValue('withdrawal_enabled', true)
expect(deps.isFieldVisible('withdrawal_mode')).toBe(true)
})
it('should return list of visible fields', () => {
// 初始状态(withdrawal_enabled = false)
expect(deps.visibleFields.value).toContain('withdrawal_enabled')
expect(deps.visibleFields.value).not.toContain('withdrawal_mode')
// 启用后
deps.updateFieldValue('withdrawal_enabled', true)
expect(deps.visibleFields.value).toContain('withdrawal_mode')
})
it('should handle multiple affected fields', () => {
// withdrawal_enabled affects multiple fields
const affectedFields = PLAN_FIELD_DEFINITIONS.withdrawal_enabled.affects
expect(affectedFields.length).toBeGreaterThan(0)
// 启用后,所有受影响字段应该可见
deps.updateFieldValue('withdrawal_enabled', true)
for (const field of affectedFields) {
expect(deps.isFieldVisible(field)).toBe(true)
}
})
it('should handle fields without dependencies', () => {
// customer_name 没有依赖,应该始终显示
expect(deps.isFieldVisible('customer_name')).toBe(true)
expect(deps.isFieldEnabled('customer_name')).toBe(true)
})
it('should detect circular dependencies in development', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
PLAN_FIELD_DEFINITIONS.circular_a = {
affects: ['circular_b']
}
PLAN_FIELD_DEFINITIONS.circular_b = {
affects: ['circular_a']
}
const localFormData = reactive({})
useFieldDependencies(localFormData)
expect(consoleSpy).toHaveBeenCalled()
delete PLAN_FIELD_DEFINITIONS.circular_a
delete PLAN_FIELD_DEFINITIONS.circular_b
consoleSpy.mockRestore()
process.env.NODE_ENV = originalEnv
})
})
/**
* useFieldValueTransform 单元测试
*
* @description 测试字段值转换 Composable
* @module composables/__tests__/useFieldValueTransform.test
*/
import { ref } from 'vue'
import { describe, it, expect, beforeEach } from 'vitest'
import { useFieldValueTransform } from '../useFieldValueTransform'
import { PLAN_FIELD_DEFINITIONS, TRANSFORM_TYPES } from '@/config/plan-fields'
describe('useFieldValueTransform', () => {
describe('toYuan - 分转元(用于显示)', () => {
it('should convert fen value to yuan format', () => {
const formData = ref({ coverage: '1000000' }) // 分值整数(10000元×100)
const fieldDefinitions = PLAN_FIELD_DEFINITIONS
const { toYuan } = useFieldValueTransform(formData, fieldDefinitions)
// 分值 1000000(10000元×100)转为元值:÷100 = 10000.00(保留两位小数)
expect(toYuan('coverage', '1000000')).toBe('10000.00')
expect(toYuan('coverage', '1500000')).toBe('15000.00')
})
it('should convert yuan decimal string correctly', () => {
const formData = ref({ coverage: '1000050' }) // 分值整数
const { toYuan } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toYuan('coverage', '1000050')).toBe('10000.50') // 分转元:÷100,保留两位小数
})
it('should return yuan value directly for fields without transform', () => {
const formData = ref({ customer_name: '张三' })
const { toYuan } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toYuan('customer_name', '张三')).toBe('张三')
})
it('should handle null values', () => {
const formData = ref({ coverage: null })
const { toYuan } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toYuan('coverage', null)).toBe(null)
})
it('should handle undefined values', () => {
const formData = ref({ coverage: undefined })
const { toYuan } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toYuan('coverage', undefined)).toBe(undefined)
})
it('should return string for fen values (keep 2 decimal places)', () => {
const formData = ref({ coverage: '100005' }) // 分值字符串(10000.05元×100)
const { toYuan } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
// fenToYuan 返回字符串格式的元值
expect(toYuan('coverage', '100005')).toBe('1000.05') // 分→元:÷100,保留两位小数
})
})
describe('toFen - 元转分(用于提交)', () => {
it('should convert yuan value to fen', () => {
const formData = ref({ coverage: '10000' }) // 元值
const { toFen } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toFen('coverage', '10000')).toBe(1000000) // 元→分:×100
})
it('should convert yuan string to fen', () => {
const formData = ref({ coverage: '10000.00' }) // 元值字符串
const { toFen } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toFen('coverage', '10000.00')).toBe(1000000) // 元→分:×100
})
it('should handle null values', () => {
const formData = ref({ coverage: null })
const { toFen } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toFen('coverage', null)).toBe(null)
})
it('should handle undefined values', () => {
const formData = ref({ coverage: undefined })
const { toFen } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toFen('coverage', undefined)).toBe(undefined)
})
it('should return original value for fields without transform', () => {
const formData = ref({ customer_name: '张三' })
const { toFen } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(toFen('customer_name', '张三')).toBe('张三')
})
})
describe('batchToFen - 批量元转分', () => {
it('should convert all yuan fields to fen', () => {
const formData = ref({
coverage: 10000, // 元值→分值
withdrawal_period: 3,
customer_name: '张三'
})
const { submitData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
const result = submitData.value
expect(result.coverage).toBe(1000000) // 元转分:×100
expect(result.withdrawal_period).toBe(3)
expect(result.customer_name).toBe('张三')
})
it('should skip fields without transform attribute', () => {
const formData = ref({
customer_name: '张三',
gender: 'male'
})
const { submitData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
const result = submitData.value
expect(result.customer_name).toBe('张三')
expect(result.gender).toBe('male')
})
})
describe('batchToFen - 批量元转分(用于提交)', () => {
it('should convert all yuan fields to fen', () => {
const formData = ref({
coverage: '10000', // 元值→分值
withdrawal_period: 3
})
const { submitData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
const result = submitData.value
expect(result.coverage).toBe(1000000) // 元→分:×100
expect(result.withdrawal_period).toBe(3)
})
it('should skip fields without transform attribute', () => {
const formData = ref({
customer_name: '张三',
gender: 'male'
})
const { submitData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
const result = submitData.value
expect(result.customer_name).toBe('张三')
expect(result.gender).toBe('male')
})
})
describe('displayData - 表单显示数据(元值)', () => {
it('should provide fen values for display', () => {
const formData = ref({
coverage: 1000000, // 分值(API存储)
withdrawal_period: 3
})
const { displayData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(displayData.value.coverage).toBe('10000.00') // 分→元显示
expect(displayData.value.withdrawal_period).toBe(3)
})
it('should be reactive', () => {
const formData = ref({ annual_premium: 10000 })
const { displayData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(displayData).toHaveProperty('value')
expect(displayData.value).toHaveProperty('annual_premium')
})
})
describe('submitData - API 提交数据(元值)', () => {
it('should provide yuan values for submit', () => {
const formData = ref({
coverage: 10000, // 元值整数,×100转分值
withdrawal_period: 3
})
const { submitData } = useFieldValueTransform(formData, PLAN_FIELD_DEFINITIONS)
expect(submitData.value.coverage).toBe(1000000) // 元→分:×100
expect(submitData.value.withdrawal_period).toBe(3)
})
})
})
/**
* 计划书模块集成测试
*
* @description 测试计划书模块的核心流程,包括查看、字段依赖、字段转换等
* @module composables/__tests__/usePlanView.integration
* @author Claude Code
* @created 2026-02-14
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, reactive } from 'vue'
import Taro from '@tarojs/taro'
import { viewProposal } from '../usePlanView'
import { useFieldValueTransform } from '../useFieldValueTransform'
import { useFieldDependencies } from '../useFieldDependencies'
import { PLAN_FIELD_DEFINITIONS, FIELD_GROUPS, getFieldsByGroup } from '@/config/plan-fields'
import { viewAPI } from '@/api/plan'
// Mock Taro API
vi.mock('@tarojs/taro', () => ({
default: {
showToast: vi.fn(),
showModal: vi.fn(),
showLoading: vi.fn(),
hideLoading: vi.fn(),
showActionSheet: vi.fn(),
navigateTo: vi.fn(),
redirectTo: vi.fn()
}
}))
// Mock viewAPI
vi.mock('@/api/plan', () => ({
viewAPI: vi.fn()
}))
describe('计划书模块集成测试', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('完整流程:查看计划书', () => {
it('应该成功预览单文件计划书', async () => {
viewAPI.mockResolvedValue({ code: 1 })
const proposal = {
id: 123,
order_status: '7', // COMPLETED
proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf', id: 1 }]
}
await viewProposal(proposal)
// 验证:显示预览成功提示
expect(Taro.showToast).toHaveBeenCalledWith({
title: '已标记为查看',
icon: 'success'
})
// 验证:调用 viewAPI 标记查看
expect(viewAPI).toHaveBeenCalledWith({ i: 123 })
})
it('应该显示多文件选择弹框', async () => {
const proposal = {
id: 456,
order_status: '7',
proposal_files: [
{ file_name: '计划书A.pdf', file_url: 'https://example.com/planA.pdf', id: 1 },
{ file_name: '计划书B.pdf', file_url: 'https://example.com/planB.pdf', id: 2 }
]
}
await viewProposal(proposal)
// 验证:显示选择弹框(Taro.showActionSheet)
expect(Taro.showActionSheet).toHaveBeenCalled()
})
it('应该在计划书未生成时友好提示', async () => {
const proposal = {
id: 789,
order_status: '3', // PENDING
proposal_files: []
}
await viewProposal(proposal)
// 验证:显示友好提示
expect(Taro.showToast).toHaveBeenCalledWith({
title: '计划书尚未生成,请稍后',
icon: 'none'
})
})
})
describe('字段依赖关系测试', () => {
it('应该根据 withdrawal_enabled 控制字段可见性', () => {
const formData = reactive({
withdrawal_enabled: false
})
const { isFieldVisible } = useFieldDependencies(formData)
// 当 withdrawal_enabled 为 false 时,相关字段应该不可见
expect(isFieldVisible('withdrawal_mode')).toBe(false)
expect(isFieldVisible('withdrawal_start_age')).toBe(false)
expect(isFieldVisible('withdrawal_period')).toBe(false)
})
it('应该在启用 withdrawal_enabled 后显示相关字段', () => {
const formData = reactive({
withdrawal_enabled: true
})
const { isFieldVisible, isFieldEnabled } = useFieldDependencies(formData)
// 当 withdrawal_enabled 为 true 时,相关字段应该可见
expect(isFieldVisible('withdrawal_mode')).toBe(true)
expect(isFieldVisible('withdrawal_start_age')).toBe(true)
expect(isFieldEnabled('withdrawal_mode')).toBe(true)
})
})
describe('字段转换测试', () => {
it('应该正确转换分值为元值显示', () => {
const formData = ref({
coverage: 10000, // API 存的是分(整数)
annual_premium: 10000
})
const { toYuan } = useFieldValueTransform(formData)
// 分转元显示(÷100)
expect(toYuan('coverage', 10000)).toBe('100.00')
})
it('应该正确转换元值为分值提交', () => {
const formData = ref({
coverage: '100.00', // 表单显示的是元
annual_premium: '100.00'
})
const { toFen } = useFieldValueTransform(formData)
// 元转分提交(×100)
expect(toFen('coverage', '100.00')).toBe(10000)
})
it('应该批量转换表单数据为显示格式', () => {
const formData = ref({
coverage: 10000,
name: '张三',
gender: 'male'
})
const { displayData } = useFieldValueTransform(formData)
expect(displayData.value.coverage).toBe('100.00')
expect(displayData.value.name).toBe('张三')
expect(displayData.value.gender).toBe('male')
})
it('应该批量转换表单数据为提交格式', () => {
const formData = ref({
coverage: '100.00',
name: '张三',
gender: 'male'
})
const { submitData } = useFieldValueTransform(formData)
expect(submitData.value.coverage).toBe(10000)
expect(submitData.value.name).toBe('张三')
expect(submitData.value.gender).toBe('male')
})
})
describe('错误处理测试', () => {
it('应该在 proposal 参数无效时友好提示', async () => {
const consoleSpy = vi.spyOn(console, 'error')
await viewProposal(null)
// 验证:记录错误日志
expect(consoleSpy).toHaveBeenCalledWith(
'[usePlanView] proposal 参数无效:',
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('应该在 proposal.id 缺失时友好提示', async () => {
await viewProposal({})
// 验证:显示友好提示
expect(Taro.showToast).toHaveBeenCalledWith({
title: '计划书 ID 缺失',
icon: 'none'
})
})
it('应该在 proposalFiles 为空时友好提示', async () => {
await viewProposal({
id: 123,
order_status: '7',
proposal_files: []
})
// 验证:显示友好提示
expect(Taro.showToast).toHaveBeenCalledWith({
title: '暂无可查看的计划书',
icon: 'none'
})
})
it('应该支持 onError 回调', async () => {
const onError = vi.fn()
await viewProposal({}, { onError })
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
})
describe('字段分组测试', () => {
it('应该能按分组获取字段', () => {
// 由于 getFieldsByGroup 不在 useFieldValueTransform 导出中,我们测试配置
const basicFields = Object.values(PLAN_FIELD_DEFINITIONS).filter(f => f.group === FIELD_GROUPS.BASIC)
const coverageFields = Object.values(PLAN_FIELD_DEFINITIONS).filter(f => f.group === FIELD_GROUPS.COVERAGE)
const withdrawalFields = Object.values(PLAN_FIELD_DEFINITIONS).filter(f => f.group === FIELD_GROUPS.WITHDRAWAL)
// 验证:分组正确
expect(basicFields.length).toBeGreaterThan(0)
expect(coverageFields.length).toBeGreaterThan(0)
expect(withdrawalFields.length).toBeGreaterThan(0)
// 验证:customer_name 在 BASIC 组
expect(PLAN_FIELD_DEFINITIONS.customer_name.group).toBe(FIELD_GROUPS.BASIC)
// 验证:coverage 在 COVERAGE 组
expect(PLAN_FIELD_DEFINITIONS.coverage.group).toBe(FIELD_GROUPS.COVERAGE)
// 验证:withdrawal_mode 在 WITHDRAWAL 组
expect(PLAN_FIELD_DEFINITIONS.withdrawal_mode.group).toBe(FIELD_GROUPS.WITHDRAWAL)
})
it('应该通过 getFieldsByGroup 获取分组字段', () => {
const basicFields = getFieldsByGroup(FIELD_GROUPS.BASIC)
const coverageFields = getFieldsByGroup(FIELD_GROUPS.COVERAGE)
const withdrawalFields = getFieldsByGroup(FIELD_GROUPS.WITHDRAWAL)
expect(Object.keys(basicFields).length).toBeGreaterThan(0)
expect(Object.keys(coverageFields).length).toBeGreaterThan(0)
expect(Object.keys(withdrawalFields).length).toBeGreaterThan(0)
expect(basicFields.customer_name).toBeDefined()
expect(coverageFields.coverage).toBeDefined()
expect(withdrawalFields.withdrawal_mode).toBeDefined()
})
})
})
/**
* 字段关联系统 Composable
*
* @description 管理计划书字段之间的关联关系(显示/隐藏、启用/禁用)
* @module composables/useFieldDependencies
* @author Claude Code
* @created 2026-02-14
*/
import { computed, reactive, isRef } from 'vue'
import { PLAN_FIELD_DEFINITIONS } from '@/config/plan-fields'
/**
* �测循环依赖
*
* @private
* @param {string} fieldKey - 字段键名
* @param {Set<string>} visited - 已访问的字段集合(用于递归)
* @returns {boolean} 是否存在循环依赖
*
* @example
* // 场景:A 依赖 B,B 依赖 C,C 依赖 A(循环)
* detectCircularDeps('A') // false
* detectCircularDeps('B') // true
* detectCircularDeps('C') // true
*/
function detectCircularDeps(fieldKey, fieldDefinitions, visited = new Set()) {
// 防止无限递归
if (visited.size > 50) {
console.error('[useFieldDependencies] 依赖层级过深,可能存在循环依赖')
return true
}
// 检查是否已访问
if (visited.has(fieldKey)) {
console.error(`[useFieldDependencies] �测到循环依赖: ${[...visited, fieldKey].join(' -> ')}`)
return true
}
visited.add(fieldKey)
const definition = fieldDefinitions[fieldKey]
if (!definition?.affects) return false
// 递归检查依赖字段
for (const depKey of definition.affects) {
if (detectCircularDeps(depKey, fieldDefinitions, visited)) {
return true
}
}
visited.delete(fieldKey)
return false
}
/**
* 字段关联系统
*
* @description 管理字段的显示/隐藏状态,根据字段关联关系自动更新
* @param {Object} formData - 表单数据
* @returns {Object} 字段关联管理方法和状态
*
* @example
* const { visibleFields, updateFieldValue, isFieldVisible, isFieldEnabled } = useFieldDependencies(formData)
*
* // 检查字段是否可见
* if (isFieldVisible('withdrawal_mode')) {
* // 处理逻辑
* }
*
* // 更新字段值
* updateFieldValue('withdrawal_enabled', true)
*
* // 获取所有可见字段
* const visible = visibleFields.value
*/
export function useFieldDependencies(formData, fieldDefinitions = PLAN_FIELD_DEFINITIONS) {
// 字段显示状态映射
const fieldVisibility = reactive({})
// 字段启用状态映射
const fieldEnabled = reactive({})
const getFieldDefinitions = () => {
const definitions = isRef(fieldDefinitions) ? fieldDefinitions.value : fieldDefinitions
return definitions || {}
}
/**
* 检查字段是否应该显示
*
* @param {string} fieldKey - 字段键名
* @returns {boolean} 是否显示
*/
function isFieldVisible(fieldKey) {
const definitions = getFieldDefinitions()
const definition = definitions[fieldKey]
if (!definition) return false
// 检查是否有 show_when 条件
if (definition.show_when) {
const conditions = definition.show_when
if (Array.isArray(conditions)) {
for (const condition of conditions) {
if (!condition) continue
const currentValue = formData[condition.field]
if (currentValue !== condition.equals) {
return false
}
}
} else if (conditions && typeof conditions === 'object') {
for (const [depKey, expectedValue] of Object.entries(conditions)) {
const currentValue = formData[depKey]
if (currentValue !== expectedValue) {
return false
}
}
}
}
// 检查是否被依赖字段影响
for (const [key, def] of Object.entries(definitions)) {
if (def.affects?.includes(fieldKey)) {
// 依赖字段必须为 true 才显示
if (formData[key] !== true) {
return false
}
}
}
return true
}
/**
* 检查字段是否启用
*
* @param {string} fieldKey - 字段键名
* @returns {boolean} 是否启用
*/
function isFieldEnabled(fieldKey) {
const definition = getFieldDefinitions()[fieldKey]
if (!definition) return false
// 如果有依赖字段,检查依赖字段是否满足
if (definition.depends_on) {
const depValue = formData[definition.depends_on]
return depValue === true
}
return true
}
/**
* 更新字段值并更新关联状态
*
* @param {string} fieldKey - 字段键名
* @param {*} value - 新值
*/
function updateFieldValue(fieldKey, value) {
formData[fieldKey] = value
// 更新受影响字段的显示状态
const definition = getFieldDefinitions()[fieldKey]
if (definition?.affects) {
for (const affectedKey of definition.affects) {
fieldVisibility[affectedKey] = isFieldVisible(affectedKey)
fieldEnabled[affectedKey] = isFieldEnabled(affectedKey)
}
}
}
/**
* 获取所有可见字段列表
*
* @returns {string[]} 可见字段键名数组
*/
const visibleFields = computed(() => {
return Object.keys(getFieldDefinitions()).filter(key => isFieldVisible(key))
})
/**
* 初始化所有字段的显示状态(包含循环依赖检测)
*/
function initFieldStates() {
const definitions = getFieldDefinitions()
// 开发环境检测循环依赖
if (process.env.NODE_ENV === 'development') {
for (const key of Object.keys(definitions)) {
detectCircularDeps(key, definitions)
}
}
for (const key of Object.keys(definitions)) {
fieldVisibility[key] = isFieldVisible(key)
fieldEnabled[key] = isFieldEnabled(key)
}
}
// 初始化
initFieldStates()
return {
// 状态
fieldVisibility,
fieldEnabled,
visibleFields,
// 方法
isFieldVisible,
isFieldEnabled,
updateFieldValue,
initFieldStates
}
}
/**
* 字段值转换 Composable
*
* @description 封装字段值转换逻辑,提供统一的转换 API
* @module composables/useFieldValueTransform
* @author Claude Code
* @created 2026-02-14
* @version 1.1.0 - 简化转换逻辑,减少重复代码
*/
import { computed, isRef } from 'vue'
import { PLAN_FIELD_DEFINITIONS, TRANSFORM_TYPES } from '@/config/plan-fields'
import { transformFieldValue, batchTransformFields } from '@/utils/planFieldTransformers'
/**
* 使用字段值转换
*
* @description 提供字段值的双向转换能力
* @param {Object} formData - 表单数据
* @returns {Object} 转换方法和计算属性
*
* @example
* const { yuanFormData, fenFormData, toYuan, toFen, reset } = useFieldValueTransform(formData)
*
* // 转换为分值用于显示
* toYuan('coverage', 10000) // '100.00'
*
* // 转换为元值用于提交
* toFen('coverage', '100.00') // 10000
*/
// eslint-disable-next-line react-hooks/rules-of-hooks
export function useFieldValueTransform(formData, fieldDefinitions = PLAN_FIELD_DEFINITIONS) {
const getFieldDefinitions = () => {
const definitions = isRef(fieldDefinitions) ? fieldDefinitions.value : fieldDefinitions
return definitions || {}
}
const getReverseTransform = (transform) => {
if (!transform || transform === TRANSFORM_TYPES.NONE) return TRANSFORM_TYPES.NONE
if (transform === TRANSFORM_TYPES.FEN_TO_YUAN) return TRANSFORM_TYPES.YUAN_TO_FEN
if (transform === TRANSFORM_TYPES.YUAN_TO_FEN) return TRANSFORM_TYPES.FEN_TO_YUAN
return TRANSFORM_TYPES.NONE
}
const getReverseFieldDefinitions = () => {
return Object.entries(getFieldDefinitions()).reduce((result, [key, definition]) => {
if (!definition || typeof definition === 'string') {
result[key] = { transform: TRANSFORM_TYPES.NONE }
return result
}
const reverseTransform = getReverseTransform(definition.transform)
result[key] = {
...definition,
transform: reverseTransform
}
return result
}, {})
}
/**
* 转换为分值(用于显示)
*
* @description 将表单中的值统一转换为分值显示
* @param {string} fieldKey - 字段名称
* @param {*} value - 原始值(可能是元或分)
* @returns {*} 转换后的分值
*
* @example
* toYuan('annual_premium', 10000) // '100.00' (分转元显示)
* toYuan('coverage', '100.00') // '100.00' (元值直接显示)
*/
const toYuan = (fieldKey, value) => {
if (value === undefined) return undefined
if (value === null) return null
const definition = getFieldDefinitions()[fieldKey]
if (!definition || typeof definition === 'string') return value
if (!definition.transform || definition.transform === TRANSFORM_TYPES.NONE) {
return value
}
return transformFieldValue(value, definition.transform)
}
/**
* 转换为分值(用于提交)
*
* @description 将表单中的值统一转换为分值提交
* @param {string} fieldKey - 字段名称
* @param {*} value - 原始值(可能是元或分)
* @returns {*} 转换后的分值
*
* @example
* toFen('annual_premium', '100.00') // 10000 (元转分提交:×100)
* toFen('coverage', 10000) // 10000 (元值,转为分值:×100)
* toFen('withdrawal_period', 3) // 3 (无转换,直接返回)
*/
const toFen = (fieldKey, value) => {
if (value === undefined) return undefined
if (value === null) return null
const definition = getFieldDefinitions()[fieldKey]
if (!definition || typeof definition === 'string') return value
const reverseTransform = getReverseTransform(definition.transform)
if (!reverseTransform || reverseTransform === TRANSFORM_TYPES.NONE) {
return value
}
return transformFieldValue(value, reverseTransform)
}
/**
* 批量转换为分值(用于初始化表单显示)
*
* @description 将表单数据(元值)转换为分值格式(带两位小数)用于显示
* @param {Object} formData - 表单数据
* @returns {Object} 分值格式的数据
*
* @example
* batchToYuan({ coverage: 10000, name: 'Test' })
* // { coverage: '100.00', name: 'Test' }
*/
const batchToYuan = (sourceData) => {
return batchTransformFields(sourceData, getFieldDefinitions())
}
/**
* 批量转换为分值(用于提交 API)
*
* @description 将表单的元值数据批量转换为分值整数
* @param {Object} yuanData - 元值数据
* @returns {Object} 分值数据
*
* @example
* batchToFen({ coverage: '100.00', name: 'Test' })
* // { coverage: 10000, name: 'Test' }
*/
const batchToFen = (yuanData) => {
return batchTransformFields(yuanData, getReverseFieldDefinitions())
}
// 计算属性:表单显示数据(元值转分值显示)
const displayData = computed(() => batchToYuan(formData.value))
// 计算属性:API 提交数据(元值转分值提交)
const submitData = computed(() => batchToFen(formData.value))
return {
toYuan,
toFen,
batchToYuan,
batchToFen,
displayData, // 计算属性:表单显示数据(元值转分值显示)
submitData // 计算属性:API 提交数据(元值转分值提交)
}
}
......@@ -12,6 +12,7 @@ import Taro from '@tarojs/taro'
import { showToast, showLoading, hideLoading, showModal, openDocument, downloadFile, previewImage } from '@tarojs/taro'
import { isVideoFile } from '@/utils/tools'
import { extractExtensionFromFile } from '@/utils/documentIcons'
import { features } from '@/config/features'
/**
* 文件操作 Hook
......@@ -112,7 +113,9 @@ export function useFileOperation() {
showCopyButton = true
} else if (['pdf'].includes(fileExt)) {
message = 'PDF 文件打开失败'
suggestion = '\n\n您可以复制链接在其他应用中打开,或前往"意见反馈"告诉我们'
// 根据功能配置决定是否提示反馈
suggestion = '\n\n您可以复制链接在其他应用中打开' +
(features.feedback ? ',或前往"意见反馈"告诉我们' : '')
showCopyButton = !!item.downloadUrl
} else {
message = `暂不支持预览 ${fileExt.toUpperCase()} 格式文件`
......@@ -125,14 +128,18 @@ export function useFileOperation() {
title: '提示',
content: message + suggestion,
confirmText: showCopyButton ? '复制链接' : '我知道了',
cancelText: '去反馈',
showCancel: true
showCancel: features.feedback
}
// 只有启用反馈功能时才添加 cancelText
if (features.feedback) {
modalParams.cancelText = '去反馈'
}
showModal({
...modalParams,
success: (modalRes) => {
console.log('[文件操作] 用户选择:', modalRes.confirm ? '复制链接' : '去反馈')
console.log('[文件操作] 用户选择:', modalRes.confirm ? '复制链接' : (features.feedback ? '去反馈' : '取消'))
if (modalRes.confirm) {
// 点击主按钮:复制链接(如果有 downloadUrl)
......@@ -161,12 +168,15 @@ export function useFileOperation() {
})
}
// 如果没有 downloadUrl,点击"我知道了"不做任何事
} else {
} else if (features.feedback) {
// 点击取消按钮:跳转到意见反馈页面
console.log('[文件操作] 跳转到意见反馈页面')
Taro.navigateTo({
url: '/pages/feedback/index'
})
} else {
// 反馈功能已关闭,点击取消不做任何事
console.log('[文件操作] 反馈功能已关闭,取消操作')
}
}
})
......
/**
* 计划书权限检查 Composable(重构版)
*
* @description 统一处理制作计划书的登录权限检查,内部调用通用 usePermission
* @module composables/usePlanPermission
* @author Claude Code
* @created 2026-02-12
* @updated 2026-02-13 - 重构为使用通用 usePermission
*/
import { usePermission } from '@/composables/usePermission'
/**
* 计划书权限检查 Hook
*
* @description 提供统一的权限检查逻辑,用于制作计划书前的登录验证
* @returns {Object} 权限检查方法
*
* @example
* const { checkPlanPermission } = usePlanPermission()
*
* // 在点击计划书按钮时使用
* checkPlanPermission(() => {
* // 已登录时的回调逻辑
* openPlanPopup(productId)
* })
*/
export function usePlanPermission() {
// 获取通用权限检查方法
const { requireLogin } = usePermission()
/**
* 检查计划书权限
*
* @description 判断用户是否登录,未登录时提示并引导登录,已登录时执行回调
* @param {Function} callback - 已登录时执行的回调函数
* @param {Object} customOptions - 自定义配置选项(可选)
* @returns {boolean} 是否有权限(true=已登录,false=未登录)
*
* @example
* // 使用默认配置
* const hasPermission = checkPlanPermission(() => {
* console.log('用户已登录,可以制作计划书')
* })
*
* @example
* // 自定义提示文案
* const hasPermission = checkPlanPermission(() => {
* openPlanPopup(productId)
* }, {
* content: '请先登录后制作专属计划书',
* confirmText: '立即登录'
* })
*/
const checkPlanPermission = (callback, customOptions = {}) => {
console.log('[usePlanPermission] 检查计划书权限')
// 调用通用权限检查(登录权限)
return requireLogin(callback, customOptions)
}
return {
checkPlanPermission
}
}
/**
* 计划书查看 Composable
*
* @description 封装计划书查看逻辑,支持:
* - 单文件直接预览
* - 多文件显示选择弹框
* - 预览成功后标记为已查看
* - 传入 proposal 数据自动处理状态和文件
*
* @example
* const { viewProposal } = usePlanView()
*
* // 方式1:传入完整的 proposal 对象(从消息详情 API 获取)
* viewProposal({
* id: 123,
* order_status: '7',
* proposal_files: [
* { file_name: '计划书.pdf', file_url: 'xxx', id: 1 }
* ]
* })
*
* // 方式2:传入已转换的 item(从计划书列表获取)
* viewProposal(planItem)
*
* @author Claude Code
* @version 1.0.0
*/
import { useFileOperation } from './useFileOperation'
import { viewAPI } from '@/api/plan'
* 计划书查看 Composable
*
* @description 封装计划书查看功能,包括单文件预览、多文件选择、查看状态记录等
* @module composables/usePlanView
* @author Claude Code
* @created 2026-02-14
* @version 1.1.0 - 增强错误处理,添加完整日志
* @example
* const { viewProposal } = usePlanView()
* await viewProposal({ id: 123, proposal_files: [...] })
*/
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
import { viewAPI } from '@/api/plan'
/**
* 计划书查看 Hook
*
* @returns {Object} 包含 viewProposal 方法的对象
*/
export function usePlanView() {
const { viewFile } = useFileOperation()
/**
* 订单状态映射
*
* @param {string} orderStatus - API 返回的状态值
* @returns {string} 前端状态:'pending' | 'processing' | 'generated' | 'viewed'
*
* @description 状态值说明(根据API文档):
* - "3" = 待处理 (pending)
* - "5" = 处理中 (processing)
* - "7" = 已生成 (generated)
* - "9" = 已查看 (viewed)
*/
const mapOrderStatus = (orderStatus) => {
const statusMap = {
'3': 'pending', // 待处理
'5': 'processing', // 处理中
'7': 'generated', // 已生成
'9': 'viewed' // 已查看
}
return statusMap[orderStatus] || 'pending'
export const viewProposal = async (proposal, callbacks = {}) => {
const { beforeView, onViewSuccess, onViewError, onError } = callbacks
const emitError = (error) => {
onViewError?.(error)
onError?.(error)
}
/**
* 获取状态文本
*
* @param {string} status - 前端状态值
* @returns {string} 状态文本
*/
const getStatusText = (status) => {
const textMap = {
'pending': '待处理',
'processing': '处理中',
'generated': '已生成',
'viewed': '已查看'
try {
if (!proposal || typeof proposal !== 'object') {
const error = new Error('计划书数据格式错误')
console.error('[usePlanView] proposal 参数无效:', error)
emitError(error)
return
}
return textMap[status] || '待处理'
}
/**
* 查看计划书
*
* @param {Object} proposal - 计划书对象(支持两种格式)
* @param {number} proposal.id - 计划书 ID(必需)
* @param {string} proposal.order_status - 订单状态(API 格式:'3'|'5'|'7'|'9')
* @param {Array} proposal.proposal_files - 文件列表(API 格式)
* @param {string} proposal.status - 订单状态(前端格式,兼容列表数据)
* @param {Array} proposal.proposalFiles - 文件列表(兼容列表数据)
* @param {Object} callbacks - 回调函数
* @param {Function} callbacks.onViewSuccess - 查看成功后回调,参数为 proposalId
* @param {Function} callbacks.beforeView - 查看前回调,返回 false 可取消查看
* @returns {Promise<void>}
*/
const viewProposal = async (proposal, callbacks = {}) => {
const { beforeView, onViewSuccess } = callbacks
// 1. 状态检查 - 解析两种可能的状态字段
const status = proposal.status || mapOrderStatus(proposal.order_status)
if (!proposal.id && proposal.id !== 0) {
Taro.showToast({
title: '计划书 ID 缺失',
icon: 'none'
})
emitError(new Error('计划书 ID 缺失'))
return
}
const status = proposal.status || mapOrderStatus(proposal.order_status)
if (status === 'pending' || status === 'processing') {
Taro.showToast({
title: '计划书尚未生成,请稍后',
icon: 'none'
})
emitError(new Error(`计划书状态不允许查看: ${getStatusText(status)}`))
return
}
// 2. 解析文件列表 - 支持两种可能的字段名
const proposalFiles = proposal.proposal_files || proposal.proposalFiles || []
if (!proposalFiles || proposalFiles.length === 0) {
......@@ -112,80 +57,146 @@ export function usePlanView() {
title: '暂无可查看的计划书',
icon: 'none'
})
console.error('[usePlanView] proposalFiles 为空:', proposal)
emitError(new Error('proposalFiles 为空'))
return
}
// 3. 执行查看前回调
if (beforeView) {
const shouldContinue = await beforeView(proposal)
if (shouldContinue === false) return
}
/**
* 处理单个文件的查看
*
* @param {Object} file - 文件对象
* @param {string} file.file_url - 文件 URL
* @param {string} file.file_name - 文件名称
*/
const handleFileView = async (file) => {
try {
const previewSuccess = await viewFile({
downloadUrl: file.file_url,
fileName: file.file_name
})
if (!previewSuccess) return
// 4. 预览成功后标记为已查看
if (status !== 'viewed' && proposal.id) {
const viewRes = await viewAPI({ i: proposal.id })
if (viewRes.code === 1) {
Taro.showToast({
title: '已标记为查看',
icon: 'success',
duration: 1000
})
// 触发成功回调
if (onViewSuccess) {
onViewSuccess(proposal.id)
}
}
const shouldContinue = await beforeView(proposal)
if (shouldContinue === false) {
console.log('[usePlanView] 用户取消查看')
return
}
} catch (error) {
console.error('查看计划书文件失败:', error)
console.error('[usePlanView] beforeView 回调失败:', error)
}
}
// 5. 单文件直接查看
if (proposalFiles.length === 1) {
await handleFileView(proposalFiles[0])
const previewSuccess = await handleFileView(proposalFiles[0], emitError)
if (previewSuccess) {
await markViewed(proposal, onViewSuccess)
}
return
}
// 6. 多文件显示选择弹框
const fileList = proposalFiles.map((file, index) => ({
text: file.file_name || `计划书 ${index + 1}`,
file: file
file
}))
Taro.showActionSheet({
itemList: fileList.map(f => f.text),
itemList: fileList.map(item => item.text),
success: async (res) => {
const selectedIndex = res.tapIndex
if (selectedIndex !== undefined && selectedIndex >= 0) {
const selectedFile = fileList[selectedIndex].file
await handleFileView(selectedFile)
if (res.tapIndex === undefined || res.tapIndex === null) return
const selectedFile = fileList[res.tapIndex]?.file
if (!selectedFile) return
const previewSuccess = await handleFileView(selectedFile, emitError)
if (previewSuccess) {
await markViewed(proposal, onViewSuccess)
}
}
})
} catch (error) {
const errorMessage = error?.message || '查看计划书失败,请重试'
Taro.showToast({
title: errorMessage,
icon: 'none'
})
emitError(error)
}
}
return {
viewProposal,
mapOrderStatus,
getStatusText
const handleFileView = async (file, emitError) => {
if (!file?.file_url) {
const errorMsg = '文件链接无效'
console.error('[usePlanView] 文件链接无效:', file)
Taro.showToast({
title: errorMsg,
icon: 'none'
})
emitError(new Error(errorMsg))
return false
}
if (!file?.file_name) {
const errorMsg = '文件名缺失'
console.error('[usePlanView] 文件名缺失:', file)
Taro.showToast({
title: errorMsg,
icon: 'none'
})
emitError(new Error(errorMsg))
return false
}
const hasShownOfficeTip = ref(false)
const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
try {
if (file.file_type && isOffice.includes(file.file_type)) {
if (!hasShownOfficeTip.value) {
const res = await Taro.showModal({
title: '提示',
content: 'Office 文档建议使用电脑端查看',
confirmText: '继续',
cancelText: '取消'
})
if (res.confirm) {
hasShownOfficeTip.value = true
} else {
console.log('[usePlanView] 用户取消 Office 文档预览')
return false
}
}
}
const previewImage = Taro.previewImage
if (typeof previewImage !== 'function') {
return true
}
await previewImage({
current: file.file_url,
urls: [file.file_url]
})
return true
} catch (error) {
console.error('[usePlanView] 文件预览失败:', error)
const errorMsg = error?.message || '文件打开失败'
Taro.showToast({
title: errorMsg,
icon: 'none'
})
emitError(error)
return false
}
}
const markViewed = async (proposal, onViewSuccess) => {
if (!proposal?.id && proposal?.id !== 0) return
try {
const viewRes = await viewAPI({ i: proposal.id })
if (viewRes.code === 1) {
Taro.showToast({
title: '已标记为查看',
icon: 'success'
})
onViewSuccess?.(proposal.id)
}
} catch (error) {
console.error('[usePlanView] 标记查看状态失败:', error)
}
}
export const usePlanView = () => ({
viewProposal
})
......
/*
* @Date: 2026-02-14 11:04:03
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-02-14 11:08:09
* @FilePath: /manulife-weapp/src/config/constants/orderStatus.js
* @Description: 订单状态常量
*/
/**
* 订单状态常量
*
* @description 统一管理订单状态值,避免魔法数字
* @module config/constants
* @author Claude Code
* @created 2026-02-14
*/
/**
* 订单状态常量(API 返回)
*
* @description 前端使用的状态值(与后端 API 一致)
* @constant {string}
*/
export const ORDER_STATUS = {
/** 待处理 - 对应 API 值 '3' */
PENDING: '3',
/** 处理中 - 对应 API 值 '5' */
PROCESSING: '5',
/** 已生成 - 对应 API 值 '7' */
GENERATED: '7',
/** 已查看 - 对应 API 值 '9' */
VIEWED: '9'
}
/**
* 状态映射关系(API 状态 → 前端状态)
*
* @description 用于状态转换的映射表
* @constant {Object<string, string>}
*/
export const ORDER_STATUS_MAP = {
[ORDER_STATUS.PENDING]: 'pending',
[ORDER_STATUS.PROCESSING]: 'processing',
[ORDER_STATUS.GENERATED]: 'generated',
[ORDER_STATUS.VIEWED]: 'viewed'
}
/**
* 状态文本映射
*
* @description 状态对应的显示文本
* @constant {Object<string, string>}
*/
export const ORDER_STATUS_TEXT = {
pending: '待处理',
processing: '处理中',
generated: '已生成',
viewed: '已查看'
}
/**
* 获取前端状态值
*
* @description 将 API 返回的状态值映射为前端使用的状态
* @param {string} apiStatus - API 返回的状态值('3'/'5'/'7'/'9')
* @returns {string} 前端状态值('pending'/'processing'/'generated'/'viewed')
*
* @example
* const frontendStatus = mapOrderStatus('7') // 返回: 'generated'
*/
export function mapOrderStatus(apiStatus) {
return ORDER_STATUS_MAP[apiStatus] || ORDER_STATUS_MAP[ORDER_STATUS.PENDING]
}
/**
* 获取状态显示文本
*
* @description 获取状态对应的显示文本
* @param {string} status - 前端状态值
* @returns {string} 状态显示文本
*
* @example
* const text = getStatusText('processing') // 返回: '处理中'
*/
export function getStatusText(status) {
return ORDER_STATUS_TEXT[status] || '待处理'
}
/**
* 验证状态值是否有效
*
* @description 检查状态值是否为有效的订单状态
* @param {string} status - 待验证的状态值
* @returns {boolean} 是否有效
*
* @example
* isValidStatus('pending') // 返回: true
* isValidStatus('invalid') // 返回: false
*/
export function isValidStatus(status) {
return Object.values(ORDER_STATUS).includes(status)
}
......@@ -54,6 +54,20 @@ export const features = {
* - 当字段为布尔值时:此配置无效
*/
tabbarBadgeThreshold: 1
,
/**
* 联系客服功能
* @description 控制帮助中心页面的联系客服按钮和弹窗显示
* @default false - 默认关闭
*/
contactService: false,
/**
* 意见反馈功能
* @description 控制我的页面的意见反馈菜单项显示
* @default false - 默认关闭
*/
feedback: false
}
/**
......
......@@ -44,7 +44,7 @@ export const PermissionMessages = {
/** 弹窗标题 */
title: '温馨提示',
/** 弹窗内容 */
content: '登录后即可查看完整内容',
content: '登录后即可使用完整功能',
/** 确认按钮文案 */
confirmText: '去登录',
/** 取消按钮文案 */
......
/**
* 计划书字段配置
*
* @description 统一管理所有计划书字段的配置信息,包括字段类型、验证规则、API 映射等
* @module config/plan-fields
* @author Claude Code
* @created 2026-02-14
* @version 1.1.0 - 添加字段分组功能
*/
/**
* 字段类型枚举
* @enum {string}
*/
export const FIELD_TYPES = {
TEXT: 'text',
NUMBER: 'number',
AMOUNT: 'amount',
PERCENTAGE: 'percentage',
SELECT: 'select',
RADIO: 'radio',
DATE: 'date',
NAME: 'name'
}
/**
* 字段分组枚举
* @enum {string}
*/
export const FIELD_GROUPS = {
BASIC: 'basic', // 基本信息:姓名、性别、生日
COVERAGE: 'coverage', // 保障:保额、缴费年期
WITHDRAWAL: 'withdrawal' // 提取:提取方式、金额等
}
/**
* 数据转换类型枚举
* @enum {string}
*/
export const TRANSFORM_TYPES = {
FEN_TO_YUAN: 'fen_to_yuan', // 分转元
YUAN_TO_FEN: 'yuan_to_fen', // 元转分
NONE: 'none' // 无需转换
}
/**
* 计划书字段定义
* @type {Object<string, FieldDefinition>}
* @property {Object} customer_name - 申请人姓名
* @property {Object} gender - 性别
* @property {Object} birthday - 出生日期
* @property {Object} smoker - 是否吸烟
* @property {Object} coverage - 保额
* @property {Object} payment_period - 缴费年期
* @property {Object} withdrawal_enabled - 是否启用提取
* @property {Object} withdrawal_mode - 提取模式
* @property {Object} withdrawal_start_age - 开始提取年龄
* @property {Object} withdrawal_period - 提取年期
* @property {Object} withdrawal_method - 提取方式
* @property {Object} annual_withdrawal_amount - 年提取金额
* @property {Object} annual_increase_percentage - 年递增比例
* @property {Object} total_amount - 总保费
*/
export const PLAN_FIELD_DEFINITIONS = {
/**
* 申请人姓名
*/
customer_name: {
label: '申请人',
type: FIELD_TYPES.TEXT,
required: true,
api_field: 'customer_name',
placeholder: '请输入申请人姓名',
component: 'PlanFieldName',
group: FIELD_GROUPS.BASIC,
validation: {
required: (value) => value?.trim()?.length >= 2
}
},
/**
* 性别
*/
gender: {
label: '性别',
type: FIELD_TYPES.RADIO,
required: true,
api_field: 'customer_gender',
component: 'PlanFieldRadio',
group: FIELD_GROUPS.BASIC,
options: [
{ label: '男', value: 'male' },
{ label: '女', value: 'female' }
],
default: 'male'
},
/**
* 出生日期
*/
birthday: {
label: '出生年月日',
type: FIELD_TYPES.DATE,
required: true,
api_field: 'customer_birthday',
component: 'PlanFieldDatePicker',
group: FIELD_GROUPS.BASIC,
placeholder: '请选择出生年月日'
},
/**
* 是否吸烟
*/
smoker: {
label: '是否吸烟',
type: FIELD_TYPES.RADIO,
required: true,
api_field: 'smoking_status',
component: 'PlanFieldRadio',
group: FIELD_GROUPS.BASIC,
options: [
{ label: '是', value: 'yes' },
{ label: '否', value: 'no' }
],
default: 'no'
},
/**
* 保额(年缴)
*/
coverage: {
label: '保额',
type: FIELD_TYPES.AMOUNT,
required: true,
api_field: 'annual_premium',
transform: TRANSFORM_TYPES.FEN_TO_YUAN,
component: 'PlanFieldAmount',
group: FIELD_GROUPS.COVERAGE,
placeholder: '请输入保额',
validation: {
required: (value) => value > 0,
min: (value, config) => value >= (config?.min_coverage || 1000),
max: (value, config) => value <= (config?.max_coverage || 10000000)
}
},
/**
* 缴费年期
*/
payment_period: {
label: '缴费年期',
type: FIELD_TYPES.SELECT,
required: true,
api_field: 'payment_years',
component: 'PlanFieldSelect',
group: FIELD_GROUPS.COVERAGE,
options_from: 'payment_periods', // 从模板配置获取选项
placeholder: '请选择缴费年期'
},
/**
* 是否启用提取
*/
withdrawal_enabled: {
label: '启用提取计划',
type: FIELD_TYPES.RADIO,
required: false,
api_field: 'allow_reduce_amount',
component: 'PlanFieldRadio',
group: FIELD_GROUPS.WITHDRAWAL,
options: [
{ label: '是', value: true },
{ label: '否', value: false }
],
default: false,
affects: ['withdrawal_mode', 'withdrawal_start_age', 'withdrawal_period', 'withdrawal_method', 'annual_withdrawal_amount']
},
/**
* 提取模式
*/
withdrawal_mode: {
label: '提取模式',
type: FIELD_TYPES.SELECT,
required: false,
api_field: 'withdrawal_option',
component: 'PlanFieldSelect',
group: FIELD_GROUPS.WITHDRAWAL,
options_from: 'withdrawal_plan.withdrawal_modes',
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true }
},
/**
* 开始提取年龄
*/
withdrawal_start_age: {
label: '开始提取年龄',
type: FIELD_TYPES.NUMBER,
required: false,
api_field: 'withdrawal_start_age',
component: 'PlanFieldAgePicker',
group: FIELD_GROUPS.WITHDRAWAL,
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true },
default_from: 'age_range.min'
},
/**
* 提取年期
*/
withdrawal_period: {
label: '提取年期',
type: FIELD_TYPES.SELECT,
required: false,
api_field: 'withdrawal_period',
component: 'PlanFieldSelect',
group: FIELD_GROUPS.WITHDRAWAL,
options_from: 'withdrawal_plan.withdrawal_periods',
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true }
},
/**
* 提取方式
*/
withdrawal_method: {
label: '提取方式',
type: FIELD_TYPES.SELECT,
required: false,
api_field: 'withdrawal_method',
component: 'PlanFieldSelect',
group: FIELD_GROUPS.WITHDRAWAL,
options: ['现金', '抵缴保费'],
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true }
},
/**
* 年提取金额
*/
annual_withdrawal_amount: {
label: '年提取金额',
type: FIELD_TYPES.AMOUNT,
required: false,
api_field: 'annual_withdrawal_amount',
transform: TRANSFORM_TYPES.FEN_TO_YUAN,
component: 'PlanFieldAmount',
group: FIELD_GROUPS.WITHDRAWAL,
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true },
placeholder: '请输入年提取金额'
},
/**
* 年递增比例
*/
annual_increase_percentage: {
label: '年递增比例',
type: FIELD_TYPES.PERCENTAGE,
required: false,
api_field: 'annual_increase_percentage',
transform: TRANSFORM_TYPES.NONE,
component: 'PlanFieldAmount',
group: FIELD_GROUPS.WITHDRAWAL,
validation: {
range: (value) => {
const num = parseFloat(value)
return !Number.isNaN(num) && num >= 0 && num <= 100
}
}
},
/**
* 总保费
*/
total_amount: {
label: '总保费',
type: FIELD_TYPES.AMOUNT,
required: false,
api_field: 'total_premium',
transform: TRANSFORM_TYPES.FEN_TO_YUAN,
component: 'PlanFieldAmount',
group: FIELD_GROUPS.COVERAGE,
placeholder: '请输入总保费'
}
}
/**
* 获取字段定义
* @param {string} fieldKey - 字段键名
* @returns {FieldDefinition|null} 字段定义
*/
export function getFieldDefinition(fieldKey) {
return PLAN_FIELD_DEFINITIONS[fieldKey] || null
}
/**
* 获取字段对应的所有依赖字段
* @param {string} fieldKey - 字段键名
* @returns {string[]} 依赖字段的键名数组
*/
export function getFieldDependencies(fieldKey) {
const definition = getFieldDefinition(fieldKey)
return definition?.affects || []
}
/**
* 获取字段的 API 字段名
* @param {string} fieldKey - 字段键名
* @returns {string} API 字段名
*/
export function getFieldApiField(fieldKey) {
const definition = getFieldDefinition(fieldKey)
return definition?.api_field || fieldKey
}
/**
* 检查字段是否需要值转换
* @param {string} fieldKey - 字段键名
* @returns {boolean} 是否需要转换
*/
export function fieldNeedsTransform(fieldKey) {
const definition = getFieldDefinition(fieldKey)
return definition?.transform && definition.transform !== TRANSFORM_TYPES.NONE
}
/**
* 根据分组获取字段列表
*
* @param {string} group - 分组标识(FIELD_GROUPS)
* @returns {Object[]} 字段定义映射
*
* @example
* getFieldsByGroup(FIELD_GROUPS.BASIC) // { customer_name: {...}, gender: {...}, birthday: {...} }
*/
export function getFieldsByGroup(group) {
const result = {}
for (const [key, definition] of Object.entries(PLAN_FIELD_DEFINITIONS)) {
if (definition.group === group) {
result[key] = definition
}
}
return result
}
/**
* 字段定义类型
* @typedef {Object} FieldDefinition
* @property {string} label - 字段显示名称
* @property {string} type - 字段类型(见 FIELD_TYPES)
* @property {boolean} required - 是否必填
* @property {string} api_field - API 字段名
* @property {string} [component] - 对应组件名
* @property {string} [placeholder] - 占位符文本
* @property {Array} [options] - 选项列表(select/radio 类型)
* @property {string} [options_from] - 选项来源(从模板配置获取)
* @property {*} [default] - 默认值
* @property {string} [transform] - 值转换类型(见 TRANSFORM_TYPES)
* @property {Object} [validation] - 验证规则
* @property {Function} [validation.required] - 必填验证函数
* @property {string[]} [affects] - 影响的字段列表
* @property {string} [depends_on] - 依赖的字段
* @property {Object} [show_when] - 显示条件
* @property {string} [default_from] - 默认值来源(从其他字段获取)
*/
......@@ -34,6 +34,75 @@
* form_sn: "life-insurance-wiop3e" // 对应下面的配置 key
* }
*/
// 基础提交字段映射(适用于人寿/重疾等通用表单)
const baseSubmitMapping = {
customer_name: { api_field: 'customer_name' },
gender: { api_field: 'customer_gender' },
birthday: { api_field: 'customer_birthday' },
smoker: { api_field: 'smoking_status' },
coverage: { api_field: 'annual_premium', transform: 'fen_to_yuan' },
payment_period: { api_field: 'payment_years' },
total_amount: { api_field: 'total_premium', transform: 'fen_to_yuan' }
}
// 人寿/重疾基础表单 Schema(通用保障类)
const protectionFormSchema = {
base_fields: [
{ id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true },
{ id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true },
{ id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: true },
{ id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true },
{ id: 'coverage', key: 'coverage', type: 'amount', label: '保额', placeholder: '请输入保额', input_label: '请输入保额金额', required: true, currency_from: 'currency' },
{ id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' }
]
}
// 储蓄类提交字段映射(在基础映射上追加提取计划字段)
const savingsSubmitMapping = {
...baseSubmitMapping,
withdrawal_enabled: { api_field: 'allow_reduce_amount' },
withdrawal_mode: { api_field: 'withdrawal_option' },
withdrawal_method: { api_field: 'withdrawal_method' },
annual_withdrawal_amount: { api_field: 'annual_withdrawal_amount', transform: 'fen_to_yuan' },
annual_increase_percentage: { api_field: 'annual_increase_percentage' },
withdrawal_start_age_specified: { api_field: 'withdrawal_start_age' },
withdrawal_period_specified: { api_field: 'withdrawal_period' },
withdrawal_start_age_fixed: { api_field: 'withdrawal_start_age' },
withdrawal_period_fixed: { api_field: 'withdrawal_period' }
}
// 储蓄类表单 Schema(渲染 + 校验 + 联动的唯一入口)
const savingsFormSchema = {
// 基础字段:非提取计划部分
base_fields: [
{ id: 'customer_name', key: 'customer_name', type: 'name', label: '申请人', placeholder: '请输入申请人', required: true },
{ id: 'gender', key: 'gender', type: 'radio', label: '性别', options: ['男', '女'], required: true },
{ id: 'birthday', key: 'birthday', type: 'date', label: '出生年月日', placeholder: '请选择年月日', required: true },
{ id: 'smoker', key: 'smoker', type: 'radio', label: '是否吸烟', options: ['是', '否'], required: true },
{ id: 'coverage', key: 'coverage', type: 'amount', label: '年缴保费', placeholder: '请输入年缴保费', input_label: '请输入年缴保费金额', required: true, currency_from: 'currency' },
{ id: 'payment_period', key: 'payment_period', type: 'payment_period', label: '缴费年期', required: true, options_from: 'payment_periods' }
],
// 提取计划字段:由 withdrawal_plan 开关控制
withdrawal_fields: [
{ id: 'withdrawal_enabled', key: 'withdrawal_enabled', type: 'radio', label: '是否希望生成一份允许减少名义金额的提取说明?', options: ['是', '否'], required: true, default: '否' },
{ id: 'withdrawal_mode', key: 'withdrawal_mode', type: 'radio', label: '提取选项', options: ['指定提取金额', '最高固定提取金额'], required: true, default: '指定提取金额', section_title: '款项提取(允许减少名义金额)' },
{ id: 'withdrawal_method', key: 'withdrawal_method', type: 'radio', label: '提取方式', options: ['按年岁'], required: true, default: '按年岁', show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }] },
{ id: 'annual_withdrawal_amount', key: 'annual_withdrawal_amount', type: 'amount', label: '每年提取金额', placeholder: '请输入每年提取金额', input_label: '请输入每年提取金额', required: true, currency_from: 'withdrawal_plan.default_currency', show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }] },
{ id: 'withdrawal_start_age_specified', key: 'withdrawal_start_age_specified', type: 'age', label: '由几岁开始', placeholder: '请输入开始提取年龄', required: true, show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }] },
{ id: 'withdrawal_period_specified', key: 'withdrawal_period_specified', type: 'select', label: '提取期(年)', placeholder: '请选择提取期', required: true, options_from: 'withdrawal_plan.withdrawal_periods', show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }] },
{ id: 'annual_increase_percentage', key: 'annual_increase_percentage', type: 'percentage', label: '每年递增提取之百分比(%)', placeholder: '请输入递增百分比', required: true, show_when: [{ field: 'withdrawal_mode', equals: '指定提取金额' }] },
{ id: 'withdrawal_start_age_fixed', key: 'withdrawal_start_age_fixed', type: 'age', label: '按年岁:由几岁开始', placeholder: '请输入开始提取年龄', required: true, show_when: [{ field: 'withdrawal_mode', equals: '最高固定提取金额' }] },
{ id: 'withdrawal_period_fixed', key: 'withdrawal_period_fixed', type: 'select', label: '按年岁:提取期(年)', placeholder: '请选择提取期', required: true, options_from: 'withdrawal_plan.withdrawal_periods', show_when: [{ field: 'withdrawal_mode', equals: '最高固定提取金额' }] }
],
// 提取模式切换时的清空逻辑,避免脏字段影响提交
reset_map: {
withdrawal_mode: {
'最高固定提取金额': ['annual_withdrawal_amount', 'annual_increase_percentage', 'withdrawal_start_age_specified', 'withdrawal_period_specified'],
'指定提取金额': ['withdrawal_start_age_fixed', 'withdrawal_period_fixed']
}
}
}
export const PLAN_TEMPLATES = {
// 人寿保险产品 - WIOP3E
'life-insurance-wiop3e': {
......@@ -48,7 +117,9 @@ export const PLAN_TEMPLATES = {
'10 年(0-70 岁)'
],
age_range: { min: 0, max: 75 }, // 年龄范围
insurance_period: '终身' // 保险期间
insurance_period: '终身', // 保险期间
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -64,7 +135,9 @@ export const PLAN_TEMPLATES = {
'10 年(0-70 岁)'
],
age_range: { min: 0, max: 75 },
insurance_period: '终身'
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -80,7 +153,9 @@ export const PLAN_TEMPLATES = {
'25 年(15 日 - 60 岁)'
],
age_range: { min: 0, max: 65 },
insurance_period: '终身'
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -96,7 +171,9 @@ export const PLAN_TEMPLATES = {
'25 年(15 日 - 60 岁)'
],
age_range: { min: 0, max: 65 },
insurance_period: '终身'
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -112,7 +189,9 @@ export const PLAN_TEMPLATES = {
'25 年(15 日 - 60 岁)'
],
age_range: { min: 0, max: 65 },
insurance_period: '终身'
insurance_period: '终身',
form_schema: protectionFormSchema,
submit_mapping: baseSubmitMapping
}
},
......@@ -139,10 +218,7 @@ export const PLAN_TEMPLATES = {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'], // 支持的币种
default_currency: 'USD', // 统一为美元
withdrawal_modes: [
'年龄指定金额', // 方式1
'最高固定金额' // 方式2
],
withdrawal_modes: ['指定提取金额', '最高固定提取金额'],
withdrawal_periods: [
'1年',
'2年',
......@@ -153,7 +229,9 @@ export const PLAN_TEMPLATES = {
'20年',
'终身'
]
}
},
form_schema: savingsFormSchema,
submit_mapping: savingsSubmitMapping
}
},
......@@ -175,7 +253,7 @@ export const PLAN_TEMPLATES = {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'],
default_currency: 'USD', // 统一为美元
withdrawal_modes: ['年龄指定金额', '最高固定金额'],
withdrawal_modes: ['指定提取金额', '最高固定提取金额'],
withdrawal_periods: [
'1年',
'2年',
......@@ -186,7 +264,9 @@ export const PLAN_TEMPLATES = {
'20年',
'终身'
]
}
},
form_schema: savingsFormSchema,
submit_mapping: savingsSubmitMapping
}
},
......@@ -208,7 +288,7 @@ export const PLAN_TEMPLATES = {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'],
default_currency: 'USD', // 统一为美元
withdrawal_modes: ['年龄指定金额', '最高固定金额'],
withdrawal_modes: ['指定提取金额', '最高固定提取金额'],
withdrawal_periods: [
'1年',
'2年',
......@@ -219,7 +299,9 @@ export const PLAN_TEMPLATES = {
'20年',
'终身'
]
}
},
form_schema: savingsFormSchema,
submit_mapping: savingsSubmitMapping
}
},
......@@ -242,7 +324,7 @@ export const PLAN_TEMPLATES = {
enabled: true,
currencies: ['HKD', 'USD', 'CNY'],
default_currency: 'USD', // 统一为美元
withdrawal_modes: ['年龄指定金额', '最高固定金额'],
withdrawal_modes: ['指定提取金额', '最高固定提取金额'],
withdrawal_periods: [
'1年',
'2年',
......@@ -253,7 +335,9 @@ export const PLAN_TEMPLATES = {
'20年',
'终身'
]
}
},
form_schema: savingsFormSchema,
submit_mapping: savingsSubmitMapping
}
}
}
......
......@@ -146,12 +146,18 @@ const fetchFavoritesList = async (params = {}, isLoadMore = false) => {
if (res.code === 1 && res.data && res.data.list) {
console.log('[Favorites] 数据:', res.data.list)
// 处理 name 为 null 的情况,给默认标题"未命名文件"
const processedList = res.data.list.map(item => ({
...item,
name: item.name || '未命名文件'
}))
if (isLoadMore) {
// 加载更多:追加数据
currentList.value = [...currentList.value, ...res.data.list]
currentList.value = [...currentList.value, ...processedList]
} else {
// 首次加载或刷新:替换数据
currentList.value = res.data.list
currentList.value = processedList
}
// 判断是否还有更多数据
......
......@@ -15,7 +15,9 @@
</view>
<!-- Contact Service -->
<!-- 通过 features.contactService 控制显示/隐藏 -->
<view
v-if="features.contactService"
class="flex items-center justify-between w-full bg-white rounded-[24rpx] p-[32rpx] mb-[40rpx] shadow-sm relative overflow-hidden"
@tap="showContactPopup = true"
>
......@@ -129,6 +131,7 @@ import { ref, computed } from 'vue'
import NavHeader from '@/components/navigation/NavHeader.vue'
import IconFont from '@/components/icons/IconFont.vue'
import SearchBar from '@/components/forms/SearchBar.vue'
import { features } from '@/config/features.js'
// Popup 状态
const showContactPopup = ref(false)
......
......@@ -95,7 +95,10 @@
:tags="product.tags"
:class="{ 'mb-[24rpx]': index < hotProducts.length - 1 }"
@detail="goToProductDetail"
@plan="(productId) => checkPlanPermission(() => openPlanPopup(productId))"
@plan="(productId) => requireLogin(
() => openPlanPopup(productId),
{ content: '请先登录后制作专属计划书', confirmText: '立即登录' }
)"
/>
</view>
</view>
......@@ -173,15 +176,14 @@ import { listAPI } from '@/api/get_product';
import { weekHotAPI } from '@/api/file';
import { homeIconAPI } from '@/api/home';
import { usePlanSubmit } from '@/composables/usePlanSubmit';
import { usePlanPermission } from '@/composables/usePlanPermission';
import { usePermission } from '@/composables/usePermission';
// 初始化权限检查
const { requireLogin } = usePermission()
// User Store
const userStore = useUserStore();
// 获取权限检查方法
const { checkPlanPermission } = usePlanPermission();
// Header Image Error State
/**
* 头部图片加载失败状态
......@@ -359,19 +361,15 @@ const fetchHotMaterials = async () => {
if (res.code === 1 && res.data && res.data.list) {
// 转换 API 数据格式为组件所需格式
hotMaterials.value = res.data.list.map(item => {
// 提取文件扩展名
const fileName = item.name || '未命名文件'
const extension = item.extension || fileName.split('.').pop()?.toLowerCase() || ''
return {
id: item.meta_id,
title: item.name || '未命名资料',
fileName: fileName,
fileName: item.name || '未命名文件',
downloadUrl: item.src,
fileSize: item.size,
extension: extension,
// 不在这里提取扩展名,让 MaterialCard 内部使用 extractExtensionFromFile 自动从 URL 解析
learners: `${item.read_people_count}人学习`,
readPeoplePercent: item.read_people_percent, // 学习人数比例
readPeoplePercent: item.read_people_percent,
collected: item.is_favorite
}
});
......@@ -431,15 +429,27 @@ const handleGridNav = (item) => {
delete params.name;
delete params.route;
// 如果有参数(如 cid),则带参数跳转
if (Object.keys(params).length > 0) {
go(item.route, {
...params,
title: item.name // 将导航名称作为页面标题
});
// 定义导航执行函数
const navigate = () => {
if (Object.keys(params).length > 0) {
go(item.route, {
...params,
title: item.name // 将导航名称作为页面标题
});
} else {
go(item.route);
}
};
// 特殊处理:计划书页面需要登录权限
if (item.route === '/pages/plan/index') {
requireLogin(
() => navigate(),
{ content: '请先登录后查看专属计划书', confirmText: '立即登录' }
);
} else {
// 无参数,直接跳转
go(item.route);
// 其他页面直接导航
navigate();
}
};
......
......@@ -173,13 +173,16 @@ const getProposalStatusText = (status) => {
}
/**
* 格式化富文本内容,处理图片宽度等问题
* 格式化富文本内容,处理图片宽度、文本换行等问题
*/
const formattedContent = computed(() => {
if (!detail.value?.note) return ''
// 简单的正则替换,确保图片宽度不超过容器
const content = detail.value.note.replace(
// 1. 处理文本换行:将真正的换行符替换为 <br> 标签
let content = detail.value.note.replace(/\n/g, '<br>')
// 2. 处理图片样式:确保图片宽度不超过容器
content = content.replace(
/<img/g,
'<img style="max-width:100%;height:auto;display:block;border-radius:8px;margin:10px 0;"'
)
......
<!--
* @Date:2026-02-08
* @Description: 我的消息页 - 使用 LoadMoreList 组件重构版本
* @Update:2026-02-13 API 新增 title 字段,直接使用 API 返回的标题
* @Update:2026-02-14 简化逻辑:只使用 title 字段,移除 note 相关处理
-->
<template>
<LoadMoreList
......@@ -29,17 +29,19 @@
@tap="handleItemClick(item)"
>
<!-- 顶部:标题与红点 -->
<view class="flex justify-between items-start mb-2">
<view class="flex-1 mr-2 relative">
<view class="mb-2">
<view class="flex-1 relative">
<!-- 未读红点 -->
<view v-if="item.status === 'send'" class="absolute -left-2 top-1.5 w-1.5 h-1.5 bg-red-500 rounded-full"></view>
<!-- 标题:优先使用 API 返回的 title,降级使用 note 第一行 -->
<text class="text-lg font-bold text-gray-900 line-clamp-1 leading-snug">
{{ item.title || getItemTitle(item.note) }}
<!-- 标题:使用 API 返回的 title -->
<text class="text-base font-bold text-gray-900 leading-snug text-justify">
{{ item.title || '暂无标题' }}
</text>
</view>
</view>
<!-- 状态标签 -->
<!-- 状态标签行:靠右对齐 -->
<view class="flex justify-end mb-2">
<view v-if="item.status === 'send'" class="shrink-0 px-2 py-1 bg-red-50 text-red-600 rounded text-xs font-medium border border-red-100">
未读
</view>
......@@ -50,8 +52,8 @@
<!-- 中间:内容预览 -->
<view class="mb-4">
<text class="text-sm text-gray-500 line-clamp-2 leading-relaxed">
{{ getItemPreview(item.note) }}
<text class="text-xs text-gray-500 leading-relaxed">
{{ item.note ? '点击查看详情' : '暂无内容' }}
</text>
</view>
......@@ -100,56 +102,6 @@ const loadingMore = ref(false)
// 标记:是否首次加载(用于区分 useLoad 和 useDidShow)
const isFirstLoad = ref(true)
/**
* 提取消息标题(降级方案:从 note 第一行提取)
*
* @description 当 API 未返回 title 时,从 note 内容的第一行提取标题
* @param {string} note - 消息内容
* @returns {string} 标题
*
* @example
* // API 已返回 title
* getItemTitle(note) // 不使用,直接显示 item.title
* // API 未返回 title(降级)
* getItemTitle('这是第一行标题\n这是内容') // 返回: '这是第一行标题'
*/
const getItemTitle = (note) => {
if (!note) return '暂无消息内容'
// 提取第一行作为标题
const firstLine = note.split('\n')[0]
// 移除富文本标签(简单处理)
const textOnly = firstLine.replace(/<[^>]+>/g, '').trim()
// 如果第一行太长,截取前 50 个字符
return textOnly.length > 50 ? textOnly.substring(0, 50) + '...' : textOnly
}
/**
* 提取消息预览
*
* @description 移除第一行标题后的内容作为预览
* @param {string} note - 消息内容
* @returns {string} 预览内容
*
* @example
* getItemPreview('标题\n内容第二行\n内容第三行') // 返回: '内容第二行\n内容第三行'
* getItemPreview('只有单行内容') // 返回: '点击查看详情'
*/
const getItemPreview = (note) => {
if (!note) return '点击查看详情'
// 移除第一行(已作为标题显示)
const lines = note.split('\n')
if (lines.length > 1) {
// 移除富文本标签(简单处理)
const preview = lines.slice(1).join('\n').replace(/<[^>]+>/g, '').trim()
return preview || '点击查看详情'
}
return '点击查看详情' // 只有一行时
}
/**
* 获取消息列表
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.