fix(workbench): correct home overview aggregation
This commit is contained in:
@@ -163,6 +163,8 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
|||||||
|
|
||||||
报表页在真实模式下使用 `GET /api/v1/sign-tasks` 聚合当前任务数据,并支持跳转任务和导出当前筛选结果;MEDISIGN 文档中暂无独立的报表统计接口。首页同样从模板、院区和签署任务接口聚合工作台概览。
|
报表页在真实模式下使用 `GET /api/v1/sign-tasks` 聚合当前任务数据,并支持跳转任务和导出当前筛选结果;MEDISIGN 文档中暂无独立的报表统计接口。首页同样从模板、院区和签署任务接口聚合工作台概览。
|
||||||
|
|
||||||
|
首页前端修复记录及后端聚合接口需求见 [`docs/workbench-home-api.md`](./docs/workbench-home-api.md)。
|
||||||
|
|
||||||
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
|
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
|
||||||
|
|
||||||
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
|
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
|
||||||
|
|||||||
141
clinical-web/docs/workbench-home-api.md
Normal file
141
clinical-web/docs/workbench-home-api.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 工作台首页后端接口需求
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
工作台首页是只读的概览页,当前前端可以通过模板、院区和签署任务接口临时聚合数据,但任务量较大时会产生多次分页请求,而且现有任务列表主要按 `createdAt` 查询,无法准确支持“按签署时间统计”和“按超时时间统计”。
|
||||||
|
|
||||||
|
建议后端提供一个聚合接口,由后端在权限范围内一次性计算首页所需数据。
|
||||||
|
|
||||||
|
## 2. 推荐接口
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/workbench/overview
|
||||||
|
```
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Token: {登录令牌}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| -------------- | ------- | ---- | ---------------------------------------------- |
|
||||||
|
| `campusId` | UUID | 是 | 当前首页选择的院区 |
|
||||||
|
| `period` | integer | 是 | 签署趋势和文档排名周期,只允许 `7`、`14`、`30` |
|
||||||
|
| `todoLimit` | integer | 否 | 待办数量,默认 `8`,最大 `20` |
|
||||||
|
| `rankingLimit` | integer | 否 | 排名数量,默认 `10`,最大 `20` |
|
||||||
|
|
||||||
|
日期计算建议统一使用服务端的医院业务时区(上海为 `Asia/Shanghai`),响应中的时间字段使用 ISO-8601 格式。
|
||||||
|
|
||||||
|
## 3. 返回结构
|
||||||
|
|
||||||
|
接口继续使用当前统一响应包装:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "OK",
|
||||||
|
"data": {
|
||||||
|
"campusId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"generatedAt": "2026-09-01T08:00:00Z",
|
||||||
|
"summary": {
|
||||||
|
"todaySigned": 36,
|
||||||
|
"todaySignedChange": 2,
|
||||||
|
"pendingPatientSigning": 18,
|
||||||
|
"todayOverdue": 2,
|
||||||
|
"availableTemplates": 128,
|
||||||
|
"coveredDepartments": 9
|
||||||
|
},
|
||||||
|
"trend": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-26",
|
||||||
|
"label": "8/26",
|
||||||
|
"signedCount": 39
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"todos": [
|
||||||
|
{
|
||||||
|
"taskId": "任务 UUID",
|
||||||
|
"patientId": "脱敏患者标识",
|
||||||
|
"patientName": "患者姓名",
|
||||||
|
"documentName": "知情同意书",
|
||||||
|
"departmentName": "消化内科",
|
||||||
|
"status": "WAITING_SIGN",
|
||||||
|
"signMethod": "PAD",
|
||||||
|
"updatedAt": "2026-09-01T07:50:00Z",
|
||||||
|
"expiredAt": "2026-09-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"documentRanking": [
|
||||||
|
{
|
||||||
|
"templateId": "模板 UUID",
|
||||||
|
"documentName": "住院患者知情同意书",
|
||||||
|
"departmentName": "全院通用",
|
||||||
|
"signedCount": 58
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"traceId": "链路追踪 ID",
|
||||||
|
"timestamp": "2026-09-01T08:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 统计口径
|
||||||
|
|
||||||
|
### 4.1 指标卡
|
||||||
|
|
||||||
|
- `todaySigned`:`status=SIGNED` 且 `signedAt` 位于今天业务时区起止范围内的任务数。
|
||||||
|
- `todaySignedChange`:`todaySigned - yesterdaySigned`,正数表示上升,负数表示下降,`0` 表示持平。
|
||||||
|
- `pendingPatientSigning`:状态为 `CREATED`、`WAITING_SIGN` 或 `GENERATING` 的任务数;不按创建日期限制。
|
||||||
|
- `todayOverdue`:状态为 `EXPIRED` 且 `expiredAt` 位于今天业务时区起止范围内的任务数。
|
||||||
|
- `availableTemplates`:当前用户在指定院区有 `USE` 权限且已发布生效的模板数量,按 `templateId` 去重,不按版本行数重复计算。
|
||||||
|
- `coveredDepartments`:上述可用模板中非空 `departmentId` 的去重数量;全院通用模板不计入科室数。
|
||||||
|
|
||||||
|
### 4.2 签署趋势
|
||||||
|
|
||||||
|
返回最近 `period` 个自然日,包含今天,按日期升序排列。每天按 `signedAt` 统计 `SIGNED` 任务数,而不是按 `createdAt` 或 `updatedAt` 统计。
|
||||||
|
|
||||||
|
### 4.3 待办任务
|
||||||
|
|
||||||
|
待办包括 `CREATED`、`WAITING_SIGN`、`GENERATING` 和 `EXPIRED` 任务,按 `updatedAt` 倒序返回前 `todoLimit` 条。前端会将前三种状态展示为“等待患者签署”,将 `EXPIRED` 展示为“已超时”。
|
||||||
|
|
||||||
|
返回的患者标识必须遵守现有数据脱敏和权限规则,不返回明文证件号、明文手机号或其他不必要的敏感信息。
|
||||||
|
|
||||||
|
### 4.4 文档签署排名
|
||||||
|
|
||||||
|
仅统计 `signedAt` 位于最近 `period` 个自然日内的 `SIGNED` 任务,按 `templateId` 分组后倒序返回前 `rankingLimit` 条。分组键必须使用模板 ID,不能只使用文档名称,避免同名模板被错误合并。
|
||||||
|
|
||||||
|
## 5. 权限和错误响应
|
||||||
|
|
||||||
|
接口必须复用签署任务查询和模板 `USE` 权限的数据范围控制,只统计当前用户有权查看的院区、科室和任务。
|
||||||
|
|
||||||
|
建议至少返回以下状态:
|
||||||
|
|
||||||
|
| HTTP 状态 | 场景 |
|
||||||
|
| --------- | ------------------------------- |
|
||||||
|
| `200` | 查询成功 |
|
||||||
|
| `400` | `campusId` 或 `period` 参数错误 |
|
||||||
|
| `401` | 未登录或令牌失效 |
|
||||||
|
| `403` | 无权查看指定院区或工作台数据 |
|
||||||
|
| `500` | 服务端聚合失败 |
|
||||||
|
|
||||||
|
## 6. 暂时不增加聚合接口时的最小改造
|
||||||
|
|
||||||
|
如果暂时不能提供 `/workbench/overview`,现有接口至少需要补充或明确以下能力:
|
||||||
|
|
||||||
|
1. `GET /api/v1/templates/available-versions` 必须支持 `campusId`,并返回真实 `departmentId`;前端已可以使用该筛选参数。
|
||||||
|
2. `GET /api/v1/sign-tasks` 需要支持按 `signedAt` 和 `expiredAt` 查询,而不能只支持 `createdAt`。
|
||||||
|
3. 任务列表最好支持多状态查询,或者提供待签署任务汇总接口,否则待办数量需要分别请求多个状态。
|
||||||
|
4. 任务或模板响应需要提供稳定的 `templateId`、`templateName`、`departmentName`,避免历史模板不可用时只能显示模板版本号。
|
||||||
|
5. 如果继续使用分页聚合,接口需要保证 `total`、`page`、`size` 一致,并允许客户端按最大页大小安全拉取全部数据。
|
||||||
|
|
||||||
|
## 7. 前端对接验收标准
|
||||||
|
|
||||||
|
- 切换院区后,四个指标、趋势、待办和排名都只属于当前院区。
|
||||||
|
- 切换 `7/14/30` 天后,趋势和排名同时改变统计周期。
|
||||||
|
- 跨自然日、任务延迟签署和任务延迟超时场景下,指标仍按 `signedAt`、`expiredAt` 正确统计。
|
||||||
|
- 首页显示的昨日变化不再使用固定文案。
|
||||||
|
- 待办点击后可以根据 `taskId` 直接打开详情,即使任务不在签署工作台当前第一页。
|
||||||
@@ -8,7 +8,10 @@ import type {
|
|||||||
WorkbenchDocumentRanking,
|
WorkbenchDocumentRanking,
|
||||||
WorkbenchOverviewQuery,
|
WorkbenchOverviewQuery,
|
||||||
WorkbenchOverviewResponse,
|
WorkbenchOverviewResponse,
|
||||||
|
SigningApiOptions,
|
||||||
|
SigningTemplate,
|
||||||
SigningTaskRecord,
|
SigningTaskRecord,
|
||||||
|
SigningTaskQuery,
|
||||||
WorkbenchTodoTask,
|
WorkbenchTodoTask,
|
||||||
WorkbenchTrendPoint,
|
WorkbenchTrendPoint,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -26,6 +29,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
|||||||
本部院区: {
|
本部院区: {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned: 36,
|
todaySigned: 36,
|
||||||
|
todaySignedChange: 2,
|
||||||
pendingPatientSigning: 18,
|
pendingPatientSigning: 18,
|
||||||
todayOverdue: 2,
|
todayOverdue: 2,
|
||||||
availableTemplates: 128,
|
availableTemplates: 128,
|
||||||
@@ -93,6 +97,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
|||||||
东院区: {
|
东院区: {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned: 22,
|
todaySigned: 22,
|
||||||
|
todaySignedChange: 1,
|
||||||
pendingPatientSigning: 11,
|
pendingPatientSigning: 11,
|
||||||
todayOverdue: 1,
|
todayOverdue: 1,
|
||||||
availableTemplates: 76,
|
availableTemplates: 76,
|
||||||
@@ -160,6 +165,7 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
|||||||
西院区: {
|
西院区: {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned: 17,
|
todaySigned: 17,
|
||||||
|
todaySignedChange: 2,
|
||||||
pendingPatientSigning: 9,
|
pendingPatientSigning: 9,
|
||||||
todayOverdue: 2,
|
todayOverdue: 2,
|
||||||
availableTemplates: 54,
|
availableTemplates: 54,
|
||||||
@@ -246,6 +252,47 @@ function createRanking(
|
|||||||
.sort((left, right) => right.signedCount - left.signedCount)
|
.sort((left, right) => right.signedCount - left.signedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countAvailableTemplates(templates: SigningTemplate[]) {
|
||||||
|
return new Set(templates.map((template) => template.id)).size
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCoveredDepartments(templates: SigningTemplate[]) {
|
||||||
|
return new Set(
|
||||||
|
templates
|
||||||
|
.map((template) => {
|
||||||
|
if (template.departmentId) {
|
||||||
|
return `id:${template.departmentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.department && template.department !== '全院通用'
|
||||||
|
? `name:${template.department}`
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
.filter((department): department is string => Boolean(department)),
|
||||||
|
).size
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllSigningTasks(
|
||||||
|
query: SigningTaskQuery,
|
||||||
|
options: SigningApiOptions,
|
||||||
|
): Promise<SigningTaskRecord[]> {
|
||||||
|
const pageSize = 200
|
||||||
|
const firstPage = await getSigningTasks({ ...query, page: 1, pageSize }, options)
|
||||||
|
const totalPages = Math.ceil(firstPage.total / Math.max(firstPage.pageSize, 1))
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
return firstPage.records
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingPages = await Promise.all(
|
||||||
|
Array.from({ length: totalPages - 1 }, (_, index) =>
|
||||||
|
getSigningTasks({ ...query, page: index + 2, pageSize }, options),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||||
|
}
|
||||||
|
|
||||||
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||||
return value === '本部院区' || value === '东院区' || value === '西院区'
|
return value === '本部院区' || value === '东院区' || value === '西院区'
|
||||||
}
|
}
|
||||||
@@ -268,8 +315,31 @@ function isSameDay(left: Date | null, right: Date) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskDate(task: SigningTaskRecord) {
|
function getSignedDate(task: SigningTaskRecord) {
|
||||||
return parseDate(task.createdAt) ?? parseDate(task.updatedAt)
|
return parseDate(task.signedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpiredDate(task: SigningTaskRecord) {
|
||||||
|
return parseDate(task.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdatedDate(task: SigningTaskRecord) {
|
||||||
|
return parseDate(task.updatedAt) ?? parseDate(task.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinPeriod(value: Date | null, today: Date, period: HomeRankingPeriod) {
|
||||||
|
if (!value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(today)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
start.setDate(start.getDate() - period + 1)
|
||||||
|
|
||||||
|
const end = new Date(today)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
|
return value >= start && value <= end
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPendingTask(task: SigningTaskRecord) {
|
function isPendingTask(task: SigningTaskRecord) {
|
||||||
@@ -284,10 +354,15 @@ function createLiveOverview(
|
|||||||
): WorkbenchOverviewResponse {
|
): WorkbenchOverviewResponse {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const signedTasks = tasks.filter((task) => task.status === 'signed')
|
const signedTasks = tasks.filter((task) => task.status === 'signed')
|
||||||
const todaySigned = signedTasks.filter((task) => isSameDay(getTaskDate(task), today)).length
|
const todaySigned = signedTasks.filter((task) => isSameDay(getSignedDate(task), today)).length
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const yesterdaySigned = signedTasks.filter((task) =>
|
||||||
|
isSameDay(getSignedDate(task), yesterday),
|
||||||
|
).length
|
||||||
const pendingTasks = tasks.filter(isPendingTask)
|
const pendingTasks = tasks.filter(isPendingTask)
|
||||||
const todayOverdue = tasks.filter(
|
const todayOverdue = tasks.filter(
|
||||||
(task) => task.status === 'expired' && isSameDay(getTaskDate(task), today),
|
(task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today),
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
|
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
|
||||||
@@ -297,15 +372,15 @@ function createLiveOverview(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
||||||
value: signedTasks.filter((task) => isSameDay(getTaskDate(task), date)).length,
|
value: signedTasks.filter((task) => isSameDay(getSignedDate(task), date)).length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const todos = tasks
|
const todos = tasks
|
||||||
.filter((task) => isPendingTask(task) || task.status === 'expired')
|
.filter((task) => isPendingTask(task) || task.status === 'expired')
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftDate = getTaskDate(left)?.getTime() ?? 0
|
const leftDate = getUpdatedDate(left)?.getTime() ?? 0
|
||||||
const rightDate = getTaskDate(right)?.getTime() ?? 0
|
const rightDate = getUpdatedDate(right)?.getTime() ?? 0
|
||||||
return rightDate - leftDate
|
return rightDate - leftDate
|
||||||
})
|
})
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
@@ -321,9 +396,11 @@ function createLiveOverview(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
|
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
|
||||||
signedTasks.forEach((task) => {
|
signedTasks
|
||||||
const current = rankingMap.get(task.documentName)
|
.filter((task) => isWithinPeriod(getSignedDate(task), today, rankingPeriod))
|
||||||
rankingMap.set(task.documentName, {
|
.forEach((task) => {
|
||||||
|
const current = rankingMap.get(task.documentId)
|
||||||
|
rankingMap.set(task.documentId, {
|
||||||
id: current?.id ?? task.documentId,
|
id: current?.id ?? task.documentId,
|
||||||
documentName: task.documentName,
|
documentName: task.documentName,
|
||||||
department: task.department,
|
department: task.department,
|
||||||
@@ -334,6 +411,7 @@ function createLiveOverview(
|
|||||||
return {
|
return {
|
||||||
summary: {
|
summary: {
|
||||||
todaySigned,
|
todaySigned,
|
||||||
|
todaySignedChange: todaySigned - yesterdaySigned,
|
||||||
pendingPatientSigning: pendingTasks.length,
|
pendingPatientSigning: pendingTasks.length,
|
||||||
todayOverdue,
|
todayOverdue,
|
||||||
availableTemplates: templateCount,
|
availableTemplates: templateCount,
|
||||||
@@ -361,38 +439,35 @@ export function getWorkbenchOverview(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.allSettled([getSigningTemplates(), getCampuses()]).then(
|
return getCampuses().then(async (campuses) => {
|
||||||
async ([templatesResult, campusesResult]) => {
|
const campus = campuses.find((item) => item.name === query.campus)
|
||||||
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : []
|
|
||||||
const campusNames: Record<string, WorkbenchCampus> =
|
if (!campus) {
|
||||||
campusesResult.status === 'fulfilled'
|
throw new Error(`未找到院区:${query.campus}`)
|
||||||
? (Object.fromEntries(
|
}
|
||||||
campusesResult.value
|
|
||||||
.filter((campus) => isWorkbenchCampus(campus.name))
|
const campusIds = Object.fromEntries(campuses.map((item) => [item.name, item.id]))
|
||||||
.map((campus) => [campus.id, campus.name]),
|
const campusNames = Object.fromEntries(
|
||||||
) as Record<string, WorkbenchCampus>)
|
campuses.filter((item) => isWorkbenchCampus(item.name)).map((item) => [item.id, item.name]),
|
||||||
: {}
|
) as Record<string, WorkbenchCampus>
|
||||||
const tasksResponse = await getSigningTasks(
|
const templates = await getSigningTemplates({ campusId: campus.id })
|
||||||
|
const tasks = await getAllSigningTasks(
|
||||||
{
|
{
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
status: 'all',
|
status: 'all',
|
||||||
dateRange: 'all',
|
dateRange: 'all',
|
||||||
campus: 'all',
|
campus: query.campus,
|
||||||
method: 'all',
|
method: 'all',
|
||||||
},
|
},
|
||||||
{ templates, campusNames },
|
{ templates, campusIds, campusNames },
|
||||||
)
|
)
|
||||||
const scopedTasks = Object.keys(campusNames).length
|
|
||||||
? tasksResponse.records.filter((task) => task.campus === query.campus)
|
|
||||||
: tasksResponse.records
|
|
||||||
|
|
||||||
return createLiveOverview(
|
return createLiveOverview(
|
||||||
scopedTasks,
|
tasks,
|
||||||
templates.length,
|
countAvailableTemplates(templates),
|
||||||
new Set(templates.map((template) => template.department)).size,
|
countCoveredDepartments(templates),
|
||||||
query.rankingPeriod,
|
query.rankingPeriod,
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface SigningTemplate {
|
|||||||
|
|
||||||
export interface WorkbenchSummary {
|
export interface WorkbenchSummary {
|
||||||
todaySigned: number
|
todaySigned: number
|
||||||
|
todaySignedChange: number
|
||||||
pendingPatientSigning: number
|
pendingPatientSigning: number
|
||||||
todayOverdue: number
|
todayOverdue: number
|
||||||
availableTemplates: number
|
availableTemplates: number
|
||||||
|
|||||||
@@ -27,11 +27,21 @@ const loading = ref(true)
|
|||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
const overview = ref<WorkbenchOverviewResponse | null>(null)
|
const overview = ref<WorkbenchOverviewResponse | null>(null)
|
||||||
const rankingPeriod = ref<HomeRankingPeriod>(14)
|
const rankingPeriod = ref<HomeRankingPeriod>(14)
|
||||||
|
let overviewRequestId = 0
|
||||||
|
|
||||||
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
const selectedCampus = computed<WorkbenchCampus>(() => appStore.selectedCampus)
|
||||||
|
|
||||||
const metrics = computed<HomeMetricCardData[]>(() => {
|
const metrics = computed<HomeMetricCardData[]>(() => {
|
||||||
const summary = overview.value?.summary
|
const summary = overview.value?.summary
|
||||||
|
const signedChange = summary?.todaySignedChange ?? 0
|
||||||
|
const signedChangeTone: HomeMetricCardData['detailTone'] =
|
||||||
|
signedChange > 0 ? 'up' : signedChange < 0 ? 'down' : 'muted'
|
||||||
|
const signedChangeDetail =
|
||||||
|
signedChange > 0
|
||||||
|
? `▲ 较昨日 +${signedChange} · 点击查看`
|
||||||
|
: signedChange < 0
|
||||||
|
? `▼ 较昨日 ${signedChange} · 点击查看`
|
||||||
|
: '较昨日持平 · 点击查看'
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -39,8 +49,8 @@ const metrics = computed<HomeMetricCardData[]>(() => {
|
|||||||
label: '今日已签署(单)',
|
label: '今日已签署(单)',
|
||||||
value: summary?.todaySigned ?? 0,
|
value: summary?.todaySigned ?? 0,
|
||||||
tone: 'success',
|
tone: 'success',
|
||||||
detail: '▲ 较昨日 +2 · 点击查看',
|
detail: signedChangeDetail,
|
||||||
detailTone: 'up',
|
detailTone: signedChangeTone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pending',
|
key: 'pending',
|
||||||
@@ -74,26 +84,42 @@ const todoItems = computed<HomeTodoItem[]>(() => overview.value?.todos ?? [])
|
|||||||
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
|
const rankingRows = computed<HomeDocumentRanking[]>(() => overview.value?.documentRanking ?? [])
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
|
const requestId = ++overviewRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
overview.value = await getWorkbenchOverview({
|
const nextOverview = await getWorkbenchOverview({
|
||||||
campus: selectedCampus.value,
|
campus: selectedCampus.value,
|
||||||
rankingPeriod: rankingPeriod.value,
|
rankingPeriod: rankingPeriod.value,
|
||||||
})
|
})
|
||||||
|
if (requestId !== overviewRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.value = nextOverview
|
||||||
} catch {
|
} catch {
|
||||||
|
if (requestId !== overviewRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.value = null
|
||||||
loadFailed.value = true
|
loadFailed.value = true
|
||||||
ElMessage.error('首页数据加载失败,请稍后重试')
|
ElMessage.error('首页数据加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === overviewRequestId) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function goToSigning(query: Record<string, string> = {}) {
|
function goToSigning(query: Record<string, string> = {}) {
|
||||||
void router.push({
|
void router.push({
|
||||||
name: 'workbench-signing',
|
name: 'workbench-signing',
|
||||||
query,
|
query: {
|
||||||
|
campus: selectedCampus.value,
|
||||||
|
...query,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const STATUS_VALUES: SigningTaskStatus[] = [
|
|||||||
'failed',
|
'failed',
|
||||||
]
|
]
|
||||||
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
||||||
|
const CAMPUS_VALUES: WorkbenchCampus[] = ['本部院区', '东院区', '西院区']
|
||||||
|
|
||||||
const statusLabels: Record<SigningTaskStatus, string> = {
|
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||||
pending: '待签署',
|
pending: '待签署',
|
||||||
@@ -163,12 +164,18 @@ function isSigningDateRange(value: string): value is SigningDateRange {
|
|||||||
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
|
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
|
||||||
|
return CAMPUS_VALUES.includes(value as WorkbenchCampus)
|
||||||
|
}
|
||||||
|
|
||||||
function syncFilterFromRoute() {
|
function syncFilterFromRoute() {
|
||||||
const status = getQueryString(route.query.status)
|
const status = getQueryString(route.query.status)
|
||||||
const range = getQueryString(route.query.range)
|
const range = getQueryString(route.query.range)
|
||||||
|
const campus = getQueryString(route.query.campus)
|
||||||
|
|
||||||
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
|
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
|
||||||
filter.dateRange = isSigningDateRange(range) ? range : 'all'
|
filter.dateRange = isSigningDateRange(range) ? range : 'all'
|
||||||
|
filter.campus = isWorkbenchCampus(campus) ? campus : 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
@@ -305,10 +312,10 @@ async function loadTasks(preferredTaskId?: string, resetPage = false) {
|
|||||||
|
|
||||||
const taskIds = new Set(response.records.map((task) => task.id))
|
const taskIds = new Set(response.records.map((task) => task.id))
|
||||||
const routeTaskId = getQueryString(route.query.taskId)
|
const routeTaskId = getQueryString(route.query.taskId)
|
||||||
const nextTaskId =
|
const matchedTaskId = [preferredTaskId, selectedTaskId.value, routeTaskId].find(
|
||||||
[preferredTaskId, selectedTaskId.value, routeTaskId].find((id) => id && taskIds.has(id)) ??
|
(id) => id && taskIds.has(id),
|
||||||
response.records[0]?.id ??
|
)
|
||||||
null
|
const nextTaskId = matchedTaskId ?? (routeTaskId || response.records[0]?.id || null)
|
||||||
|
|
||||||
selectedTaskId.value = nextTaskId
|
selectedTaskId.value = nextTaskId
|
||||||
await loadTaskDetail(nextTaskId)
|
await loadTaskDetail(nextTaskId)
|
||||||
@@ -561,7 +568,7 @@ async function handleTaskCreated(task: SigningTaskRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [route.query.status, route.query.range, route.query.taskId],
|
() => [route.query.status, route.query.range, route.query.campus, route.query.taskId],
|
||||||
() => {
|
() => {
|
||||||
syncFilterFromRoute()
|
syncFilterFromRoute()
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
|||||||
Reference in New Issue
Block a user