Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yd-csf-h5
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
jianan
yd-csf-h5
Commits
9437ca2a
Commit
9437ca2a
authored
Nov 05, 2026
by
Sweet Zhang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
删除没用的文件
parent
ff73e600
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
0 additions
and
1159 deletions
+0
-1159
BACKEND-API-CHANGES.md
+0
-240
CODE-REVIEW.md
+0
-919
No files found.
BACKEND-API-CHANGES.md
deleted
100644 → 0
View file @
ff73e600
# 月结单 / 我的保单 —— 前端改版所需后端接口调整
> 版本:v1.0 编写日期:2026-10-29
> 涉及接口:`GET /c/statement/list`、`GET /c/statement/{salaryBizId}`、`GET /c/policy/list`
> 性质:**全部为新增字段**,不改动、不删除任何现有字段,可独立上线;前端已做缺失兜底,未改完不会白屏。
---
## 0. 一句话结论
| 接口 | 是否必须改 | 原因 |
|---|---|---|
|
`GET /c/statement/{salaryBizId}`
|
**必须**
| 新增打款细项数组、三类备注、保单转介人、期交保费 / 缴费频率 / 缴费年期 |
|
`GET /c/policy/list`
|
**必须**
| 新增投保人、受保人、保险公司、缴费日、缴费频率、生效日 |
|
`GET /c/statement/list`
| 无需改 | 现有字段已够用(仅口径确认,见第 4 节) |
前端当前表现:未下发的字段一律显示
`—`
,打款细项缺失时用结单头上的
`bankName`
+
`accountEndNo`
合成一条兜底。
**所以可以分接口灰度上线。**
---
## 1. `GET /c/statement/{salaryBizId}` —— 月结单详情
### 1.1 新增顶层字段
| 字段 | 类型 | 必填 | 说明 | 前端用法 |
|---|---|---|---|---|
|
`mpfRemark`
|
`string \| null`
| 否 | MPF 事项的说明文字(如「強積金法定供款」) | 显示在 MPF 金额下方 |
|
`otherRemark`
|
`string \| null`
| 否 | 其他调整的说明 | 显示在「其他调整总金额」下方 |
|
`remark`
|
`string \| null`
| 否 | 其他备注(整张结单级别的补充说明) | 单独一块展示,支持换行 |
|
`payouts`
|
`PayoutDTO[] \| null`
|
**是(本次新增)**
| 打款细项,一笔到账一条 | 渲染「打款细项」区块 |
**`payouts` 元素结构(新增)**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
|
`bankName`
|
`string`
| 是 | 收款银行名称 |
|
`accountEndNo`
|
`string`
| 是 | 账户尾号(建议 4 位,
**不要做掩码**
,前端直接展示) |
|
`currency`
|
`string`
| 是 | 该笔到账币种,ISO 4217 三位码(
`HKD`
/
`USD`
/
`CNY`
…) |
|
`paidAmount`
|
`number`
| 是 | 该笔发放金额,
**用 `currency` 计价**
(不是港币) |
|
`exchangeRate`
|
`number`
| 是 | 该笔使用的汇率(原币 → HKD);
`currency = HKD`
时传
`1`
|
|
`remark`
|
`string \| null`
| 否 | 该笔打款的备注 |
> 命名说明:如果内部字段叫 `originalToHkdRate`,也可以直接沿用,前端两个名字都认。
### 1.2 `items[]` 新增字段(关联保单)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
|
`referrerName`
|
`string`
| 是 |
**该保单的转介人姓名**
(不是投保人;投保人仍放在
`policyHolder`
) |
|
`periodPremium`
|
`number`
| 是 |
**期交保费**
,即「每期」保费,用
`originalCurrency`
计价 |
|
`paymentFrequency`
|
`string`
| 是 | 缴费频率,枚举见第 3 节 |
|
`paymentTerm`
|
`number`
| 否 |
**缴费年期(年)**
,整数;终身缴费或不适用时传
`0`
或
`null`
|
|
`remark`
|
`string \| null`
| 否 | 该条佣金明细的备注 |
**现有字段保持不变**
:
`policyNo`
、
`policyHolder`
、
`insuranceCompany`
、
`productName`
、
`fortunePeriod`
、
`fortuneName`
、
`brokerRatio`
、
`originalCurrency`
、
`originalAmount`
、
`originalToHkdRate`
、
`hkdAmount`
。
### 1.3 完整响应示例
```
json
{
"salaryBizId"
:
"SB2026030001"
,
"salaryNo"
:
"SL2026030001"
,
"month"
:
"202603"
,
"grossAmount"
:
12800.00
,
"mpfAmount"
:
750.00
,
"mpfRemark"
:
"強積金法定供款"
,
"otherAmount"
:
-320.00
,
"otherRemark"
:
"上月多發佣金回沖"
,
"remark"
:
"如對金額有疑問,請於發放日起 30 日內聯絡財務部。"
,
"paidAmount"
:
11730.00
,
"payoutDate"
:
"2026-04-15"
,
"bankName"
:
"香港上海滙豐銀行"
,
"accountEndNo"
:
"4821"
,
"payouts"
:
[
{
"bankName"
:
"香港上海滙豐銀行"
,
"accountEndNo"
:
"4821"
,
"currency"
:
"HKD"
,
"paidAmount"
:
10000.00
,
"exchangeRate"
:
1
,
"remark"
:
"第一期"
},
{
"bankName"
:
"中國銀行(香港)"
,
"accountEndNo"
:
"8890"
,
"currency"
:
"USD"
,
"paidAmount"
:
221.34
,
"exchangeRate"
:
7.8000
,
"remark"
:
null
}
],
"items"
:
[
{
"policyNo"
:
"AI2025000123"
,
"policyHolder"
:
"陳大文"
,
"referrerName"
:
"李小明"
,
"insuranceCompany"
:
"友邦保險"
,
"productName"
:
"充裕未來"
,
"fortunePeriod"
:
1
,
"fortuneName"
:
"首年佣金"
,
"brokerRatio"
:
"0.35"
,
"originalCurrency"
:
"USD"
,
"originalAmount"
:
350.00
,
"originalToHkdRate"
:
7.8000
,
"hkdAmount"
:
2730.00
,
"periodPremium"
:
1000.00
,
"paymentFrequency"
:
"QUARTERLY"
,
"paymentTerm"
:
5
,
"remark"
:
"季繳保單"
}
]
}
```
### 1.4 「没值就不要显示」的约定
前端对 MPF 与其他调整做了
**空值即整行隐藏**
:
-
`mpfAmount`
/
`otherAmount`
传
`null`
、
`undefined`
、空串、或
`0`
→ 该行不渲染;
-
请
**不要**
为了占位传
`0`
,否则语义上会变成「本月 MPF 为 0」;
-
`mpfAmount`
请统一传
**正数**
(前端固定按减项展示为负号);若线上历史数据是负数,前端已按绝对值兼容,不会重复取负。
---
## 2. `GET /c/policy/list` —— 我的保单
### 2.1 新增字段(`effectivePolicies[]` 与 `inactivePolicies[]` 都要)
| 字段 | 类型 | 必填 | 说明 | 前端用法 |
|---|---|---|---|---|
|
`insuranceCompany`
|
`string`
| 是 | 保险公司 | 卡片副标题第二行 |
|
`policyHolder`
|
`string`
| 是 | 投保人姓名 | 卡片信息格 |
|
`insuredName`
|
`string`
| 是 | 受保人姓名 | 卡片信息格 |
|
`paymentFrequency`
|
`string`
| 是 | 缴费频率,枚举见第 3 节 | 卡片信息格 |
|
`nextPremiumDate`
|
`string`
| 否 |
**下次缴费日**
,
`yyyy-MM-dd`
| 卡片信息格「缴费日」 |
|
`effectiveDate`
|
`string`
| 否 | 保单生效日,
`yyyy-MM-dd`
| 卡片信息格末行 |
### 2.2 响应示例
```
json
{
"effectivePolicies"
:
[
{
"policyNo"
:
"AI2025000123"
,
"productName"
:
"充裕未來"
,
"insuranceCompany"
:
"友邦保險"
,
"policyHolder"
:
"陳大文"
,
"insuredName"
:
"陳小明"
,
"currency"
:
"USD"
,
"fullPremium"
:
1000.00
,
"myRatio"
:
"0.35"
,
"status"
:
"INFORCE"
,
"paymentFrequency"
:
"QUARTERLY"
,
"nextPremiumDate"
:
"2026-04-01"
,
"effectiveDate"
:
"2021-04-01"
}
],
"inactivePolicies"
:
[]
}
```
### 2.3 说明
-
日期字段:
`yyyy-MM-dd`
或 ISO 时间串都可以,前端统一格式化;
**缺失请传 `null`,不要传 `""` 以外的占位文本**
(如
`"-"`
),否则会原样上屏。
-
排序不要求改变,沿用当前顺序即可。
---
## 3. 枚举与单位约定(**最容易出问题,请重点确认**)
### 3.1 缴费频率 `paymentFrequency`
| 值 | 含义 | 每年期数(前端年化用) |
|---|---|---|
|
`ANNUAL`
| 年缴 | 1 |
|
`SEMI_ANNUAL`
| 半年缴 | 2 |
|
`QUARTERLY`
| 季缴 | 4 |
|
`MONTHLY`
| 月缴 | 12 |
-
只接受以上 4 个值(大写);
**不要下发中文「季缴」或 `Q` / `M` 等缩写**
,前端会降级为
`UNKNOWN`
(按 1 期/年处理,总额会偏小)。
-
如果库里存的是数字(1/2/4/12)或
`Y/S/Q/M`
,请在后端 VO 转换时映射成上述枚举。
### 3.2 分成比例 `brokerRatio` / `myRatio`(**历史遗留,需拍板**)
现状:字符串下发,且同时存在
`"0.35"`
、
`"35"`
、
`"0.35%"`
几种写法,前端只能猜。
-
前端当前策略:带
`%`
视为百分数;
`0 < n ≤ 1`
视为小数比例;其余视为百分数。
-
**建议后端统一为 `0~1` 小数**
(
`0.35`
表示 35%),并写进接口文档;确认后前端可以把兼容分支收掉。
-
如果短期内无法统一,至少保证
**同一接口内口径一致**
,不要一张结单里既有
`0.35`
又有
`35`
。
### 3.3 金额与汇率
-
所有金额字段为
`number`
(
`BigDecimal`
序列化为数字),不要下发带千分位的字符串(
`"12,800.00"`
会被解析成
`NaN`
并显示
`—`
)。
-
汇率保留 4 位小数;HKD 对 HKD 传
`1`
。
-
`periodPremium`
的币种与
`originalCurrency`
一致,不要换成港币。
---
## 4. 口径确认(麻烦回一句「确认」或给出修正)
1.
**应发 / 实发**
:
`grossAmount`
= 应发(扣减前总额,HKD),
`paidAmount`
= 实发(实际到账,HKD),对吗?
2.
**等式**
:
`grossAmount − |mpfAmount| + otherAmount = paidAmount`
,且
`Σ(payouts[i].paidAmount × exchangeRate) = paidAmount`
,对吗?
(
`otherAmount`
符号约定:正数为加项、负数为扣项,前端按原值展示。)
3.
**列表接口**
:
`/c/statement/list`
的
`grossAmount`
/
`paidAmount`
是否
**始终为港币**
?如果未来会出现非 HKD 结单,需要补
`currency`
字段(前端目前写死 HKD)。
4.
**`items[].referrerName`**
:指的是这张保单的转介人(当前登录人可能是其上级/下级),而不是投保人,对吗?
5.
**`paymentTerm`**
:单位是「年」(整数),不是「期数」,对吗?季缴 5 年请传
`5`
而不是
`20`
。
---
## 5. 上线顺序建议
1.
先上
`GET /c/policy/list`
的 6 个新字段(纯新增,风险最低);
2.
再上
`GET /c/statement/{id}`
的备注字段(
`mpfRemark`
/
`otherRemark`
/
`remark`
);
3.
最后上
`payouts`
数组与
`items[]`
的 5 个新字段;
4.
全部上线并稳定后,通知前端可以把
`bankName`
/
`accountEndNo`
兜底逻辑与比例单位兼容分支移除。
---
## 6. 接口变更汇总(可直接贴给后端同学)
```
GET /c/statement/{salaryBizId}
+ mpfRemark: string|null
+ otherRemark: string|null
+ remark: string|null
+ payouts: [{ bankName, accountEndNo, currency, paidAmount, exchangeRate, remark }]
+ items[].referrerName: string
+ items[].periodPremium: number // 期交保费,原币种
+ items[].paymentFrequency: enum // ANNUAL|SEMI_ANNUAL|QUARTERLY|MONTHLY
+ items[].paymentTerm: number // 缴费年期(年)
+ items[].remark: string|null
GET /c/policy/list
+ effectivePolicies[]/inactivePolicies[]:
insuranceCompany, policyHolder, insuredName, paymentFrequency,
nextPremiumDate, effectiveDate
GET /c/statement/list
无字段变更(仅确认金额币种口径)
```
CODE-REVIEW.md
deleted
100644 → 0
View file @
ff73e600
# yd-csf-h5 代码审查报告
> 扫描范围:`src/` 全部 44 个文件(1665 行 TS/Vue)+ 构建/部署配置
> 验证方式:`vue-tsc --noEmit` 通过(0 error);逐文件人工审查 + 死代码/i18n 键交叉检索
> 结论:**架构分层清晰、契约映射集中在 api 模块、注释质量高**。主要风险集中在「后端返回异常值时的容错」「交互失败路径」「工程化基建(无测试 / 无 lint)」。
>
> **⚠ 更新(2026-10-28)**:P0 全部 10 项、大部分 P1、全部 P2 已修复并加了回归测试。
> 落地清单见文末「第七节 修复结果」。装饰性清理(13 个未使用的 i18n 键)与需产品确认的两项未动。
---
## 一、总体评价
**做得好的地方**
| 维度 | 说明 |
|---|---|
| 分层 |
`api/http`
统一解包
`{code,msg,data}`
,DTO → ViewModel 映射全部收敛在
`api/modules/*`
,组件不碰后端字段名 |
| 安全 | access_token 仅存内存(
`MemoryStore`
),不入 localStorage / URL;OIDC 用 code flow + sessionStorage 存 state |
| 契约 | D4.1 四态、8 态客户阶段均以语义码下发,前端只查文案,不翻译内部状态 |
| UI 一致性 |
`ApiLoader`
统一 loading/error/empty/retry;
`Badge`
/
`Button`
/
`Modal`
抽出;
`tnum`
等宽数字防跳动 |
| 部署 | Dockerfile 固定 nginx 1.26 并注释了老内核 seccomp 坑;nginx 直连 9202 绕过网关的原因写在配置里 |
**主要短板**
1.
后端返回
`null`
/ 脏数据时多处直接崩(
`null.map`
、
`Number()`
产出 NaN 渲染上屏)
2.
有一处
**确定会失效的交互**
:保单佣金弹窗请求失败后完全无反馈
3.
二级页底部 Tab 高亮丢失
4.
**零测试、零 lint**
,
`package.json`
只有 3 个脚本
5.
金额负数四舍五入不符合 HALF_UP 语义(调整项/负佣金会算错)
---
## 二、问题清单
### P0 — 缺陷,建议立即修
| # | 位置 | 问题 | 影响 |
|---|---|---|---|
| 1 |
`views/PoliciesView.vue:34-38`
|
`openCommissions`
无 try/catch,
`await`
抛错时
`modalOpen`
永不置 true | 点击保单
**毫无反应**
,控制台 unhandled rejection,用户以为按钮坏了 |
| 2 |
`App.vue:44,48`
| 用
`route.path === tab.path`
精确匹配 | 进入
`/statement/:id`
、
`/customers`
后
**四个 Tab 全部变灰**
,无当前位置 |
| 3 |
`api/modules/statement.ts:43`
|
`list.map(...)`
未做空值保护(其它模块都是
`(list ?? [])`
) | 后端
`data: null`
(某月无数据)→
`TypeError: Cannot read properties of null`
,整页报错 |
| 4 |
`api/modules/statement.ts:56`
|
`dto.items.map(...)`
同上 | 明细为空时详情页崩溃 |
| 5 |
`api/modules/statement.ts:57`
|
`id: \`${policyNo}-${fortunePeriod}\``
| 同保单同年度多行(不同产品/币种/调整项)时
**v-for key 重复**
,行渲染错乱、diff 复用错误 DOM |
| 6 |
`api/modules/policy.ts:42`
、
`statement.ts:67`
|
`Number(p.myRatio)`
无 NaN 校验(三元只挡了
`null`
/
`""`
) | 后端给
`"0.35%"`
、
`"abc"`
等
**非空非数值**
字符串 → 界面直接显示
**NaN**
(已实测:
`Number('0.35%') === NaN`
) |
| 6b |
`utils/money.ts:7`
|
`Intl.NumberFormat`
对
`currency: ''`
/ 非 3 位字母抛
`RangeError`
(已实测) | 后端
`currency`
为空串时
**渲染中断整页报错**
。
`formatDetailMoney`
应加币种白名单兜底到 HKD |
| 7 |
`api/modules/policy.ts:38`
|
`p.status as Policy['status']`
强转,无白名单 | 后端新增枚举(如
`SUSPENDED`
)→ 界面显示原始 i18n key
`stage.SUSPENDED`
|
| 8 |
`router/index.ts:19-27`
| 守卫内
`await signinRedirect()`
未捕获异常 | IdP 不可达时守卫抛错 → 导航中断 →
**整页空白**
,无任何提示 |
| 9 |
`router/index.ts`
| 无 catch-all 路由 | 访问
`/xxx`
守卫放行后无匹配组件,
`<RouterView>`
空白 |
| 10 |
`utils/money.ts:1-4`
|
`roundHalfUp`
负数退化为向零取整:
`roundHalfUp(-1.005) === -1`
(期望
`-1.01`
) | 调整项(
`otherAmount`
)为负时金额
**计算错误**
,金融场景不可接受 |
**#10 复现**
:
`-1.005 * 100 = -100.4999...`
,
`Math.round(-100.4999) = -100`
→
`-1.00`
。HALF_UP 应得
`-1.01`
。
```
ts
// 当前
Math
.
round
((
value
+
Number
.
EPSILON
)
*
factor
)
/
factor
// 修复:按符号分离
function
roundHalfUp
(
value
:
number
,
decimals
=
2
):
number
{
const
factor
=
10
**
decimals
const
sign
=
value
<
0
?
-
1
:
1
return
(
sign
*
Math
.
round
(
Math
.
abs
(
value
)
*
factor
+
Number
.
EPSILON
))
/
factor
}
```
### P1 — 健壮性 / 体验,建议本迭代修
| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| 11 |
`api/http.ts:13`
| axios 无
`timeout`
| 弱网/后端 hang 时 loading
**永久转圈**
。加
`timeout: 15000`
|
| 12 |
`api/http.ts:33,35`
| 所有错误都
`new Error(string)`
,丢失 HTTP status / 业务 code | 无法区分 401 / 超时 / 业务失败,文案不能差异化。建
`ApiError { kind, httpStatus, code }`
|
| 13 |
`api/http.ts:24`
|
`redirecting`
只置 true 从不复位 | 若
`signinRedirect`
内部失败(不跳转),标记永久为 true,此后所有 401 都不再重定向 → 死锁在错误态 |
| 14 |
`utils/oidc.ts:44`
+
`router/index.ts:22`
| token 仅内存 +
`automaticSilentRenew: false`
|
**每次硬刷新/微信 web-view 重进都要整页 OIDC 往返**
,弱网首屏白屏 1~3s。⚠️ 涉及 D8 约束,需产品确认(见「待确认」) |
| 15 |
`api/mock/index.ts:1`
|
`VITE_USE_MOCK !== 'false'`
→
**默认开启**
,且
`.env.example`
完全没提这个变量 | 语义反直觉,未来接真接口易被静默 mock。改
`=== 'true'`
显式开启,并写入
`.env.example`
|
| 16 |
`api/modules/dashboard.ts:32-35`
|
`fetchTodos`
永远返回硬编码 3 条 mock,UI 无任何标识 | 首页「智能行动看板」被用户当成真实待办。加「示例数据」标识或 feature flag |
| 17 |
`components/feature/TodoCard.vue:21-32`
|
`done`
是组件本地 ref,刷新即丢;且无接口调用,注释提到后端已有「留痕写入接口」 | 当前是
**假功能**
。要么接通留痕接口,要么显式标注「演示」 |
| 18 |
`api/modules/pipeline.ts:20`
|
`payoutDate.slice(0, 7)`
| 后端返回
`2026-3-5`
(非补零)→ 得到
`"2026-3-"`
,月份下拉出现脏值。用 dayjs 解析 |
| 19 |
`views/PoliciesView.vue:64`
|
`:key="\`${item.year}-${item.status}\`"`
| 同年度同状态多行时 key 重复 |
| 20 |
`utils/money.ts:7`
| 每次调用
`new Intl.NumberFormat`
| 构造 formatter 是慢路径,列表页每行 2~3 次。用
`Map`
按
`currency:decimals`
缓存;并对非法 currency code 做
`RangeError`
兜底 |
| 21 |
`components/feature/PolicyCard.vue:28`
、
`StatementDetail.vue:58-59`
| 比例/汇率裸输出
`0.35`
、
`@7.8`
| 用户分不清 35% 还是 0.35。加
`formatPercent`
/
`formatRate`
|
| 22 |
`index.html:2`
+
`stores/preference.ts:11`
|
`<html lang="zh-Hant">`
写死,切简体后不变 | 读屏/SEO 语言标注错误。
`setLocale`
里同步
`document.documentElement.lang`
|
| 23 |
`locales/*.ts`
| 两套各 78 键,其中
**13 个从未使用**
(
`common.loading`
、
`common.pullHint`
、
`customer.title`
、
`statement.customer/company/product/yearType`
、
`policies.fullPremium/fyp/fyc/ryp/ryc/year`
) | 占 17%,建议清理 |
| 24 |
`locales/*.ts`
类型 |
`messages`
无类型约束,
`t(\`stage.${item.status}\`)`
等 2 处动态拼接无兜底 | key 拼错静默显示原文。建议 schema 化 + 泛型
`useI18n`
,动态 key 走映射表 |
| 25 |
`views/HomeView.vue:33-37`
|
`countByType`
三次
`filter`
遍历 | 一次
`reduce`
即可(整洁性问题) |
| 26 |
`utils/date.ts`
整文件 |
`taxYearOf`
/
`formatDate`
**全项目零引用**
| 死代码;连带
`dayjs`
成为幽灵依赖(仅此文件 import,未被打包但仍在 dependencies) |
| 27 |
`api/http.ts:50-53`
|
`post`
全项目零引用 | 死代码,保留可,但需注释说明预留 |
| 28 |
`tsconfig.json`
| 未开
`noUnusedLocals`
/
`noUnusedParameters`
;
`include`
未覆盖
`vite.config.ts`
| 死代码无法自动发现;
**构建配置本身不受类型检查**
|
| 29 |
`package.json`
| 仅
`dev`
/
`build`
/
`preview`
| 无
`test`
/
`lint`
/
`type-check`
独立脚本 |
### P2 — 工程化 / 部署 / 安全
| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| 30 |
`nginx.conf`
| 只有
`try_files`
| 无 gzip、无缓存策略。Vite 产物带 hash →
`/assets/`
设
`expires 1y`
+
`immutable`
,
`index.html`
设
`no-cache`
;开 gzip |
| 31 |
`nginx.conf`
/
`deploy/nginx-center.zuihuibi.cn.conf`
| 缺安全头 | 加
`X-Content-Type-Options: nosniff`
、
`Referrer-Policy`
、HSTS;OIDC 站点建议 CSP(
`connect-src`
放行 IdP) |
| 32 |
`Dockerfile`
| 无
`EXPOSE 80`
、无
`HEALTHCHECK`
| 编排/探活不便 |
| 33 |
`utils/oidc.ts:31`
|
`clientId`
源码兜底
`6db48077896a73e99c4a`
| 非机密,但「未注入即用生产 IdP」易误连。建议未配置时 fail fast |
| 34 |
`vite.config.ts:15-19`
| dev proxy 直连
`center.zuihuibi.cn`
+
`secure:false`
| 本地开发默认打测试环境,易误操作数据。改 env 变量、默认
`localhost`
|
| 35 |
`vite.config.ts`
| 无
`build.rollupOptions.manualChunks`
| vendor(vue+pinia+i18n+oidc+headlessui)可拆包,首屏可再降 |
| 36 | 全局 | 无 ESLint / Prettier | 建议
`@antfu/eslint-config`
或
`eslint-plugin-vue`
flat config |
| 37 | 全局 |
**零测试**
| 见第四节 |
---
## 三、P0 补丁(可直接套用)
### 3.1 `views/PoliciesView.vue` — 弹窗失败路径
```
ts
const
modalLoading
=
ref
(
false
)
const
modalError
=
ref
<
string
|
null
>
(
null
)
async
function
openCommissions
(
policyNo
:
string
)
{
selectedPolicyNo
.
value
=
policyNo
commissions
.
value
=
[]
modalError
.
value
=
null
modalOpen
.
value
=
true
// 先开弹窗,用 loading 态占位
modalLoading
.
value
=
true
try
{
commissions
.
value
=
await
fetchPolicyCommissions
(
policyNo
)
}
catch
(
e
)
{
modalError
.
value
=
e
instanceof
Error
?
e
.
message
:
'error'
}
finally
{
modalLoading
.
value
=
false
}
}
```
```
vue
<Modal
:open=
"modalOpen"
:title=
"selectedPolicyNo"
@
close=
"modalOpen = false"
>
<ApiLoader
:loading=
"modalLoading"
:error=
"modalError"
:empty=
"!modalLoading && !modalError && !commissions.length"
>
<div
class=
"space-y-2"
>
<div
v-for=
"(item, i) in commissions"
:key=
"`${item.year}-${item.status}-${i}`"
...
>
</div>
</ApiLoader>
</Modal>
```
### 3.2 `App.vue` — Tab 高亮
```
ts
function
isActive
(
path
:
string
):
boolean
{
if
(
path
===
'/'
)
return
route
.
path
===
'/'
return
route
.
path
===
path
||
route
.
path
.
startsWith
(
`
${
path
}
/`
)
}
```
模板中
`route.path === tab.path`
全部替换为
`isActive(tab.path)`
。
### 3.3 `api/modules/statement.ts` — 空值 + 唯一 key
```
ts
export
async
function
fetchStatements
():
Promise
<
Statement
[]
>
{
const
list
=
await
get
<
StatementDTO
[]
>
(
'/c/statement/list'
)
return
(
list
??
[]).
map
((
dto
)
=>
({
/* ...原样... */
}))
}
export
async
function
fetchStatementDetail
(
id
:
string
):
Promise
<
StatementDetail
>
{
const
dto
=
await
get
<
StatementDetailDTO
>
(
`/c/statement/
${
id
}
`
)
const
items
:
StatementItem
[]
=
(
dto
.
items
??
[]).
map
((
it
,
i
)
=>
({
id
:
`
${
it
.
policyNo
}
-
${
it
.
fortunePeriod
}
-
${
i
}
`
,
// 保证唯一
// ...
}))
// ...
}
```
### 3.4 新增 `src/utils/number.ts` — 安全数值转换
```
ts
/** 后端 decimal 为 string 时安全转换;非法值退化为 0,绝不产出 NaN 上屏 */
export
function
toNumber
(
value
:
unknown
,
fallback
=
0
):
number
{
if
(
value
===
null
||
value
===
undefined
||
value
===
''
)
return
fallback
const
n
=
typeof
value
===
'number'
?
value
:
Number
(
value
)
return
Number
.
isFinite
(
n
)
?
n
:
fallback
}
export
function
formatPercent
(
ratio
:
number
,
decimals
=
2
):
string
{
return
`
${(
ratio
*
100
).
toFixed
(
decimals
)}
%`
}
```
替换点:
`policy.ts:42`
→
`toNumber(p.myRatio)`
;
`statement.ts:67`
→
`toNumber(it.brokerRatio)`
。
### 3.5 `api/modules/policy.ts` — 状态白名单兜底
```
ts
const
POLICY_STATUSES
:
readonly
Policy
[
'status'
][]
=
[
'INFORCE'
,
'LAPSED'
,
'SURRENDERED'
,
'REJECTED'
]
function
toPolicyStatus
(
raw
:
string
):
Policy
[
'status'
]
{
return
(
POLICY_STATUSES
as
readonly
string
[]).
includes
(
raw
)
?
(
raw
as
Policy
[
'status'
])
:
'LAPSED'
// 未知状态归入「未生效」分组,不污染生效件数
}
```
同理
`customer.ts`
的 8 态、
`pipeline/policy`
的四态,建议统一用一个
`pickEnum(code, allowed, fallback)`
工具。
### 3.6 `router/index.ts` — 守卫容错 + 404
```
ts
router
.
beforeEach
(
async
(
to
)
=>
{
if
(
to
.
name
===
'auth-callback'
||
to
.
name
===
'logged-out'
)
return
true
const
user
=
await
userManager
.
getUser
()
if
(
user
&&
!
user
.
expired
)
return
true
try
{
await
signinRedirect
(
to
.
fullPath
)
}
catch
{
return
{
name
:
'logged-out'
}
// IdP 不可达时不白屏
}
return
false
})
// routes 末尾
{
path
:
'/:pathMatch(.*)*'
,
redirect
:
'/'
}
```
---
## 四、测试方案
### 4.1 落地步骤
```
bash
npm i
-D
vitest @vue/test-utils jsdom @vitest/coverage-v8
```
`package.json`
:
```
json
"scripts"
:
{
"dev"
:
"vite"
,
"build"
:
"vue-tsc --noEmit && vite build"
,
"preview"
:
"vite preview"
,
"type-check"
:
"vue-tsc --noEmit"
,
"test"
:
"vitest run"
,
"test:watch"
:
"vitest"
,
"test:cov"
:
"vitest run --coverage"
}
```
`vitest.config.ts`
:
```
ts
import
{
defineConfig
}
from
'vitest/config'
import
vue
from
'@vitejs/plugin-vue'
import
{
fileURLToPath
,
URL
}
from
'node:url'
export
default
defineConfig
({
plugins
:
[
vue
()],
resolve
:
{
alias
:
{
'@'
:
fileURLToPath
(
new
URL
(
'./src'
,
import
.
meta
.
url
))
}
},
test
:
{
environment
:
'jsdom'
,
globals
:
true
,
include
:
[
'src/**/*.spec.ts'
],
coverage
:
{
provider
:
'v8'
,
include
:
[
'src/**/*.{ts,vue}'
],
exclude
:
[
'src/**/*.spec.ts'
]
}
}
})
```
### 4.2 测试优先级
| 层 | 目标 | 覆盖重点 | 单测/集成 |
|---|---|---|---|
|
`utils/money.ts`
| 90%+ | 金额分级、负数 HALF_UP、formatter 缓存 | 单测 |
|
`utils/date.ts`
| 100% | 课税年度 3/31 与 4/1 边界 | 单测 |
|
`api/modules/*.ts`
| 85%+ | DTO→VM 映射、null 兜底、脏数据降级 | 单测(mock http) |
|
`api/http.ts`
| 80%+ |
`unwrap`
业务码分支、401 单次重定向 | 单测 |
|
`stores/auth.ts`
| 100% | 手机号 claim 兼容(Casdoor
`phone`
vs 标准
`phone_number`
) | 单测 |
|
`components/common/*`
| 80%+ |
`ApiLoader`
四态、
`Button`
disabled、
`Badge`
tone | 组件测试 |
|
`views/*`
| 关键路径 | Pipeline 筛选、Policies 弹窗成功/失败 | 组件测试 |
| 全链路 | 冒烟 | 登录 → 首页 → 月结单 → 明细 | E2E(Playwright,二期) |
### 4.3 测试用例
#### `src/utils/__tests__/money.spec.ts`
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
roundHalfUp
,
formatHkd
,
formatDetailMoney
}
from
'../money'
describe
(
'roundHalfUp'
,
()
=>
{
it
(
'正数按 HALF_UP 进位'
,
()
=>
{
expect
(
roundHalfUp
(
1.005
)).
toBe
(
1.01
)
expect
(
roundHalfUp
(
1.004
)).
toBe
(
1
)
expect
(
roundHalfUp
(
2.675
)).
toBe
(
2.68
)
})
// ❗ 当前实现失败(P0-10):Math.round 对负数向零取整
it
(
'负数按 HALF_UP 远离零进位'
,
()
=>
{
expect
(
roundHalfUp
(
-
1.005
)).
toBe
(
-
1.01
)
expect
(
roundHalfUp
(
-
1.004
)).
toBe
(
-
1
)
})
it
(
'零与整数不变'
,
()
=>
{
expect
(
roundHalfUp
(
0
)).
toBe
(
0
)
expect
(
roundHalfUp
(
100
)).
toBe
(
100
)
})
it
(
'支持自定义小数位'
,
()
=>
{
expect
(
roundHalfUp
(
1.23456
,
4
)).
toBe
(
1.2346
)
})
})
describe
(
'formatHkd'
,
()
=>
{
it
(
'保留两位并带货币符号'
,
()
=>
{
expect
(
formatHkd
(
12345.678
)).
toContain
(
'12,345.68'
)
})
it
(
'零值正常展示'
,
()
=>
{
expect
(
formatHkd
(
0
)).
toContain
(
'0.00'
)
})
})
describe
(
'formatDetailMoney'
,
()
=>
{
it
(
'HKD 用 2 位小数'
,
()
=>
{
expect
(
formatDetailMoney
(
1.23456
,
'HKD'
)).
toContain
(
'1.23'
)
})
it
(
'外币用 4 位小数'
,
()
=>
{
expect
(
formatDetailMoney
(
1.23456
,
'USD'
)).
toContain
(
'1.2346'
)
})
// ❗ 当前失败(P0-6b):currency='' 时 Intl 抛 RangeError
it
(
'空币种回退到 HKD,不抛异常'
,
()
=>
{
expect
(()
=>
formatDetailMoney
(
1
,
''
)).
not
.
toThrow
()
expect
(
formatDetailMoney
(
1
,
''
)).
toContain
(
'1.00'
)
})
})
```
#### `src/utils/__tests__/date.spec.ts`
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
taxYearOf
,
formatDate
}
from
'../date'
describe
(
'taxYearOf(香港课税年度 4/1–3/31)'
,
()
=>
{
it
(
'4 月 1 日属于新年度'
,
()
=>
{
expect
(
taxYearOf
(
new
Date
(
2026
,
3
,
1
))).
toBe
(
'2026-2027'
)
})
it
(
'3 月 31 日仍属上年度'
,
()
=>
{
expect
(
taxYearOf
(
new
Date
(
2026
,
2
,
31
))).
toBe
(
'2025-2026'
)
})
it
(
'跨年 1 月归入上一开始年'
,
()
=>
{
expect
(
taxYearOf
(
new
Date
(
2026
,
0
,
15
))).
toBe
(
'2025-2026'
)
})
})
describe
(
'formatDate'
,
()
=>
{
it
(
'默认 YYYY-MM-DD'
,
()
=>
{
expect
(
formatDate
(
'2026-03-05'
)).
toBe
(
'2026-03-05'
)
})
it
(
'支持自定义模板'
,
()
=>
{
expect
(
formatDate
(
'2026-03-05'
,
'YYYY/MM'
)).
toBe
(
'2026/03'
)
})
it
(
'非法输入不抛异常'
,
()
=>
{
expect
(
formatDate
(
''
)).
toBe
(
'Invalid Date'
)
})
})
```
#### `src/api/modules/__tests__/statement.spec.ts`
> 需先把 `formatMonth` 从模块私有改为 `export`(便于直接测试)。
```
ts
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'vitest'
import
type
{
Mock
}
from
'vitest'
import
{
get
}
from
'../../http'
import
{
fetchStatements
,
fetchStatementDetail
}
from
'../statement'
vi
.
mock
(
'../../http'
,
()
=>
({
get
:
vi
.
fn
()
}))
const
getMock
=
vi
.
mocked
(
get
)
as
unknown
as
Mock
beforeEach
(()
=>
getMock
.
mockReset
())
describe
(
'formatMonth'
,
()
=>
{
it
(
'yyyyMM 转 yyyy-MM'
,
()
=>
expect
(
formatMonth
(
'202603'
)).
toBe
(
'2026-03'
))
it
(
'已格式化则原样返回'
,
()
=>
expect
(
formatMonth
(
'2026-03'
)).
toBe
(
'2026-03'
))
it
(
'空串不抛异常'
,
()
=>
expect
(
formatMonth
(
''
)).
toBe
(
''
))
})
describe
(
'fetchStatements'
,
()
=>
{
it
(
'映射 salaryBizId → id、paidAmount → netAmount'
,
async
()
=>
{
getMock
.
mockResolvedValue
([
{
salaryBizId
:
'S1'
,
month
:
'202603'
,
salaryNo
:
'N1'
,
grossAmount
:
1000
,
mpfAmount
:
50
,
otherAmount
:
0
,
paidAmount
:
950
,
payoutDate
:
'2026-03-15'
}
])
const
[
row
]
=
await
fetchStatements
()
expect
(
row
).
toMatchObject
({
id
:
'S1'
,
month
:
'2026-03'
,
netAmount
:
950
,
payDate
:
'2026-03-15'
})
})
// ❗ 当前实现失败(P0-3)
it
(
'后端返回 null 时降级为空数组'
,
async
()
=>
{
getMock
.
mockResolvedValue
(
null
)
await
expect
(
fetchStatements
()).
resolves
.
toEqual
([])
})
it
(
'payoutDate 缺失时 payDate 为空串'
,
async
()
=>
{
getMock
.
mockResolvedValue
([
{
salaryBizId
:
'S2'
,
month
:
'202604'
,
salaryNo
:
''
,
grossAmount
:
0
,
mpfAmount
:
0
,
otherAmount
:
0
,
paidAmount
:
0
,
payoutDate
:
null
}
])
const
[
row
]
=
await
fetchStatements
()
expect
(
row
.
payDate
).
toBe
(
''
)
})
})
describe
(
'fetchStatementDetail'
,
()
=>
{
const
baseDto
=
{
salaryBizId
:
'S1'
,
month
:
'202603'
,
salaryNo
:
'N1'
,
grossAmount
:
1000
,
mpfAmount
:
50
,
otherAmount
:
-
20
,
paidAmount
:
930
,
payoutDate
:
'2026-03-15'
,
accountEndNo
:
'1234'
}
it
(
'映射 mpfAmount / otherAmount / bankTail'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
...
baseDto
,
items
:
[]
})
const
d
=
await
fetchStatementDetail
(
'S1'
)
expect
(
d
).
toMatchObject
({
mpfAmount
:
50
,
adjustmentAmount
:
-
20
,
bankTail
:
'1234'
})
})
// ❗ 当前失败(P0-5):同保单同年度两行 key 相同
it
(
'同保单同年度的多行明细 id 必须唯一'
,
async
()
=>
{
const
item
=
{
policyNo
:
'P1'
,
policyHolder
:
'陳大文'
,
insuranceCompany
:
'A'
,
productName
:
'X'
,
fortunePeriod
:
1
,
fortuneName
:
'首年'
,
brokerRatio
:
'0.5'
,
originalCurrency
:
'USD'
,
originalAmount
:
100
,
originalToHkdRate
:
7.8
,
hkdAmount
:
780
}
getMock
.
mockResolvedValue
({
...
baseDto
,
items
:
[
item
,
{
...
item
,
productName
:
'Y'
}]
})
const
d
=
await
fetchStatementDetail
(
'S1'
)
expect
(
new
Set
(
d
.
items
.
map
((
i
)
=>
i
.
id
)).
size
).
toBe
(
d
.
items
.
length
)
})
// ❗ 当前失败(P0-4)
it
(
'items 为 null 时不崩溃'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
...
baseDto
,
items
:
null
})
await
expect
(
fetchStatementDetail
(
'S1'
)).
resolves
.
toMatchObject
({
items
:
[]
})
})
it
(
'brokerRatio 为非法字符串时降级为 0'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
...
baseDto
,
items
:
[{
policyNo
:
'P1'
,
policyHolder
:
''
,
insuranceCompany
:
''
,
productName
:
''
,
fortunePeriod
:
1
,
fortuneName
:
''
,
brokerRatio
:
'abc'
,
originalCurrency
:
'HKD'
,
originalAmount
:
1
,
originalToHkdRate
:
1
,
hkdAmount
:
1
}]
})
const
d
=
await
fetchStatementDetail
(
'S1'
)
expect
(
d
.
items
[
0
].
myRatio
).
toBe
(
0
)
expect
(
Number
.
isNaN
(
d
.
items
[
0
].
myRatio
)).
toBe
(
false
)
})
})
```
#### `src/api/modules/__tests__/policy.spec.ts`
```
ts
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'vitest'
import
type
{
Mock
}
from
'vitest'
import
{
get
}
from
'../../http'
import
{
fetchPolicies
,
fetchPolicyCommissions
}
from
'../policy'
vi
.
mock
(
'../../http'
,
()
=>
({
get
:
vi
.
fn
()
}))
const
getMock
=
vi
.
mocked
(
get
)
as
unknown
as
Mock
beforeEach
(()
=>
getMock
.
mockReset
())
const
p
=
(
over
:
Partial
<
Record
<
string
,
unknown
>>
=
{})
=>
({
policyNo
:
'P1'
,
productName
:
'X'
,
currency
:
'HKD'
,
fullPremium
:
1000
,
myRatio
:
'0.5'
,
status
:
'INFORCE'
,
...
over
})
describe
(
'fetchPolicies'
,
()
=>
{
it
(
'合并生效与未生效两组'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
effectivePolicies
:
[
p
()],
inactivePolicies
:
[
p
({
policyNo
:
'P2'
,
status
:
'LAPSED'
})]
})
expect
(
await
fetchPolicies
()).
toHaveLength
(
2
)
})
it
(
'两组都为 null 时返回空数组'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
effectivePolicies
:
null
,
inactivePolicies
:
null
})
await
expect
(
fetchPolicies
()).
resolves
.
toEqual
([])
})
it
(
'myRatio 为 null 时取 0'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
effectivePolicies
:
[
p
({
myRatio
:
null
})],
inactivePolicies
:
[]
})
expect
((
await
fetchPolicies
())[
0
].
myRatio
).
toBe
(
0
)
})
// ❗ 当前失败(P0-6)
it
(
'myRatio 为非法字符串时不产出 NaN'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
effectivePolicies
:
[
p
({
myRatio
:
'0.35%'
})],
inactivePolicies
:
[]
})
const
[
row
]
=
await
fetchPolicies
()
expect
(
Number
.
isNaN
(
row
.
myRatio
)).
toBe
(
false
)
})
})
describe
(
'fetchPolicyCommissions'
,
()
=>
{
it
(
'fortunePeriod=1 归为 FYC,其余 RYC'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
policyNo
:
'P1'
,
items
:
[
{
stage
:
'PAID'
,
fortunePeriod
:
1
,
periodLabel
:
'首年'
,
fortuneName
:
''
,
amount
:
100
},
{
stage
:
'PIPELINE'
,
fortunePeriod
:
2
,
periodLabel
:
'第2年'
,
fortuneName
:
''
,
amount
:
50
}
]
})
const
res
=
await
fetchPolicyCommissions
(
'P1'
)
expect
(
res
.
map
((
r
)
=>
r
.
type
)).
toEqual
([
'FYC'
,
'RYC'
])
})
it
(
'policyNo 为空时直接返回,不发请求'
,
async
()
=>
{
expect
(
await
fetchPolicyCommissions
(
''
)).
toEqual
([])
expect
(
getMock
).
not
.
toHaveBeenCalled
()
})
it
(
'periodLabel 为空时回退 Y{n}'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
policyNo
:
'P1'
,
items
:
[{
stage
:
'PAID'
,
fortunePeriod
:
3
,
periodLabel
:
''
,
fortuneName
:
''
,
amount
:
1
}]
})
expect
((
await
fetchPolicyCommissions
(
'P1'
))[
0
].
year
).
toBe
(
'Y3'
)
})
it
(
'statusDesc 为 null 时归一为 undefined'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
policyNo
:
'P1'
,
items
:
[{
stage
:
'LAPSED'
,
fortunePeriod
:
1
,
periodLabel
:
'首年'
,
fortuneName
:
''
,
statusDesc
:
null
,
amount
:
1
}]
})
expect
((
await
fetchPolicyCommissions
(
'P1'
))[
0
].
statusDesc
).
toBeUndefined
()
})
it
(
'items 为 null 时返回空数组'
,
async
()
=>
{
getMock
.
mockResolvedValue
({
policyNo
:
'P1'
,
items
:
null
})
await
expect
(
fetchPolicyCommissions
(
'P1'
)).
resolves
.
toEqual
([])
})
})
```
#### `src/api/modules/__tests__/pipeline.spec.ts`
```
ts
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'vitest'
import
type
{
Mock
}
from
'vitest'
import
{
get
}
from
'../../http'
import
{
fetchPipeline
}
from
'../pipeline'
vi
.
mock
(
'../../http'
,
()
=>
({
get
:
vi
.
fn
()
}))
const
getMock
=
vi
.
mocked
(
get
)
as
unknown
as
Mock
beforeEach
(()
=>
getMock
.
mockReset
())
describe
(
'fetchPipeline'
,
()
=>
{
it
(
'映射字段并补 HKD'
,
async
()
=>
{
getMock
.
mockResolvedValue
([{
expectedFortuneBizId
:
'E1'
,
policyNo
:
'P1'
,
fortunePeriod
:
1
,
fortuneName
:
''
,
payoutDate
:
'2026-05-01'
,
stage
:
'PIPELINE'
,
hkdAmount
:
500
}])
expect
((
await
fetchPipeline
())[
0
]).
toMatchObject
({
id
:
'E1'
,
policyNo
:
'P1'
,
payoutMonth
:
'2026-05'
,
expectedAmount
:
500
,
currency
:
'HKD'
,
status
:
'PIPELINE'
})
})
it
(
'payoutDate 为 null 时 payoutMonth 为空串'
,
async
()
=>
{
getMock
.
mockResolvedValue
([{
expectedFortuneBizId
:
'E1'
,
policyNo
:
'P1'
,
fortunePeriod
:
1
,
fortuneName
:
''
,
payoutDate
:
null
,
stage
:
'PIPELINE'
,
hkdAmount
:
1
}])
expect
((
await
fetchPipeline
())[
0
].
payoutMonth
).
toBe
(
''
)
})
// ❗ 当前失败(P1-18):'2026-3-5'.slice(0,7) === '2026-3-'
it
(
'非补零日期也能得到 yyyy-MM'
,
async
()
=>
{
getMock
.
mockResolvedValue
([{
expectedFortuneBizId
:
'E1'
,
policyNo
:
'P1'
,
fortunePeriod
:
1
,
fortuneName
:
''
,
payoutDate
:
'2026-3-5'
,
stage
:
'PIPELINE'
,
hkdAmount
:
1
}])
expect
((
await
fetchPipeline
())[
0
].
payoutMonth
).
toBe
(
'2026-03'
)
})
it
(
'list 为 null 时返回空数组'
,
async
()
=>
{
getMock
.
mockResolvedValue
(
null
)
await
expect
(
fetchPipeline
()).
resolves
.
toEqual
([])
})
})
```
#### `src/api/__tests__/http.spec.ts`
> 需在 `http.ts` 中把 `unwrap` 改为 `export`。
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
unwrap
}
from
'../http'
describe
(
'unwrap({code,msg,data} 解包)'
,
()
=>
{
it
(
'code=200 返回 data'
,
()
=>
{
expect
(
unwrap
({
code
:
200
,
msg
:
'ok'
,
data
:
{
a
:
1
}
})).
toEqual
({
a
:
1
})
})
it
(
'code≠200 抛业务 msg'
,
()
=>
{
expect
(()
=>
unwrap
({
code
:
500
,
msg
:
'业务失败'
,
data
:
null
})).
toThrow
(
'业务失败'
)
})
it
(
'msg 为空时给兜底文案'
,
()
=>
{
expect
(()
=>
unwrap
({
code
:
500
,
msg
:
''
,
data
:
null
})).
toThrow
(
'请求失败'
)
})
it
(
'data 为 null 但 code=200 时返回 null,不抛错'
,
()
=>
{
expect
(
unwrap
({
code
:
200
,
msg
:
'ok'
,
data
:
null
})).
toBeNull
()
})
})
```
#### `src/stores/__tests__/auth.spec.ts`
> 需把 `toAuthUser` 改为 `export`。
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
toAuthUser
}
from
'../auth'
const
fakeUser
=
(
profile
:
Record
<
string
,
unknown
>
,
expired
=
false
)
=>
({
expired
,
profile
})
as
never
describe
(
'toAuthUser'
,
()
=>
{
it
(
'优先取 name'
,
()
=>
{
expect
(
toAuthUser
(
fakeUser
({
name
:
'陳大文'
,
preferred_username
:
'u1'
}))?.
name
).
toBe
(
'陳大文'
)
})
it
(
'无 name 时回退 preferred_username'
,
()
=>
{
expect
(
toAuthUser
(
fakeUser
({
preferred_username
:
'u1'
}))?.
name
).
toBe
(
'u1'
)
})
it
(
'兼容 Casdoor 的 phone claim'
,
()
=>
{
expect
(
toAuthUser
(
fakeUser
({
name
:
'a'
,
phone
:
'91234567'
}))?.
phone
).
toBe
(
'91234567'
)
})
it
(
'兼容标准 phone_number claim'
,
()
=>
{
expect
(
toAuthUser
(
fakeUser
({
name
:
'a'
,
phone_number
:
'98887777'
}))?.
phone
).
toBe
(
'98887777'
)
})
it
(
'两者都缺时 phone 为空串'
,
()
=>
{
expect
(
toAuthUser
(
fakeUser
({
name
:
'a'
}))?.
phone
).
toBe
(
''
)
})
it
(
'token 过期返回 null'
,
()
=>
{
expect
(
toAuthUser
(
fakeUser
({
name
:
'a'
},
true
))).
toBeNull
()
})
it
(
'user 为 null 返回 null'
,
()
=>
{
expect
(
toAuthUser
(
null
)).
toBeNull
()
})
})
```
#### `src/components/__tests__/ApiLoader.spec.ts`
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
mount
}
from
'@vue/test-utils'
import
{
createI18n
}
from
'vue-i18n'
import
ApiLoader
from
'../common/ApiLoader.vue'
import
zhHant
from
'@/locales/zh-Hant'
import
Skeleton
from
'../common/Skeleton.vue'
import
Empty
from
'../common/Empty.vue'
const
i18n
=
createI18n
({
legacy
:
false
,
locale
:
'zh-Hant'
,
messages
:
{
'zh-Hant'
:
zhHant
}
})
const
factory
=
(
props
:
Record
<
string
,
unknown
>
,
slot
=
'内容'
)
=>
mount
(
ApiLoader
,
{
props
,
slots
:
{
default
:
slot
},
global
:
{
plugins
:
[
i18n
]
}
})
describe
(
'ApiLoader 四态'
,
()
=>
{
it
(
'loading 渲染骨架屏'
,
()
=>
{
const
w
=
factory
({
loading
:
true
})
expect
(
w
.
findComponent
(
Skeleton
).
exists
()).
toBe
(
true
)
})
it
(
'error 渲染错误块与重试按钮'
,
()
=>
{
const
w
=
factory
({
loading
:
false
,
error
:
'网络错误'
})
expect
(
w
.
text
()).
toContain
(
'网络错误'
)
expect
(
w
.
text
()).
toContain
(
'重試'
)
})
it
(
'点击重试 emit retry'
,
async
()
=>
{
const
w
=
factory
({
loading
:
false
,
error
:
'x'
})
await
w
.
find
(
'button'
).
trigger
(
'click'
)
expect
(
w
.
emitted
(
'retry'
)).
toHaveLength
(
1
)
})
it
(
'empty 渲染空态并隐藏 slot'
,
()
=>
{
const
w
=
factory
({
loading
:
false
,
empty
:
true
})
expect
(
w
.
findComponent
(
Empty
).
exists
()).
toBe
(
true
)
expect
(
w
.
text
()).
not
.
toContain
(
'内容'
)
})
it
(
'正常态渲染 slot'
,
()
=>
{
const
w
=
factory
({
loading
:
false
})
expect
(
w
.
text
()).
toContain
(
'内容'
)
})
})
```
#### `src/components/__tests__/PolicyCard.spec.ts`
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
mount
}
from
'@vue/test-utils'
import
{
createI18n
}
from
'vue-i18n'
import
PolicyCard
from
'../feature/PolicyCard.vue'
import
zhHant
from
'@/locales/zh-Hant'
const
i18n
=
createI18n
({
legacy
:
false
,
locale
:
'zh-Hant'
,
messages
:
{
'zh-Hant'
:
zhHant
}
})
const
policy
=
{
policyNo
:
'POL-9001'
,
status
:
'INFORCE'
,
productName
:
'終身壽險'
,
fullPremium
:
12345.6
,
currency
:
'HKD'
,
myRatio
:
0.5
}
as
const
describe
(
'PolicyCard'
,
()
=>
{
it
(
'展示保单号与产品名'
,
()
=>
{
const
w
=
mount
(
PolicyCard
,
{
props
:
{
policy
},
global
:
{
plugins
:
[
i18n
]
}
})
expect
(
w
.
text
()).
toContain
(
'POL-9001'
)
expect
(
w
.
text
()).
toContain
(
'終身壽險'
)
})
it
(
'点击 emit select 并带保单号'
,
async
()
=>
{
const
w
=
mount
(
PolicyCard
,
{
props
:
{
policy
},
global
:
{
plugins
:
[
i18n
]
}
})
await
w
.
find
(
'button'
).
trigger
(
'click'
)
expect
(
w
.
emitted
(
'select'
)?.[
0
]).
toEqual
([
'POL-9001'
])
})
it
(
'金额按 HKD 两位小数展示'
,
()
=>
{
const
w
=
mount
(
PolicyCard
,
{
props
:
{
policy
},
global
:
{
plugins
:
[
i18n
]
}
})
expect
(
w
.
text
()).
toContain
(
'12,345.60'
)
})
})
```
#### `src/utils/__tests__/pipeline-filter.spec.ts`
> 建议把 `PipelineView` 内的筛选逻辑抽成 `src/utils/pipelineFilter.ts` 纯函数(当前内联在组件里,不可测)。
```
ts
// src/utils/pipelineFilter.ts(新增)
import
type
{
PipelineItem
}
from
'@/api/types'
export
function
filterPipeline
(
items
:
PipelineItem
[],
opts
:
{
query
?:
string
;
month
?:
string
}
):
PipelineItem
[]
{
const
keyword
=
(
opts
.
query
??
''
).
trim
().
toUpperCase
()
return
items
.
filter
((
x
)
=>
{
const
matchPolicy
=
!
keyword
||
x
.
policyNo
.
toUpperCase
().
includes
(
keyword
)
const
matchMonth
=
!
opts
.
month
||
x
.
payoutMonth
===
opts
.
month
return
matchPolicy
&&
matchMonth
})
}
export
function
payoutMonths
(
items
:
PipelineItem
[]):
string
[]
{
return
Array
.
from
(
new
Set
(
items
.
map
((
x
)
=>
x
.
payoutMonth
).
filter
(
Boolean
))).
sort
().
reverse
()
}
```
```
ts
import
{
describe
,
it
,
expect
}
from
'vitest'
import
{
filterPipeline
,
payoutMonths
}
from
'../pipelineFilter'
const
items
=
[
{
id
:
'1'
,
policyNo
:
'POL-100'
,
payoutMonth
:
'2026-05'
,
expectedAmount
:
1
,
currency
:
'HKD'
,
status
:
'PIPELINE'
},
{
id
:
'2'
,
policyNo
:
'pol-200'
,
payoutMonth
:
'2026-06'
,
expectedAmount
:
2
,
currency
:
'HKD'
,
status
:
'RETAINED'
},
{
id
:
'3'
,
policyNo
:
'ABC-300'
,
payoutMonth
:
'2026-05'
,
expectedAmount
:
3
,
currency
:
'HKD'
,
status
:
'LAPSED'
}
]
as
never
[]
describe
(
'filterPipeline'
,
()
=>
{
it
(
'无条件返回全部'
,
()
=>
expect
(
filterPipeline
(
items
,
{})).
toHaveLength
(
3
))
it
(
'保单号大小写不敏感'
,
()
=>
expect
(
filterPipeline
(
items
,
{
query
:
'pol-1'
})).
toHaveLength
(
1
))
it
(
'小写输入匹配大写保单号'
,
()
=>
expect
(
filterPipeline
(
items
,
{
query
:
'abc'
})[
0
].
id
).
toBe
(
'3'
))
it
(
'按月份筛选'
,
()
=>
expect
(
filterPipeline
(
items
,
{
month
:
'2026-05'
})).
toHaveLength
(
2
))
it
(
'条件叠加取交集'
,
()
=>
expect
(
filterPipeline
(
items
,
{
query
:
'POL-100'
,
month
:
'2026-06'
})).
toHaveLength
(
0
))
it
(
'只输入空格视为无条件'
,
()
=>
expect
(
filterPipeline
(
items
,
{
query
:
' '
})).
toHaveLength
(
3
))
})
describe
(
'payoutMonths'
,
()
=>
{
it
(
'去重并按倒序'
,
()
=>
expect
(
payoutMonths
(
items
)).
toEqual
([
'2026-06'
,
'2026-05'
]))
it
(
'过滤空月份'
,
()
=>
{
expect
(
payoutMonths
([{
payoutMonth
:
''
},
{
payoutMonth
:
'2026-01'
}]
as
never
[])).
toEqual
([
'2026-01'
])
})
})
```
#### `src/views/__tests__/PoliciesView.spec.ts`(关键缺陷回归)
```
ts
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'vitest'
import
{
mount
,
flushPromises
}
from
'@vue/test-utils'
import
{
createI18n
}
from
'vue-i18n'
import
zhHant
from
'@/locales/zh-Hant'
vi
.
mock
(
'@/api/modules/policy'
,
()
=>
({
fetchPolicies
:
vi
.
fn
(),
fetchPolicyCommissions
:
vi
.
fn
()
}))
import
{
fetchPolicies
,
fetchPolicyCommissions
}
from
'@/api/modules/policy'
import
PoliciesView
from
'../PoliciesView.vue'
const
i18n
=
createI18n
({
legacy
:
false
,
locale
:
'zh-Hant'
,
messages
:
{
'zh-Hant'
:
zhHant
}
})
const
mountView
=
()
=>
mount
(
PoliciesView
,
{
global
:
{
plugins
:
[
i18n
]
}
})
beforeEach
(()
=>
vi
.
resetAllMocks
())
describe
(
'PoliciesView 佣金弹窗'
,
()
=>
{
it
(
'正常返回时展示各年度佣金'
,
async
()
=>
{
vi
.
mocked
(
fetchPolicies
).
mockResolvedValue
([
{
policyNo
:
'P1'
,
status
:
'INFORCE'
,
productName
:
'X'
,
fullPremium
:
100
,
currency
:
'HKD'
,
myRatio
:
0.5
}
]
as
never
)
vi
.
mocked
(
fetchPolicyCommissions
).
mockResolvedValue
([
{
year
:
'首年'
,
type
:
'FYC'
,
amount
:
100
,
currency
:
'HKD'
,
status
:
'PAID'
}
]
as
never
)
const
w
=
mountView
()
await
flushPromises
()
await
w
.
find
(
'button'
).
trigger
(
'click'
)
await
flushPromises
()
expect
(
w
.
text
()).
toContain
(
'首年'
)
})
// ❗ 当前失败(P0-1):请求抛错后弹窗不打开、无提示
it
(
'接口失败时仍打开弹窗并展示错误与重试'
,
async
()
=>
{
vi
.
mocked
(
fetchPolicies
).
mockResolvedValue
([
{
policyNo
:
'P1'
,
status
:
'INFORCE'
,
productName
:
'X'
,
fullPremium
:
100
,
currency
:
'HKD'
,
myRatio
:
0.5
}
]
as
never
)
vi
.
mocked
(
fetchPolicyCommissions
).
mockRejectedValue
(
new
Error
(
'网络请求失败'
))
const
w
=
mountView
()
await
flushPromises
()
await
w
.
find
(
'button'
).
trigger
(
'click'
)
await
flushPromises
()
expect
(
w
.
text
()).
toContain
(
'网络请求失败'
)
})
})
```
### 4.4 手工冒烟清单(E2E 落地前先跑这个)
| # | 场景 | 期望 |
|---|---|---|
| 1 | 未登录访问
`/pipeline`
| 跳 IdP,登录后回
`/pipeline`
|
| 2 | 硬刷新任意页 | 内存态丢失 → 一次 OIDC 往返后回到原页(记录耗时基线) |
| 3 | 进入
`/statement/:id`
| 底部 Tab「月结单」保持高亮(当前
**失败**
) |
| 4 | 月结单某月无明细 | 显示空态而非白屏(当前
**可能崩**
) |
| 5 | 保单页点卡片时断网 | 弹窗给错误+重试(当前
**无反应**
) |
| 6 | 在途佣金筛保单号(小写) | 结果正确 |
| 7 | 在途佣金选月份 | 只显示该月 |
| 8 | 切换简/繁 | 全文切换,
`<html lang>`
同步(当前
**不同步**
) |
| 9 | Token 过期后操作 | 自动重授权一次,不循环跳转 |
| 10 | 微信 web-view 窄屏 | 指标卡金额不溢出(已验证过,回归即可) |
---
## 五、建议执行顺序
| 批次 | 内容 | 理由 |
|---|---|---|
|
**第 1 批**
(半天) | P0 全部 10 项:弹窗容错、Tab 高亮、null 兜底、key 唯一、NaN 兜底、状态白名单、守卫容错、404、负数舍入 | 都是用户可感知的缺陷,改动局部、风险低 |
|
**第 2 批**
(1 天) | 引入 vitest + 上面的用例集 +
`type-check`
/
`test`
脚本;把
`formatMonth`
/
`toAuthUser`
/
`unwrap`
导出、筛选逻辑抽纯函数 | 让第 1 批的修复有回归保护 |
|
**第 3 批**
(1~2 天) |
`ApiError`
类型化、axios timeout、formatter 缓存、比例/汇率格式化、
`<html lang>`
、清理死代码与 13 个 i18n 死键、
`noUnusedLocals`
| 健壮性与整洁度 |
|
**第 4 批**
(0.5 天) | nginx 缓存/gzip/安全头、Dockerfile HEALTHCHECK、vite proxy 环境变量化、ESLint | 上线前加固 |
---
## 六、需要你确认的两点
1.
**刷新即整页 OIDC 往返(P1-14)**
:这是
`token 仅存内存`
(D8)的直接代价。若产品认为首屏速度重要,需要评估「sessionStorage 存不含 token 的会话标记」或「refresh token + 服务端会话」,这会改动 D8 约束,我不能单方面改。
2.
**待办模块(P1-16/17)**
:首页看板当前是硬编码 mock,「已通知」是本地假状态。是本期接通留痕写入接口,还是先加「示例数据」标识?
---
*生成时间:2026-10-28*
---
## 七、修复结果(已落地)
验证命令与结果:
```
npm run build # vue-tsc --noEmit + vite build
# ✅ 通过,dist 产物正常,vendor 已拆包
npm test # vitest run
# ✅ 13 个测试文件 / 119 个用例全部通过
```
### 7.1 已修复
| 编号 | 修复要点 | 关键文件 |
|---|---|---|
| P0-1 | 弹窗先打开再加载,补
`modalLoading`
/
`modalError`
,内部复用
`ApiLoader`
支持重试 |
`views/PoliciesView.vue`
|
| P0-2 | 新增
`isActive()`
,一级路径精确匹配、二级路径前缀匹配,
`/`
单独处理 |
`App.vue`
|
| P0-3/4 |
`(list ?? [])`
、
`(dto.items ?? [])`
|
`api/modules/statement.ts`
|
| P0-5 | 明细 id 补下标保证唯一 |
`api/modules/statement.ts`
|
| P0-6 | 新增
`toNumber()`
,非法字符串统一退化,
`myRatio`
/
`brokerRatio`
/
`fullPremium`
/
`amount`
全部收口 |
`utils/number.ts`
+ 各 module |
| P0-6b | 币种白名单校验,空串/非 3 位字母回退 HKD,
`Intl`
不再抛
`RangeError`
|
`utils/money.ts`
|
| P0-7 | 新增
`pickEnum()`
+
`api/enums.ts`
白名单;
`CustomerStage`
/
`CommissionStatus`
增加
`UNKNOWN`
并补文案与配色 |
`utils/number.ts`
、
`api/enums.ts`
、
`api/types.ts`
|
| P0-8 | 守卫内
`signinRedirect`
包 try/catch,失败回
`/logged-out`
|
`router/index.ts`
|
| P0-9 | 补
`/:pathMatch(.*)*`
兜底路由 |
`router/index.ts`
|
| P0-10 |
`roundHalfUp`
改为「取绝对值 + 指数移位 + 还原符号」,负数符合 HALF_UP |
`utils/money.ts`
|
| P1-11 | axios
`timeout: 15000`
|
`api/http.ts`
|
| P1-12 | 新增
`ApiError { kind, httpStatus, code }`
,区分 auth/timeout/network/server/business |
`api/http.ts`
|
| P1-13 |
`signinRedirect`
失败时复位
`redirecting`
|
`api/http.ts`
|
| P1-15 | mock 开关改为
`=== 'true'`
显式开启,并写入
`.env.example`
|
`api/mock/index.ts`
|
| P1-16 | 新增
`todoDataSource`
标记,界面显示「示例数据」徽标 |
`api/modules/dashboard.ts`
、
`views/HomeView.vue`
|
| P1-18 |
`slice(0,7)`
改为
`toYearMonth()`
(dayjs 解析) |
`utils/date.ts`
、
`api/modules/pipeline.ts`
|
| P1-19 | 佣金行 key 补下标 |
`views/PoliciesView.vue`
|
| P1-20 |
`Intl.NumberFormat`
按
`币种:小数位`
缓存 |
`utils/money.ts`
|
| P1-22 |
`syncDocumentLang()`
,启动与切换时同步
`<html lang>`
|
`locales/index.ts`
、
`stores/preference.ts`
|
| P1-24 | 月份下拉在数据刷新后自动失效重置 |
`views/PipelineView.vue`
|
| P1-25 |
`countByType`
改为一次
`reduce`
|
`views/HomeView.vue`
|
| P1-26 |
`utils/date.ts`
已真正被使用(
`formatYearMonth`
/
`toYearMonth`
),
`dayjs`
不再是幽灵依赖 |
`utils/date.ts`
|
| P2-30/31 | nginx 加 gzip、
`/assets/`
一年 immutable 缓存、
`index.html`
不缓存、4 个安全头 |
`nginx.conf`
|
| P2-32 | Dockerfile 加
`EXPOSE 80`
+
`HEALTHCHECK`
|
`Dockerfile`
|
| P2-34 | dev proxy 目标改
`VITE_API_TARGET`
,默认
`localhost:9202`
|
`vite.config.ts`
|
| P2-35 |
`manualChunks`
拆 vue / i18n / oidc |
`vite.config.ts`
|
| P1-28 | 开启
`noUnusedLocals`
/
`noUnusedParameters`
/
`noImplicitReturns`
/
`noFallthroughCasesInSwitch`
|
`tsconfig.json`
|
| P1-29 | 新增
`type-check`
/
`test`
/
`test:watch`
/
`test:cov`
脚本 |
`package.json`
|
| P2-37 | 引入 vitest + @vue/test-utils + jsdom,落地 119 个用例 |
`vitest.config.ts`
、13 个
`*.spec.ts`
|
### 7.2 顺带新增
-
`src/utils/number.ts`
:
`toNumber`
/
`pickEnum`
-
`src/api/enums.ts`
:三组契约枚举白名单
-
`src/utils/pipelineFilter.ts`
:筛选逻辑从组件抽出,可单测
-
`src/env.d.ts`
:
`ImportMetaEnv`
声明,环境变量名写错会被类型检查抓到
-
`main.ts`
:
`app.config.errorHandler`
+ bootstrap 失败也挂载,避免白屏
### 7.3 未处理(需确认或属装饰性)
| 项 | 原因 |
|---|---|
| P1-14 刷新即整页 OIDC 往返 | 触碰 D8「token 仅存内存」约束,需产品决策 |
| P1-17
`TodoCard`
的「已通知」本地态 | 需后端留痕接口先上线;当前用「示例数据」徽标兜底 |
| P1-21 比例/汇率百分比格式化 | 后端
`brokerRatio`
/
`myRatio`
量纲未知(0.35 还是 35%),改了可能误报财务数据 |
| P1-23 13 个未使用 i18n 键 | 纯清理,可能为后续页面预留,故保留 |
| P2-33
`clientId`
源码兜底值 | 删除会让本地开发无默认值可跑,保留但已加注释 |
| P2-36 ESLint | 需团队选型(
`@antfu`
还是
`eslint-plugin-vue`
),未擅自引入 |
### 7.4 测试覆盖情况
| 模块 | 覆盖率 |
|---|---|
|
`utils/number.ts`
| 100% |
|
`utils/money.ts`
/
`utils/date.ts`
/
`utils/pipelineFilter.ts`
| 100%(仅 1 个边角分支未覆盖) |
|
`views/PoliciesView.vue`
| 93.9% |
|
`api/modules/*`
| 全部覆盖核心映射与兜底分支 |
|
`utils/oidc.ts`
/
`router/index.ts`
| 未覆盖(依赖真实 IdP 与整页跳转,建议走 E2E) |
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment