Commit 9437ca2a by Sweet Zhang

删除没用的文件

parent 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
无字段变更(仅确认金额币种口径)
```
# 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) |
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment