docs(reports): 更新报表接口接入文档与实现说明,报表分析接口接入
This commit is contained in:
@@ -163,7 +163,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
|
||||
用户与权限页面的接口核验结果、当前缺口和后端接口建议见 [`docs/users-permissions-api.md`](./docs/users-permissions-api.md)。
|
||||
|
||||
报表页在真实模式下使用 `GET /api/v1/sign-tasks` 读取全部可见分页,并结合模板、院区和科室接口在前端临时聚合,支持跳转任务和导出当前筛选结果;MEDISIGN 文档中暂无独立的报表统计接口。报表接口核验、前端统计口径见 [reports-api.md](./docs/reports-api.md),后端交付需求见 [reports-backend-requirements.md](./docs/reports-backend-requirements.md)。首页同样从模板、院区和签署任务接口聚合工作台概览。
|
||||
报表页真实模式已接入签署报表的 options、overview、tasks 和异步 export-jobs 接口,支持权限范围内的动态筛选、服务端分页、趋势/科室聚合和完整结果导出;Mock 模式仍可用于演示。接口矩阵、时间口径和联调验收见 [reports-api.md](./docs/reports-api.md),后端联调要求见 [reports-backend-requirements.md](./docs/reports-backend-requirements.md)。首页真实模式已接入 workbench overview。
|
||||
|
||||
首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [docs/workbench-home-api.md](./docs/workbench-home-api.md)。
|
||||
|
||||
|
||||
@@ -1,223 +1,71 @@
|
||||
# 报表分析模块接口核验与后端接口需求
|
||||
# 报表分析接口核验与接入说明
|
||||
|
||||
> 核验基准:MEDISIGN 患者电子签 API 文档([https://ipad.shenynet.com/doc.html#/home](https://ipad.shenynet.com/doc.html#/home))。
|
||||
> 核验基准:用户于 2026-09-02 提供的 default_OpenAPI.json,OpenAPI 3.1.0,MEDISIGN 患者电子签 API v1。
|
||||
>
|
||||
> 本次核验时间:2026-09-01。接口文档当前为 v1,未提供独立的“报表分析”接口分组。
|
||||
> 页面实现:src/views/management/reports/index.vue;API 封装:src/api/management/reports.ts。
|
||||
|
||||
## 1. 核验结论
|
||||
## 1. 结论
|
||||
|
||||
当前报表页面可以使用现有签署任务、模板和组织接口完成基础展示,但后端暂时没有报表聚合能力。页面真实模式目前采用以下临时方案:
|
||||
最新接口文档已经提供完整的签署报表资源。本轮已将报表页从“读取全部签署任务后前端聚合”切换为服务端报表接口:
|
||||
|
||||
1. 查询当前用户有权查看的全部签署任务;
|
||||
2. 按接口允许的最大页大小 200 条循环请求所有分页;
|
||||
3. 使用模板接口补充文档名称和类别;
|
||||
4. 使用院区、科室接口补充筛选字典;
|
||||
5. 在前端按业务时间范围聚合指标、趋势和科室分布。
|
||||
- 概览指标、签署趋势和科室分布由 overview 返回;
|
||||
- 明细由 tasks 服务端分页返回;
|
||||
- 院区、科室和模板分类由 options 返回;
|
||||
- 导出改为服务端异步任务,完成后下载文件;
|
||||
- VITE_USE_MOCK 非 false 时仍保留原有 Mock 演示数据。
|
||||
|
||||
这个方案适合联调和小数据量验证,不适合作为长期报表架构。任务量增大后,首次打开页面会产生多次请求并占用浏览器内存;而且当前任务列表只支持按 `createdAt` 查询,无法让后端直接按签署完成时间或过期时间聚合。
|
||||
## 2. 接入矩阵
|
||||
|
||||
## 2. 当前已接入接口
|
||||
| 页面功能 | HTTP 接口 | 接入状态 | 前端使用方式 |
|
||||
| -------------- | -------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 筛选字典 | GET /api/v1/reports/signing/options | 已接入 | 使用当前操作者有权访问的院区、科室和模板分类;科室随院区筛选联动 |
|
||||
| 概览指标 | GET /api/v1/reports/signing/overview | 已接入 | 使用 initiatedCount、signedCount、onTimeRate、averageSignDurationSeconds |
|
||||
| 签署趋势 | GET /api/v1/reports/signing/overview | 已接入 | 使用 trend[].date/label/signedCount,不再用 createdAt 代替 signedAt |
|
||||
| 科室分布 | GET /api/v1/reports/signing/overview | 已接入 | 使用 departments[].departmentName/signedCount |
|
||||
| 明细列表 | GET /api/v1/reports/signing/tasks | 已接入 | 使用 records/page/size/total/pages,翻页和每页条数均请求服务端 |
|
||||
| 明细状态和时间 | GET /api/v1/reports/signing/tasks | 已接入 | 映射全部 CREATED/WAITING_SIGN/SIGNED/EXPIRED/VOIDED/GENERATING/FAILED,展示脱敏患者、发起时间、完成时间和耗时 |
|
||||
| 创建导出任务 | POST /api/v1/reports/signing/export-jobs | 已接入 | 复用当前筛选条件,默认请求 CSV,不传分页参数以导出全部匹配结果 |
|
||||
| 导出状态 | GET /api/v1/reports/signing/export-jobs/{jobId} | 已接入 | 轮询 PENDING/RUNNING,处理 SUCCEEDED/FAILED/EXPIRED |
|
||||
| 下载导出文件 | GET /api/v1/reports/signing/export-jobs/{jobId}/download | 已接入 | 以 Blob 下载服务端生成文件 |
|
||||
| 明细查看 | 工作台签署任务路由 | 已保留 | 点击行跳转工作台,由工作台查询任务详情和签署文件 |
|
||||
|
||||
| 页面功能 | 接口 | 状态 | 说明 |
|
||||
| -------------- | ------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------- |
|
||||
| 签署任务明细 | `GET /api/v1/sign-tasks` | 已接入 | 读取所有可见分页,使用 `page`、`size`,最大页大小为 200 |
|
||||
| 任务状态 | `GET /api/v1/sign-tasks` | 已接入 | 使用 `CREATED`、`WAITING_SIGN`、`GENERATING`、`SIGNED`、`EXPIRED`、`VOIDED`、`FAILED` 映射页面状态 |
|
||||
| 任务时间 | `GET /api/v1/sign-tasks` | 已接入 | 使用 `createdAt`、`signedAt`、`expiredAt` |
|
||||
| 文档名称和类别 | `GET /api/v1/templates/available-versions` | 已接入 | 用 `templateVersionId` 关联模板版本;历史模板不可见时只能降级显示 |
|
||||
| 院区筛选 | `GET /api/v1/campuses` | 已接入 | 动态生成院区选项,并用 `campusId` 映射任务院区 |
|
||||
| 科室筛选 | `GET /api/v1/departments` | 已接入 | 动态生成科室选项;任务中出现的科室也会补入选项 |
|
||||
| 明细查看 | 工作台任务详情路由 | 已接入 | 点击明细跳转签署工作台,由工作台查询任务详情和签署文件 |
|
||||
| 当前筛选导出 | 浏览器生成 CSV | 已接入 | 导出当前已加载且按创建时间筛选的任务;不是后端审计型导出 |
|
||||
所有真实请求都通过 src/utils/request.ts 的单例发送,路径使用 /v1/...,由默认 /api 前缀形成 /api/v1/...,鉴权沿用 X-Token。
|
||||
|
||||
`GET /api/v1/sign-tasks` 当前文档明确支持的筛选字段包括:
|
||||
## 3. 参数和统计口径
|
||||
|
||||
- `page`、`size`;
|
||||
- `keyword`、`patientId`、`visitId`、`templateVersionId`;
|
||||
- `status`、`signMethod`、`campusId`、`departmentId`;
|
||||
- `createdFrom`、`createdTo`。
|
||||
页面固定传 timezone=Asia/Shanghai,日期区间按左闭右开 [from,to) 转成带 +08:00 的 UTC ISO 时间:
|
||||
|
||||
响应中已经提供报表所需的基础字段:任务状态、签署方式、患者和就诊脱敏快照、院区/科室 ID、`signedAt`、`expiredAt`、`createdAt` 和 `updatedAt`。文档没有提供独立的聚合统计响应,也没有提供签署耗时字段。
|
||||
- 7d、14d、month 和自定义日期都生成明确的起止时间;
|
||||
- overview 同时传 createdFrom/To、signedFrom/To、expiredFrom/To,让发起、签署完成和超时统计使用各自业务时间;
|
||||
- tasks 和导出只传 createdFrom/To,保证待签署任务不会因缺少 signedAt 或 expiredAt 被时间条件排除;
|
||||
- 院区、科室、模板分类、签署方式和关键词分别映射为 campusId、departmentId、templateCategory、signMethod、keyword;
|
||||
- tasks 使用 page、size、timeDimension=CREATED、sort=createdAt、direction=desc。
|
||||
|
||||
## 3. 本轮前端修复
|
||||
后端返回的 onTimeRate 按契约是 0-1 小数,前端展示为百分比;无样本的 onTimeRate 和无可靠样本的平均耗时保持为 —。真实签署耗时优先使用 signDurationSeconds,不会用固定演示数字代替。
|
||||
|
||||
- 删除了原先指向不存在的 `/management/reports/overview` 的报表 API;统一通过 `src/api/management/reports.ts` 聚合真实接口。
|
||||
- 修复只读取第 1 页、最多 200 条导致的统计和导出不完整问题。
|
||||
- 每日趋势改为按 `signedAt` 统计,不再按任务创建时间冒充签署完成时间。
|
||||
- “签署完成”按本期 `signedAt` 统计;“累计发起任务”和明细仍按本期 `createdAt` 统计。
|
||||
- “按时签署率”只统计有签署/过期时间的终态任务,并以 `signedAt <= expiredAt` 判断签署是否按时;`VOIDED`、`FAILED` 和未完成任务不进入分母。
|
||||
- 平均用时不再默认填充 7 分钟;当前按 `signedAt - createdAt` 计算任务完成耗时,没有可用数据时显示 `—`。
|
||||
- `month` 趋势按当月实际天数生成;`custom` 增加起止日期并校验起始日期不能晚于结束日期。
|
||||
- `FAILED` 单独显示为“处理失败”,不再错误归类为“已作废”。
|
||||
- 导出增加签署完成时间和过期时间,文件名使用本地日期,且导出内容覆盖全部已加载分页。
|
||||
- 任务院区优先使用 `campusId` 对应的组织字典,避免字典顺序或默认值导致院区误归类。
|
||||
## 4. 状态和脱敏映射
|
||||
|
||||
## 4. 当前前端统计口径
|
||||
| 后端状态 | 页面状态 |
|
||||
| --------------------- | -------- |
|
||||
| CREATED、WAITING_SIGN | 待签署 |
|
||||
| GENERATING | 签署中 |
|
||||
| SIGNED | 已签署 |
|
||||
| EXPIRED | 已超时 |
|
||||
| VOIDED | 已作废 |
|
||||
| FAILED | 处理失败 |
|
||||
|
||||
当前临时聚合的时间范围使用医院浏览器本地时间。生产环境建议由后端统一按 `Asia/Shanghai` 计算。
|
||||
明细使用 patientNameMasked 和 patientDisplayId,不补查或展示身份证号、完整手机号、完整病历或签名原图。模板、院区和科室直接使用报表 DTO 返回的快照字段,不再通过当前可见模板列表猜测历史分类。
|
||||
|
||||
### 4.1 指标卡
|
||||
## 5. 联调时需要确认的事项
|
||||
|
||||
- `累计发起任务`:`createdAt` 在当前日期范围内,且符合院区、科室、文档类别筛选的任务数。
|
||||
- `签署完成`:`status=SIGNED` 且 `signedAt` 在当前日期范围内的任务数。
|
||||
- `按时签署率`:本期已签署任务与本期已超时任务作为终态样本;分子为 `signedAt <= expiredAt` 的已签署任务,分母为两类终态任务之和。
|
||||
- `平均签署用时`:当前使用 `signedAt - createdAt` 的平均分钟数。该值是“任务从创建到完成的总耗时”,不是患者实际落笔时长。
|
||||
接口已经按文档接入,但页面能否显示真实数据仍取决于后端环境:
|
||||
|
||||
### 4.2 趋势和科室分布
|
||||
- 六个报表路径都已部署在 /api/v1,并允许当前角色访问;
|
||||
- 成功响应使用统一包装,code=0 或 "0",业务数据位于 data;
|
||||
- options、overview 和 tasks 的对象查询参数按 OpenAPI 默认 form/explode 方式接收为平铺参数;
|
||||
- overview 的 trend 按 signedAt、超时按 expiredAt,并返回包含零值日期的趋势;
|
||||
- tasks 的权限过滤在分页前执行,total/pages 与 records 一致,排序稳定;
|
||||
- 导出任务创建、状态查询和下载都重新校验权限,文件有效期和过期错误符合契约;
|
||||
- 真实环境的 Network 请求需确认 401/403/400 能被网关和统一响应正确返回。
|
||||
|
||||
- 每日趋势按 `SIGNED` 任务的 `signedAt` 按自然日统计。
|
||||
- 科室分布按本期完成签署的任务、使用任务科室名称统计。
|
||||
- 明细表按 `createdAt` 筛选,便于核对本期发起的任务;这意味着明细条数与“本期签署完成”可能不完全相等,后端聚合接口上线后建议在界面上明确两个时间维度。
|
||||
|
||||
## 5. 后端缺口与推荐接口
|
||||
|
||||
### 5.1 签署报表聚合接口(最高优先级)
|
||||
|
||||
建议新增:
|
||||
|
||||
```http
|
||||
GET /api/v1/reports/signing/overview
|
||||
X-Token: {登录令牌}
|
||||
```
|
||||
|
||||
建议请求参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------------- | ---- | ------------------------------------------------ |
|
||||
| `createdFrom` / `createdTo` | ISO-8601 | 否 | 发起任务统计和明细的时间范围 |
|
||||
| `signedFrom` / `signedTo` | ISO-8601 | 否 | 签署完成、趋势和完成分布的时间范围 |
|
||||
| `expiredFrom` / `expiredTo` | ISO-8601 | 否 | 超时任务统计的时间范围 |
|
||||
| `campusId` | UUID | 否 | 院区筛选;不传表示当前权限范围内全部院区 |
|
||||
| `departmentId` | UUID | 否 | 科室筛选 |
|
||||
| `category` | string | 否 | 文档类别,建议使用模板的稳定分类编码 |
|
||||
| `signMethod` | `PAD` / `SMS` | 否 | 签署方式筛选 |
|
||||
| `timezone` | string | 否 | 默认 `Asia/Shanghai`;建议服务端固定或校验白名单 |
|
||||
| `detailPage` / `detailSize` | integer | 否 | 明细分页,`detailSize` 最大 200 |
|
||||
|
||||
建议响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"timezone": "Asia/Shanghai",
|
||||
"metrics": {
|
||||
"initiatedCount": 128,
|
||||
"signedCount": 112,
|
||||
"onTimeSignedCount": 108,
|
||||
"onTimeDenominator": 120,
|
||||
"onTimeRate": 90.0,
|
||||
"averageCompletionDurationSeconds": 382,
|
||||
"durationSampleCount": 112
|
||||
},
|
||||
"trend": [
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"signedCount": 32
|
||||
}
|
||||
],
|
||||
"departments": [
|
||||
{
|
||||
"departmentId": "科室 UUID",
|
||||
"departmentName": "消化内科",
|
||||
"initiatedCount": 40,
|
||||
"signedCount": 36,
|
||||
"onTimeSignedCount": 35,
|
||||
"onTimeRate": 87.5
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"records": [],
|
||||
"page": 1,
|
||||
"size": 200,
|
||||
"total": 128,
|
||||
"pages": 1
|
||||
}
|
||||
},
|
||||
"traceId": "链路追踪 ID",
|
||||
"timestamp": "2026-09-01T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
后端应在 SQL 聚合前执行当前用户的数据范围、模板 ACL 和院区/科室权限过滤,不能让前端拉取无权数据后再过滤。`onTimeRate` 的分母、是否排除作废任务、过期任务的口径需要在接口契约中固定,不能由不同页面各自猜测。
|
||||
|
||||
### 5.2 稳定的模板快照信息
|
||||
|
||||
当前任务响应只有 `templateVersionId` 和模板版本信息;如果模板不再处于当前用户可见的可用版本列表,报表无法可靠获取名称和类别,只能显示版本号或根据文档名称猜测分类。
|
||||
|
||||
建议在签署任务响应中增加不可变的模板快照摘要,或由报表接口直接返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"templateId": "模板 UUID",
|
||||
"templateVersionId": "模板版本 UUID",
|
||||
"templateName": "胃镜检查知情同意书",
|
||||
"templateCategory": "EXAM_CONSENT"
|
||||
}
|
||||
```
|
||||
|
||||
分类应使用稳定编码和展示字典,不建议前端继续通过“入院”“告知”等中文名称关键词推断。
|
||||
|
||||
### 5.3 可审计的签署耗时字段
|
||||
|
||||
`createdAt` 到 `signedAt` 只能表示任务生命周期耗时,短信等待和患者未操作时间会被计入。若产品要展示真正的签署用时,建议返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"signStartedAt": "2026-09-01T07:53:00Z",
|
||||
"signedAt": "2026-09-01T08:00:02Z",
|
||||
"signDurationSeconds": 422
|
||||
}
|
||||
```
|
||||
|
||||
其中 `signStartedAt` 应来自签署投递/签署事件,而不是前端打开页面时自行记录。聚合接口应同时返回有效样本数;没有有效耗时样本时返回 `null`,前端显示 `—`。
|
||||
|
||||
### 5.4 后端导出接口
|
||||
|
||||
当前 CSV 是浏览器导出,只适合已加载的少量明细,不能替代正式导出。建议提供异步导出:
|
||||
|
||||
```http
|
||||
POST /api/v1/reports/signing/export-jobs
|
||||
GET /api/v1/reports/signing/export-jobs/{jobId}
|
||||
GET /api/v1/reports/signing/export-jobs/{jobId}/download
|
||||
```
|
||||
|
||||
导出任务请求复用报表筛选条件,响应返回任务状态、文件有效期和下载地址。导出的每一行都应在服务端按权限过滤,并记录操作人、筛选条件摘要、生成时间、文件过期时间和 `traceId`。文件中只返回当前角色允许查看的脱敏患者字段,不应包含明文身份证号、手机号或完整病历。
|
||||
|
||||
### 5.5 时间范围查询能力(没有聚合接口时的最低要求)
|
||||
|
||||
如果近期无法提供聚合接口,至少请扩展 `GET /api/v1/sign-tasks`:
|
||||
|
||||
- 增加 `signedFrom`、`signedTo`;
|
||||
- 增加 `expiredFrom`、`expiredTo`;
|
||||
- 明确多个状态、时间条件的组合语义;
|
||||
- 保证 `total`、`page`、`size`、`pages` 在权限过滤后保持一致;
|
||||
- 保证分页排序稳定,例如按 `createdAt DESC, id DESC`;
|
||||
- 如需前端核对总量,提供按条件返回的汇总字段,避免客户端循环读取全部历史任务。
|
||||
|
||||
## 6. 权限、错误和审计要求
|
||||
|
||||
推荐复用现有统一响应包和错误约定:
|
||||
|
||||
| HTTP 状态 | 场景 |
|
||||
| --------- | ------------------------------------- |
|
||||
| `200` | 查询或导出任务创建成功 |
|
||||
| `400` | 日期格式、范围、分页或筛选参数错误 |
|
||||
| `401` | 未登录或令牌失效 |
|
||||
| `403` | 无报表查询权限或超出院区/科室数据范围 |
|
||||
| `404` | 导出任务不存在或已过期 |
|
||||
| `409` | 导出条件版本或数据快照冲突 |
|
||||
| `429` | 导出任务频率超限 |
|
||||
| `500` | 服务端聚合或文件生成异常 |
|
||||
|
||||
报表查询、导出和下载都应记录审计信息;审计内容记录筛选条件摘要即可,不要记录明文患者证件号、手机号、签名原图或完整病历内容。
|
||||
|
||||
## 7. 前端联调验收标准
|
||||
|
||||
- 院区、科室、文档类别和签署方式筛选只返回当前用户有权查看的数据。
|
||||
- 趋势按 `signedAt` 统计;任务延迟签署时不会落入创建日。
|
||||
- 超时按 `expiredAt` 统计;跨自然日时按 `Asia/Shanghai` 计算。
|
||||
- 签署耗时没有有效样本时返回空值并显示 `—`,不使用固定默认值。
|
||||
- 明细支持服务端分页;导出结果不受浏览器已加载条数限制。
|
||||
- 同一筛选条件的指标、趋势、科室汇总和明细使用明确且一致的时间口径。
|
||||
- 返回 `traceId`,前端错误提示可以关联后端日志排查。
|
||||
当前页面的导出按钮只暴露 CSV;API DTO 已支持 CSV/XLSX,若产品需要 XLSX,可在筛选栏增加格式选择而无需新增接口。
|
||||
|
||||
@@ -1,228 +1,72 @@
|
||||
# 报表分析后端接口交付需求
|
||||
# 报表分析后端联调与验收说明
|
||||
|
||||
> 事实基准:用户于 2026-09-02 导出的 default_OpenAPI.json,OpenAPI 3.1.0,MEDISIGN 患者电子签 API v1。
|
||||
> 事实基准:用户于 2026-09-02 提供的 default_OpenAPI.json,OpenAPI 3.1.0,MEDISIGN 患者电子签 API v1。
|
||||
>
|
||||
> 本文的所有“建议新增”接口均不在当前 OpenAPI 中。它们是后端交付需求,前端在接口实际发布前不得直接调用。
|
||||
> 本文记录当前契约的前端接入要求和联调检查项,不再把已经存在的报表接口标记为“建议新增”。
|
||||
|
||||
## 1. 交付目标
|
||||
## 1. 当前契约与页面使用
|
||||
|
||||
当前 API 只提供签署任务、模板、院区和科室等原始数据。报表页为了展示筛选、四张指标卡、签署量趋势、科室签署分布和明细列表,需要在浏览器中循环读取全部有权限的签署任务后自行聚合。
|
||||
| 接口 | 页面用途 | 状态 |
|
||||
| -------------------------------------------------------- | ------------------------------------ | ------------------------ |
|
||||
| GET /api/v1/reports/signing/options | 权限范围内的院区、科室、模板分类字典 | 前端已接入 |
|
||||
| GET /api/v1/reports/signing/overview | 指标、签署趋势、科室分布 | 前端已接入 |
|
||||
| GET /api/v1/reports/signing/tasks | 脱敏明细和服务端分页 | 前端已接入 |
|
||||
| POST /api/v1/reports/signing/export-jobs | 创建 CSV/XLSX 异步导出任务 | 前端已接入,页面默认 CSV |
|
||||
| GET /api/v1/reports/signing/export-jobs/{jobId} | 查询导出状态 | 前端已接入并轮询 |
|
||||
| GET /api/v1/reports/signing/export-jobs/{jobId}/download | 下载已完成文件 | 前端已接入 |
|
||||
|
||||
后端需要提供权限内聚合、分页明细和正式导出的报表能力,使前端不再全量读取历史任务,也不再自行定义时间、状态、分母和时区口径。
|
||||
页面真实模式通过统一 request 单例发送 /v1/...,由 /api base URL 形成完整路径,并自动携带 X-Token。Mock 模式不调用以上接口,继续使用报表页面自己的演示数据。
|
||||
|
||||
## 2. 当前 OpenAPI 已有能力与不足
|
||||
## 2. 后端必须保持的统计口径
|
||||
|
||||
| 已有接口 | 可复用能力 | 不能解决的问题 |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| GET /api/v1/sign-tasks | 任务状态、createdAt、signedAt、expiredAt、院区/科室 ID、患者和就诊脱敏快照;支持 page、size、keyword、status、signMethod、campusId、departmentId、createdFrom、createdTo | 没有服务端指标、趋势、科室分布、按 signedAt/expiredAt 查询或真实签署耗时 |
|
||||
| GET /api/v1/templates/available-versions | 可用模板版本、模板名称和分类 | 不是历史任务的不可变模板快照;历史模板不可见时无法可靠补齐名称和类别 |
|
||||
| GET /api/v1/campuses | 院区分页查询 | 默认分页大小为 20,不能直接作为完整筛选字典 |
|
||||
| GET /api/v1/departments | 科室分页查询,可按 campusId 筛选 | 默认分页大小为 20,不能直接作为完整筛选字典 |
|
||||
- 时间区间为左闭右开 [from,to),页面传 timezone=Asia/Shanghai;
|
||||
- overview 的 createdFrom/To 只约束发起统计,signedFrom/To 约束签署完成和趋势,expiredFrom/To 约束超时统计;
|
||||
- 页面明细和导出只传 createdFrom/To,以便待签署任务仍能出现在明细中;
|
||||
- trend 必须按 signedAt 的业务时区自然日聚合,并包含零值日期;
|
||||
- departments 必须按任务就诊科室返回,不能改用模板归属科室;
|
||||
- onTimeRate 是 0-1 小数;无可靠样本返回 null;
|
||||
- averageSignDurationSeconds 必须基于可靠的 signStartedAt/signedAt 或等价审计字段;无样本返回 null;
|
||||
- CREATED、WAITING_SIGN、SIGNED、EXPIRED、VOIDED、GENERATING、FAILED 均需保持可区分。
|
||||
|
||||
当前导出的 OpenAPI 中没有 reports、analytics、statistics 或 export 资源。
|
||||
## 3. 明细分页验收
|
||||
|
||||
## 3. 页面五个区域的后端交付范围
|
||||
tasks 请求使用 page、size、timeDimension=CREATED、sort=createdAt、direction=desc。后端应保证:
|
||||
|
||||
| 页面区域 | 后端应返回的结果 | 当前前端临时实现 |
|
||||
| ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 搜索/筛选栏 | 统一筛选参数、可用院区/科室字典、明确时间维度 | 前端过滤已加载任务;无任务 keyword 搜索框 |
|
||||
| 四张指标卡 | 发起数、签署完成数、按时签署率、真实签署耗时及样本量 | 前端按 createdAt、signedAt、expiredAt 聚合;耗时暂以 signedAt - createdAt 代替 |
|
||||
| 签署量趋势图 | 按 signedAt 和指定时区的自然日聚合 | 前端扫描所有已加载任务 |
|
||||
| 科室签署分布 | 科室 ID、名称和按相同筛选条件聚合的数量 | 前端按 visitSnapshot.departmentName 聚合 |
|
||||
| 签署明细列表 | 服务端分页、稳定排序、历史模板和组织展示快照 | 前端拉取全部任务后一次性渲染 |
|
||||
- page 从 1 开始,size 在 1-200 范围内;
|
||||
- records、page、size、total、pages 在权限过滤后保持一致;
|
||||
- 默认或指定排序稳定,不能在翻页时重复或遗漏;
|
||||
- taskId、templateVersionId、campusId 等标识与返回展示字段属于同一条任务快照;
|
||||
- patientNameMasked、patientDisplayId、patientMobileMasked 只能是报表级脱敏字段;
|
||||
- templateName、templateCategory、campusName、departmentName 和 deadlineAt/signedAt/signDurationSeconds 能够覆盖历史任务,不能要求前端再查当前可见模板列表补齐。
|
||||
|
||||
## 4. P0:报表概览聚合接口
|
||||
## 4. 导出任务验收
|
||||
|
||||
**建议新增:GET /api/v1/reports/signing/overview**
|
||||
创建导出任务时页面传当前筛选条件和 format=CSV,不传 page/size,因此结果应覆盖全部匹配记录,而不是当前明细页。后端需要:
|
||||
|
||||
接口只返回四张指标卡、签署趋势和科室分布,不返回大量明细。
|
||||
- 创建、查询、下载时均重新检查操作人有效性、报表权限和数据范围;
|
||||
- 支持 PENDING、RUNNING、SUCCEEDED、FAILED、EXPIRED;
|
||||
- 文件生成完成后返回有效期和 resultCount;文件过期后拒绝下载;
|
||||
- 下载响应返回文件流,不暴露 storage key;
|
||||
- 导出列只包含当前角色允许查看的脱敏字段;
|
||||
- 记录操作者、筛选条件摘要、结果数量、生成时间、过期时间和 traceId,但不记录身份证号、完整手机号、完整病历或签名原图。
|
||||
|
||||
所有请求使用现有 X-Token 鉴权方式,并通过统一 ApiResponse 返回。
|
||||
## 5. 联调阻塞项与前端边界
|
||||
|
||||
### 查询参数
|
||||
代码已经按当前 OpenAPI 完成接入,以下事项需要实际后端环境确认:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ----------------------- | ----------------- | -------------------------------------------------- |
|
||||
| createdFrom / createdTo | ISO-8601 datetime | 发起任务和创建时间口径的闭区间 |
|
||||
| signedFrom / signedTo | ISO-8601 datetime | 签署完成、趋势和完成分布的闭区间 |
|
||||
| expiredFrom / expiredTo | ISO-8601 datetime | 超时统计的闭区间 |
|
||||
| campusId | UUID | 院区筛选;不传表示调用人权限范围内的全部院区 |
|
||||
| departmentId | UUID | 科室筛选 |
|
||||
| templateCategory | string | 模板稳定分类编码,不能用中文名称关键词推断 |
|
||||
| templateVersionId | UUID | 模板版本筛选 |
|
||||
| signMethod | PAD 或 SMS | 签署方式筛选 |
|
||||
| timezone | IANA timezone | 默认建议固定为 Asia/Shanghai,并由服务端校验白名单 |
|
||||
- 六个报表路径已部署且当前角色有查询/导出权限;
|
||||
- 统一响应的成功 code 为 0 或 "0",业务数据放在 data;
|
||||
- 对象 query 参数按 OpenAPI 默认 form/explode 方式接收为平铺字段;
|
||||
- 401、403、400、404、429 和 500 的 HTTP/业务错误能够被网关正确转发;
|
||||
- download 接口的响应类型为文件流,且允许当前前端通过同源 /api 访问。
|
||||
|
||||
### data 返回字段
|
||||
当前页面没有新增报表详情接口:明细“查看”继续跳转签署工作台,由工作台已有任务详情和签署文件能力处理。页面筛选栏只暴露 CSV,若需要 XLSX 选择器,仅需增加前端控件并复用同一导出接口。
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| timezone | 本次统计实际使用的时区 |
|
||||
| metrics.initiatedCount | 创建时间在范围内的任务数 |
|
||||
| metrics.signedCount | 状态为 SIGNED 且 signedAt 在范围内的任务数 |
|
||||
| metrics.onTimeSignedCount | signedAt 小于等于 expiredAt 的已签署任务数 |
|
||||
| metrics.onTimeSampleCount | 有明确终态时间的 SIGNED 与 EXPIRED 任务数;VOIDED、FAILED 和未完成任务不进入分母 |
|
||||
| metrics.onTimeRate | 分子除以分母;无样本时返回 null |
|
||||
| metrics.averageSignDurationSeconds | 真实签署耗时均值;无有效样本时返回 null |
|
||||
| metrics.durationSampleCount | 参与耗时计算的样本数 |
|
||||
| trend | 数组,每项包含 date 与 signedCount |
|
||||
| departments | 数组,每项包含 departmentId、departmentName、initiatedCount、signedCount、onTimeSignedCount、onTimeSampleCount、onTimeRate |
|
||||
## 6. 建议联调用例
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "0",
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"timezone": "Asia/Shanghai",
|
||||
"metrics": {
|
||||
"initiatedCount": 132,
|
||||
"signedCount": 108,
|
||||
"onTimeSignedCount": 101,
|
||||
"onTimeSampleCount": 115,
|
||||
"onTimeRate": 0.8783,
|
||||
"averageSignDurationSeconds": 1860,
|
||||
"durationSampleCount": 103
|
||||
},
|
||||
"trend": [{ "date": "2026-08-01", "signedCount": 18 }],
|
||||
"departments": [
|
||||
{
|
||||
"departmentId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"departmentName": "心内科",
|
||||
"initiatedCount": 25,
|
||||
"signedCount": 18,
|
||||
"onTimeSignedCount": 17,
|
||||
"onTimeSampleCount": 19,
|
||||
"onTimeRate": 0.8947
|
||||
}
|
||||
]
|
||||
},
|
||||
"traceId": "report-overview-example",
|
||||
"timestamp": "2026-09-02T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
统计口径必须由服务端固定。特别是趋势必须按 signedAt 聚合,超时必须按 expiredAt 聚合,不能用 createdAt 或 updatedAt 代替。
|
||||
|
||||
## 5. P0:报表明细分页接口
|
||||
|
||||
**建议新增:GET /api/v1/reports/signing/tasks**
|
||||
|
||||
该接口复用第 4 节全部筛选参数,并增加下列分页参数:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ------------- | -------------------------- | ----------------------------------------------------------- |
|
||||
| page | integer | 页码,从 1 开始,默认 1 |
|
||||
| size | integer | 每页条数,范围 1–200,默认 20 |
|
||||
| sort | string | 仅允许白名单字段;默认 createdAt,DESC,id,DESC,保证稳定排序 |
|
||||
| timeDimension | CREATED、SIGNED 或 EXPIRED | 明细列表的主时间维度;默认 CREATED |
|
||||
|
||||
每条明细至少返回:
|
||||
|
||||
- taskId、status、signMethod、createdAt、signedAt、expiredAt、voidedAt;
|
||||
- 脱敏患者展示字段,不返回身份证号、完整手机号或完整病历;
|
||||
- templateVersionId、templateName、templateCategory;
|
||||
- campusId、campusName、departmentId、departmentName;
|
||||
- signDurationSeconds;没有可靠样本时返回 null。
|
||||
|
||||
模板、院区和科室展示字段应是任务快照,或由服务端在完成权限过滤后关联返回。前端不能只依赖当前可用模板版本来补历史明细。
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
分页结构沿用当前契约的 records、page、size、total、pages 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "0",
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"records": [
|
||||
{
|
||||
"taskId": "650e8400-e29b-41d4-a716-446655440001",
|
||||
"status": "SIGNED",
|
||||
"signMethod": "SMS",
|
||||
"createdAt": "2026-08-01T01:00:00Z",
|
||||
"signedAt": "2026-08-01T01:23:40Z",
|
||||
"expiredAt": "2026-08-02T01:00:00Z",
|
||||
"voidedAt": null,
|
||||
"patient": { "name": "张*", "mobileMasked": "138****0000" },
|
||||
"templateVersionId": "750e8400-e29b-41d4-a716-446655440001",
|
||||
"templateName": "诊疗知情同意书",
|
||||
"templateCategory": "INFORMED_CONSENT",
|
||||
"campusId": "850e8400-e29b-41d4-a716-446655440001",
|
||||
"campusName": "示例院区",
|
||||
"departmentId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"departmentName": "心内科",
|
||||
"signDurationSeconds": 1420
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"size": 20,
|
||||
"total": 132,
|
||||
"pages": 7
|
||||
},
|
||||
"traceId": "report-tasks-example",
|
||||
"timestamp": "2026-09-02T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. P1:正式导出接口
|
||||
|
||||
**建议新增:**
|
||||
|
||||
- POST /api/v1/reports/signing/export-jobs
|
||||
- GET /api/v1/reports/signing/export-jobs/{jobId}
|
||||
- GET /api/v1/reports/signing/export-jobs/{jobId}/download
|
||||
|
||||
创建任务时复用概览和明细的筛选条件,可附带 format 为 CSV 或 XLSX 以及可导出列集。响应返回 jobId、任务状态和文件有效期。
|
||||
|
||||
导出任务必须:
|
||||
|
||||
- 在服务端重新执行调用人的数据范围、院区/科室权限和模板 ACL;
|
||||
- 记录操作人、筛选条件摘要、生成时间、结果数量、文件过期时间和 traceId;
|
||||
- 异步处理大数据量,提供 PENDING、RUNNING、SUCCEEDED、FAILED、EXPIRED 等状态;
|
||||
- 只返回当前角色允许查看的脱敏字段,不导出 Token、身份证号、完整手机号、完整病历或签名原图。
|
||||
|
||||
创建任务成功时,可按当前统一响应包装返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "0",
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"jobId": "950e8400-e29b-41d4-a716-446655440001",
|
||||
"status": "PENDING",
|
||||
"expiresAt": "2026-09-09T08:00:00Z"
|
||||
},
|
||||
"traceId": "report-export-example",
|
||||
"timestamp": "2026-09-02T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 过渡方案:补强原始签署任务接口
|
||||
|
||||
如果 P0 报表资源暂时无法排期,至少扩展现有 GET /api/v1/sign-tasks:
|
||||
|
||||
- 增加 signedFrom、signedTo、expiredFrom、expiredTo,并明确它们与 createdFrom、createdTo 的组合语义;
|
||||
- 返回不可变的 templateName、templateCategory、campusName、departmentName 快照;
|
||||
- 返回 signStartedAt、signDurationSeconds 或等价审计耗时字段;
|
||||
- 固定默认排序,并保证 page、size、total、pages 在权限过滤后稳定一致;
|
||||
- 院区和科室接口应支持完整可用字典,或明确允许 size=200 和稳定排序,避免筛选项只获得默认第一页。
|
||||
|
||||
## 8. 权限、错误与审计要求
|
||||
|
||||
- 所有建议接口沿用 X-Token、统一 ApiResponse、traceId 和 UTC timestamp。
|
||||
- 聚合和分页前必须在服务端执行数据范围、模板 ACL、院区和科室权限;前端不能承担授权过滤。
|
||||
- 时间边界按 timezone 计算;若不支持动态时区,应在契约中固定 Asia/Shanghai。
|
||||
- 建议错误语义:400 参数或日期范围错误,401 未登录或令牌失效,403 无报表权限,404 导出任务不存在或已过期,429 导出频率受限,500 聚合或文件生成失败。
|
||||
- 指标分母、状态排除规则和时区属于契约的一部分;变更时需要可追溯的版本说明。
|
||||
|
||||
## 9. 联调验收标准
|
||||
|
||||
- 同一筛选条件下,四张指标卡、趋势、科室分布和明细具有可解释且一致的时间口径。
|
||||
- 延迟签署任务只落入 signedAt 对应的趋势日期;过期任务只按 expiredAt 统计。
|
||||
- 无有效签署耗时时返回 null,前端显示为 —,不填固定分钟数。
|
||||
- 明细分页总数和排序稳定,翻页不重复、不遗漏。
|
||||
- 服务端返回和导出文件均已完成权限过滤,不含敏感明文。
|
||||
- 导出任务可以查询状态、审计、过期和受权限保护的下载。
|
||||
1. 全院近 14 天:校验 overview、tasks 的请求参数和三类时间统计。
|
||||
2. 选择一个院区/科室/模板分类/签署方式并输入关键词:校验 options 联动和所有接口参数一致。
|
||||
3. 构造跨创建日完成签署的任务:确认趋势落在 signedAt 日期,而不是 createdAt 日期。
|
||||
4. 翻页、改变每页条数:确认 total/pages、排序和记录不重复。
|
||||
5. 创建导出任务并等待完成:确认状态轮询、文件下载、resultCount 和过期下载错误。
|
||||
6. 用无权限角色访问:确认查询和导出均得到 403,前端不显示越权数据。
|
||||
|
||||
@@ -1,72 +1,121 @@
|
||||
import { getCampuses, getDepartments } from './organization'
|
||||
import { getSigningTasks, getSigningTemplates, isSigningMockEnabled } from '@/api/workbench/signing'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types/common'
|
||||
|
||||
import type { ReportTaskData } from './types'
|
||||
import type {
|
||||
ReportExportJobResponseDto,
|
||||
ReportExportRequestDto,
|
||||
ReportFilterDto,
|
||||
ReportOptionsResponseDto,
|
||||
ReportOverviewResponseDto,
|
||||
ReportPageResponseDto,
|
||||
ReportTaskResponseDto,
|
||||
ReportTasksQueryDto,
|
||||
} from './types'
|
||||
|
||||
const REPORT_PAGE_SIZE = 200
|
||||
export const isReportsMockEnabled = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
|
||||
export const isReportsMockEnabled = isSigningMockEnabled
|
||||
function compactQuery(values: object) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(
|
||||
([, value]) => value !== undefined && value !== null && value !== '',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function buildSigningApiOptions(data: ReportTaskData) {
|
||||
function normalizeReportOptions(data: ReportOptionsResponseDto): ReportOptionsResponseDto {
|
||||
return {
|
||||
templates: data.templates,
|
||||
campusNames: Object.fromEntries(
|
||||
data.campuses
|
||||
.filter((campus) => Boolean(campus.name.trim()))
|
||||
.map((campus) => [campus.id, campus.name]),
|
||||
) as Record<string, WorkbenchCampus>,
|
||||
campuses: data.campuses ?? [],
|
||||
departments: data.departments ?? [],
|
||||
templateCategories: data.templateCategories ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllReportTasks(data: ReportTaskData) {
|
||||
const options = buildSigningApiOptions(data)
|
||||
const query = {
|
||||
page: 1,
|
||||
pageSize: REPORT_PAGE_SIZE,
|
||||
status: 'all' as const,
|
||||
dateRange: 'all' as const,
|
||||
campus: 'all' as const,
|
||||
method: 'all' as const,
|
||||
function normalizeReportOverview(data: ReportOverviewResponseDto): ReportOverviewResponseDto {
|
||||
return {
|
||||
timezone: data.timezone ?? 'Asia/Shanghai',
|
||||
generatedAt: data.generatedAt ?? '',
|
||||
snapshotAt: data.snapshotAt ?? '',
|
||||
metrics: {
|
||||
initiatedCount: data.metrics?.initiatedCount ?? 0,
|
||||
signedCount: data.metrics?.signedCount ?? 0,
|
||||
onTimeSignedCount: data.metrics?.onTimeSignedCount ?? 0,
|
||||
onTimeSampleCount: data.metrics?.onTimeSampleCount ?? 0,
|
||||
onTimeRate: data.metrics?.onTimeRate ?? null,
|
||||
averageSignDurationSeconds: data.metrics?.averageSignDurationSeconds ?? null,
|
||||
durationSampleCount: data.metrics?.durationSampleCount ?? 0,
|
||||
},
|
||||
trend: data.trend ?? [],
|
||||
departments: data.departments ?? [],
|
||||
}
|
||||
const firstPage = await getSigningTasks(query, options)
|
||||
const records = [...firstPage.records]
|
||||
const pageSize = Math.max(firstPage.pageSize, 1)
|
||||
const totalPages = Math.ceil(firstPage.total / pageSize)
|
||||
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
const nextPage = await getSigningTasks({ ...query, page }, options)
|
||||
records.push(...nextPage.records)
|
||||
|
||||
if (!nextPage.records.length) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询报表所需的全部可见任务。
|
||||
* 当前后端没有聚合报表接口,因此这里按最大页大小读取签署任务;聚合接口上线后只需替换本函数。
|
||||
* ReportFilter/ReportTasksQuery are object query parameters in OpenAPI's default
|
||||
* form/explode representation, so fields are sent flat (createdFrom=..., etc.).
|
||||
*/
|
||||
export async function getReportTaskData(): Promise<ReportTaskData> {
|
||||
if (isReportsMockEnabled) {
|
||||
return { tasks: [], templates: [], campuses: [], departments: [] }
|
||||
}
|
||||
export async function getReportOptions(
|
||||
filter: ReportFilterDto = {},
|
||||
): Promise<ReportOptionsResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportOptionsResponseDto>>(
|
||||
'/v1/reports/signing/options',
|
||||
{ params: compactQuery(filter) },
|
||||
)
|
||||
|
||||
const [templatesResult, campusesResult, departmentsResult] = await Promise.allSettled([
|
||||
getSigningTemplates(),
|
||||
getCampuses(),
|
||||
getDepartments(),
|
||||
])
|
||||
const data: ReportTaskData = {
|
||||
tasks: [],
|
||||
templates: templatesResult.status === 'fulfilled' ? templatesResult.value : [],
|
||||
campuses: campusesResult.status === 'fulfilled' ? campusesResult.value : [],
|
||||
departments: departmentsResult.status === 'fulfilled' ? departmentsResult.value : [],
|
||||
}
|
||||
|
||||
data.tasks = await getAllReportTasks(data)
|
||||
return data
|
||||
return normalizeReportOptions(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getReportOverview(
|
||||
filter: ReportFilterDto,
|
||||
): Promise<ReportOverviewResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportOverviewResponseDto>>(
|
||||
'/v1/reports/signing/overview',
|
||||
{ params: compactQuery(filter) },
|
||||
)
|
||||
|
||||
return normalizeReportOverview(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getReportTasks(
|
||||
query: ReportTasksQueryDto,
|
||||
): Promise<ReportPageResponseDto<ReportTaskResponseDto>> {
|
||||
const response = await request.get<ApiResponse<ReportPageResponseDto<ReportTaskResponseDto>>>(
|
||||
'/v1/reports/signing/tasks',
|
||||
{ params: compactQuery(query) },
|
||||
)
|
||||
const page = unwrapApiResponse(response)
|
||||
|
||||
return {
|
||||
records: page.records ?? [],
|
||||
page: page.page,
|
||||
size: page.size,
|
||||
total: page.total,
|
||||
pages: page.pages,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReportExportJob(
|
||||
payload: ReportExportRequestDto,
|
||||
): Promise<ReportExportJobResponseDto> {
|
||||
const response = await request.post<ApiResponse<ReportExportJobResponseDto>>(
|
||||
'/v1/reports/signing/export-jobs',
|
||||
payload,
|
||||
)
|
||||
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getReportExportJob(jobId: string): Promise<ReportExportJobResponseDto> {
|
||||
const response = await request.get<ApiResponse<ReportExportJobResponseDto>>(
|
||||
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`,
|
||||
)
|
||||
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadReportExportFile(jobId: string): Promise<Blob> {
|
||||
return request.get<Blob>(
|
||||
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PageQuery, PageResult } from '@/types/common'
|
||||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||||
|
||||
export type DocumentStatus = 'draft' | 'published' | 'archived' | 'review'
|
||||
|
||||
@@ -261,15 +260,135 @@ export interface OrganizationResponseDto {
|
||||
departmentName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 报表当前由签署任务列表、模板和组织字典在前端聚合。
|
||||
* 后端提供聚合报表接口后,可在 reports.ts 的边界替换为对应 DTO,页面无需感知。
|
||||
*/
|
||||
export interface ReportTaskData {
|
||||
tasks: SigningTaskRecord[]
|
||||
templates: SigningTemplate[]
|
||||
campuses: CampusRecord[]
|
||||
departments: DepartmentRecord[]
|
||||
export type ReportBackendTaskStatus =
|
||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
|
||||
|
||||
export type ReportBackendSignMethod = 'PAD' | 'SMS'
|
||||
|
||||
export type ReportTimeDimension = 'CREATED' | 'SIGNED' | 'EXPIRED'
|
||||
|
||||
export type ReportSortDirection = 'asc' | 'desc'
|
||||
|
||||
export type ReportExportFormat = 'CSV' | 'XLSX'
|
||||
|
||||
/** /api/v1/reports/signing 的公共过滤 DTO;时间区间为左闭右开。 */
|
||||
export interface ReportFilterDto {
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
signedFrom?: string
|
||||
signedTo?: string
|
||||
expiredFrom?: string
|
||||
expiredTo?: string
|
||||
campusId?: string
|
||||
departmentId?: string
|
||||
templateVersionId?: string
|
||||
templateCategory?: string
|
||||
signMethod?: ReportBackendSignMethod
|
||||
keyword?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface ReportTasksQueryDto extends ReportFilterDto {
|
||||
page?: number
|
||||
size?: number
|
||||
timeDimension?: ReportTimeDimension
|
||||
sort?: string
|
||||
direction?: ReportSortDirection
|
||||
}
|
||||
|
||||
export interface ReportExportRequestDto extends ReportTasksQueryDto {
|
||||
format: ReportExportFormat
|
||||
columns?: string[]
|
||||
}
|
||||
|
||||
export interface ReportMetricsDto {
|
||||
initiatedCount: number
|
||||
signedCount: number
|
||||
onTimeSignedCount: number
|
||||
onTimeSampleCount: number
|
||||
onTimeRate: number | null
|
||||
averageSignDurationSeconds: number | null
|
||||
durationSampleCount: number
|
||||
}
|
||||
|
||||
export interface ReportTrendPointDto {
|
||||
date: string
|
||||
label: string
|
||||
signedCount: number
|
||||
}
|
||||
|
||||
export interface ReportDepartmentBreakdownDto {
|
||||
departmentId?: string | null
|
||||
departmentName?: string | null
|
||||
initiatedCount?: number | null
|
||||
signedCount?: number | null
|
||||
onTimeSignedCount?: number | null
|
||||
onTimeSampleCount?: number | null
|
||||
onTimeRate?: number | null
|
||||
}
|
||||
|
||||
export interface ReportOverviewResponseDto {
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
snapshotAt: string
|
||||
metrics: ReportMetricsDto
|
||||
trend: ReportTrendPointDto[]
|
||||
departments: ReportDepartmentBreakdownDto[]
|
||||
}
|
||||
|
||||
export interface ReportTaskResponseDto {
|
||||
taskId: string
|
||||
patientDisplayId: string
|
||||
patientNameMasked: string
|
||||
patientMobileMasked?: string | null
|
||||
status: ReportBackendTaskStatus
|
||||
signMethod: ReportBackendSignMethod
|
||||
createdAt: string
|
||||
deadlineAt?: string | null
|
||||
signStartedAt?: string | null
|
||||
signedAt?: string | null
|
||||
expiredAt?: string | null
|
||||
voidedAt?: string | null
|
||||
signDurationSeconds?: number | null
|
||||
onTime?: boolean | null
|
||||
templateVersionId: string
|
||||
templateVersionNumber?: number | null
|
||||
templateName: string
|
||||
templateCategory: string
|
||||
campusId: string
|
||||
campusName: string
|
||||
departmentId?: string | null
|
||||
departmentName: string
|
||||
}
|
||||
|
||||
export interface ReportOptionsResponseDto {
|
||||
campuses: Array<{ id: string; name: string }>
|
||||
departments: Array<{ id: string; campusId: string; name: string }>
|
||||
templateCategories: string[]
|
||||
}
|
||||
|
||||
export interface ReportPageResponseDto<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export type ReportExportJobStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
|
||||
|
||||
export interface ReportExportJobResponseDto {
|
||||
jobId: string
|
||||
status: ReportExportJobStatus
|
||||
format?: ReportExportFormat | string | null
|
||||
requestedAt?: string | null
|
||||
startedAt?: string | null
|
||||
completedAt?: string | null
|
||||
expiresAt?: string | null
|
||||
resultCount?: number | null
|
||||
errorCode?: string | null
|
||||
errorMessage?: string | null
|
||||
downloadPath?: string | null
|
||||
}
|
||||
|
||||
export type UserStatus = 'enabled' | 'disabled'
|
||||
|
||||
@@ -3,15 +3,26 @@ import type { ReportTask, ReportTaskStatus } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: ReportTask[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
pages: number
|
||||
loading: boolean
|
||||
error: string
|
||||
pageSizeOptions: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [row: ReportTask]
|
||||
'page-change': [page: number]
|
||||
'page-size-change': [size: number]
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const statusLabels: Record<ReportTaskStatus, string> = {
|
||||
signed: '已签署',
|
||||
pending: '待签署',
|
||||
signing: '签署中',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
@@ -29,52 +40,146 @@ function getStatusClass(status: ReportTaskStatus) {
|
||||
return `status-${status}`
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
function formatTime(date: Date | undefined) {
|
||||
if (!date) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatDuration(row: ReportTask) {
|
||||
const seconds =
|
||||
row.durationSeconds !== undefined
|
||||
? row.durationSeconds
|
||||
: row.durationMinutes !== undefined
|
||||
? row.durationMinutes * 60
|
||||
: undefined
|
||||
|
||||
if (seconds === undefined || !Number.isFinite(seconds)) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const roundedSeconds = Math.round(seconds)
|
||||
const minutes = Math.floor(roundedSeconds / 60)
|
||||
const restSeconds = roundedSeconds % 60
|
||||
|
||||
if (!minutes) {
|
||||
return String(restSeconds) + '秒'
|
||||
}
|
||||
|
||||
return restSeconds
|
||||
? String(minutes) + '分' + String(restSeconds) + '秒'
|
||||
: String(minutes) + '分钟'
|
||||
}
|
||||
|
||||
function getPageCount(pages: number) {
|
||||
return Math.max(pages, 1)
|
||||
}
|
||||
|
||||
function handlePreviousPage(page: number) {
|
||||
if (page > 1) {
|
||||
emit('page-change', page - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNextPage(page: number, pages: number) {
|
||||
if (page < getPageCount(pages)) {
|
||||
emit('page-change', page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(event: Event) {
|
||||
const value = Number((event.target as HTMLSelectElement).value)
|
||||
|
||||
if (Number.isFinite(value)) {
|
||||
emit('page-size-change', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-scroll">
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务号</th>
|
||||
<th>患者</th>
|
||||
<th>文档</th>
|
||||
<th>科室</th>
|
||||
<th>方式</th>
|
||||
<th>状态</th>
|
||||
<th>发起时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td>
|
||||
<strong>{{ row.patientName }}</strong>
|
||||
<span class="patient-id">{{ row.patientId }}</span>
|
||||
</td>
|
||||
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="getStatusClass(row.status)">
|
||||
{{ getStatusLabel(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(row.createdAt) }}</td>
|
||||
<td>
|
||||
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="empty-cell">暂无符合条件的签署明细</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="error" class="table-error" role="alert">
|
||||
<span>{{ error }}</span>
|
||||
<button type="button" @click="emit('retry')">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container" :class="{ 'is-loading': loading }" :aria-busy="loading">
|
||||
<div class="table-scroll">
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务号</th>
|
||||
<th>患者</th>
|
||||
<th>文档</th>
|
||||
<th>科室</th>
|
||||
<th>方式</th>
|
||||
<th>状态</th>
|
||||
<th>发起时间</th>
|
||||
<th>签署完成</th>
|
||||
<th>耗时</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td>
|
||||
<strong>{{ row.patientName }}</strong>
|
||||
<span class="patient-id">{{ row.patientId }}</span>
|
||||
</td>
|
||||
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="getStatusClass(row.status)">
|
||||
{{ getStatusLabel(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(row.createdAt) }}</td>
|
||||
<td>{{ formatTime(row.signedAt) }}</td>
|
||||
<td>{{ formatDuration(row) }}</td>
|
||||
<td>
|
||||
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="10" class="empty-cell">暂无符合条件的签署明细</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="total" class="table-footer">
|
||||
<span>共 {{ total }} 条</span>
|
||||
<label>
|
||||
每页
|
||||
<select :value="pageSize" aria-label="选择每页条数" @change="handlePageSizeChange">
|
||||
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
条
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="page-button"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="handlePreviousPage(page)"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {{ page }} / {{ getPageCount(pages) }} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
class="page-button"
|
||||
:disabled="page >= getPageCount(pages) || loading"
|
||||
@click="handleNextPage(page, pages)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,6 +189,16 @@ function formatTime(date: Date) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-container.is-loading {
|
||||
min-height: 120px;
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
@@ -145,6 +260,11 @@ function formatTime(date: Date) {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.status-signing {
|
||||
color: var(--info);
|
||||
background: var(--info-l);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
@@ -178,4 +298,53 @@ function formatTime(date: Date) {
|
||||
color: var(--mut);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-error {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-error button,
|
||||
.page-button {
|
||||
padding: 5px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-footer label {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-footer select {
|
||||
padding: 3px 5px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDocumentCategory,
|
||||
ReportMethodFilter,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
} from '../types'
|
||||
@@ -11,12 +12,17 @@ defineProps<{
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
method: ReportMethodFilter
|
||||
keyword: string
|
||||
customFrom: string
|
||||
customTo: string
|
||||
periodOptions: ReportSelectOption<ReportPeriodOption>[]
|
||||
campusOptions: ReportSelectOption<ReportCampus>[]
|
||||
departmentOptions: ReportSelectOption[]
|
||||
categoryOptions: ReportSelectOption<ReportDocumentCategory>[]
|
||||
methodOptions: ReportSelectOption<ReportMethodFilter>[]
|
||||
exportLoading: boolean
|
||||
exportStatus: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -24,29 +30,39 @@ const emit = defineEmits<{
|
||||
'update:campus': [value: ReportCampus]
|
||||
'update:department': [value: string]
|
||||
'update:category': [value: ReportDocumentCategory]
|
||||
'update:method': [value: ReportMethodFilter]
|
||||
'update:keyword': [value: string]
|
||||
'update:customFrom': [value: string]
|
||||
'update:customTo': [value: string]
|
||||
export: []
|
||||
}>()
|
||||
|
||||
function readSelectValue(event: Event) {
|
||||
function readValue(event: Event) {
|
||||
return (event.target as HTMLSelectElement).value
|
||||
}
|
||||
|
||||
function handlePeriodChange(event: Event) {
|
||||
emit('update:period', readSelectValue(event) as ReportPeriodOption)
|
||||
emit('update:period', readValue(event) as ReportPeriodOption)
|
||||
}
|
||||
|
||||
function handleCampusChange(event: Event) {
|
||||
emit('update:campus', readSelectValue(event) as ReportCampus)
|
||||
emit('update:campus', readValue(event) as ReportCampus)
|
||||
}
|
||||
|
||||
function handleDepartmentChange(event: Event) {
|
||||
emit('update:department', readSelectValue(event))
|
||||
emit('update:department', readValue(event))
|
||||
}
|
||||
|
||||
function handleCategoryChange(event: Event) {
|
||||
emit('update:category', readSelectValue(event) as ReportDocumentCategory)
|
||||
emit('update:category', readValue(event) as ReportDocumentCategory)
|
||||
}
|
||||
|
||||
function handleMethodChange(event: Event) {
|
||||
emit('update:method', readValue(event) as ReportMethodFilter)
|
||||
}
|
||||
|
||||
function handleKeywordChange(event: Event) {
|
||||
emit('update:keyword', readValue(event))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -89,6 +105,27 @@ function handleCategoryChange(event: Event) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>方式</span>
|
||||
<select :value="method" aria-label="选择签署方式" @change="handleMethodChange">
|
||||
<option v-for="option in methodOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field keyword-field">
|
||||
<span>关键词</span>
|
||||
<input
|
||||
type="search"
|
||||
:value="keyword"
|
||||
maxlength="128"
|
||||
placeholder="任务号/患者/文档"
|
||||
aria-label="搜索报表关键词"
|
||||
@change="handleKeywordChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<template v-if="period === 'custom'">
|
||||
<label class="filter-field date-field">
|
||||
<span>起始</span>
|
||||
@@ -96,7 +133,7 @@ function handleCategoryChange(event: Event) {
|
||||
type="date"
|
||||
:value="customFrom"
|
||||
aria-label="选择报表起始日期"
|
||||
@change="emit('update:customFrom', readSelectValue($event))"
|
||||
@change="emit('update:customFrom', readValue($event))"
|
||||
/>
|
||||
</label>
|
||||
<label class="filter-field date-field">
|
||||
@@ -105,13 +142,16 @@ function handleCategoryChange(event: Event) {
|
||||
type="date"
|
||||
:value="customTo"
|
||||
aria-label="选择报表结束日期"
|
||||
@change="emit('update:customTo', readSelectValue($event))"
|
||||
@change="emit('update:customTo', readValue($event))"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<span class="filter-spacer" />
|
||||
<button type="button" class="export-button" @click="emit('export')">导出</button>
|
||||
<span v-if="exportStatus" class="export-status" role="status">{{ exportStatus }}</span>
|
||||
<button type="button" class="export-button" :disabled="exportLoading" @click="emit('export')">
|
||||
{{ exportLoading ? '导出中…' : '导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -161,6 +201,10 @@ function handleCategoryChange(event: Event) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.keyword-field input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.filter-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
@@ -190,6 +234,20 @@ function handleCategoryChange(event: Event) {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.export-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.export-status {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-spacer {
|
||||
display: none;
|
||||
|
||||
@@ -49,7 +49,10 @@ defineProps<{
|
||||
</div>
|
||||
<div>
|
||||
<div class="metric-value">
|
||||
{{ metric.value }}<span v-if="metric.unit" class="metric-unit">{{ metric.unit }}</span>
|
||||
{{ metric.value
|
||||
}}<span v-if="metric.unit && metric.value !== '—'" class="metric-unit">{{
|
||||
metric.unit
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="metric-label">{{ metric.label }}</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,17 @@ export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
|
||||
/** 院区由后端字典提供,保留 all 作为全院筛选值。 */
|
||||
export type ReportCampus = 'all' | (string & {})
|
||||
|
||||
export type ReportDocumentCategory = 'all' | 'consent' | 'risk' | 'admission'
|
||||
export type ReportDocumentCategory = 'all' | (string & {})
|
||||
|
||||
export type ReportMethodFilter = 'all' | 'pad' | 'sms'
|
||||
|
||||
export interface ReportFilterForm {
|
||||
period: ReportPeriodOption
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
method: ReportMethodFilter
|
||||
keyword: string
|
||||
customFrom: string
|
||||
customTo: string
|
||||
}
|
||||
@@ -24,7 +28,7 @@ export interface ReportMetricCard {
|
||||
tone: ReportMetricTone
|
||||
}
|
||||
|
||||
export type ReportTaskStatus = 'signed' | 'pending' | 'expired' | 'void' | 'failed'
|
||||
export type ReportTaskStatus = 'signed' | 'pending' | 'signing' | 'expired' | 'void' | 'failed'
|
||||
|
||||
export type ReportSigningMethod = 'pad' | 'sms'
|
||||
|
||||
@@ -40,8 +44,13 @@ export interface ReportTask {
|
||||
method: ReportSigningMethod
|
||||
status: ReportTaskStatus
|
||||
createdAt: Date
|
||||
deadlineAt?: Date
|
||||
signStartedAt?: Date
|
||||
signedAt?: Date
|
||||
expiredAt?: Date
|
||||
voidedAt?: Date
|
||||
onTime?: boolean
|
||||
durationSeconds?: number
|
||||
durationMinutes?: number
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user