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