feat(workbench): 接入工作台首页概览聚合接口

This commit is contained in:
yelan
2026-09-02 13:17:40 +08:00
parent e0e98ec74f
commit e52f203123
13 changed files with 398 additions and 358 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
报表页在真实模式下使用 `GET /api/v1/sign-tasks` 读取全部可见分页,并结合模板、院区和科室接口在前端临时聚合,支持跳转任务和导出当前筛选结果;MEDISIGN 文档中暂无独立的报表统计接口。报表接口核验、前端统计口径见 [reports-api.md](./docs/reports-api.md),后端交付需求见 [reports-backend-requirements.md](./docs/reports-backend-requirements.md)。首页同样从模板、院区和签署任务接口聚合工作台概览。
首页前端修复记录及后端聚合接口需求见 [`docs/workbench-home-api.md`](./docs/workbench-home-api.md)。
首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [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,不向后端发送患者快照、文档名称或明文手机号等页面字段。
+48 -120
View File
@@ -1,141 +1,69 @@
# 工作台首页后端接口需求
# 工作台首页接口核验与接入说明
## 1. 目标
> 事实基准:用户于 2026-09-02 导出的 default_OpenAPI.json,OpenAPI 3.1.0,MEDISIGN 患者电子签 API v1。
>
> 接入状态:当 VITE_USE_MOCK=false 时,首页已接入 GET /api/v1/workbench/overview。
工作台首页是只读的概览页,当前前端可以通过模板、院区和签署任务接口临时聚合数据,但任务量较大时会产生多次分页请求,而且现有任务列表主要按 `createdAt` 查询,无法准确支持“按签署时间统计”和“按超时时间统计”。
## 1. 核验结论
建议后端提供一个聚合接口,由后端在权限范围内一次性计算首页所需数据。
最新契约已提供工作台首页聚合接口。真实模式下,首页不再循环读取模板、签署任务和组织数据并在浏览器聚合,而是由后端在权限范围内一次性返回指标、趋势、待办和文档排名。
## 2. 推荐接口
```http
GET /api/v1/workbench/overview
```
请求头:
全局 `appStore` 在应用加载后调用 GET /api/v1/campuses(page=1、size=200),仅保留启用院区,并以 UUID 作为下拉选项值。首页将当前选中 UUID 直接传给概览接口;API 边界只在未传 UUID 的调用场景下按院区名称兜底解析。
## 2. 已接入的请求
```http
GET /api/v1/workbench/overview?campusId={UUID}&period={7|14|30}&todoLimit=8&rankingLimit=10
X-Token: {登录令牌}
```
请求参数:
| 参数 | 必填 | 前端传值 | 契约说明 |
| ------------ | ---- | ------------------------- | ------------------------------ |
| campusId | 是 | 当前工作台院区对应的 UUID | 必须在当前用户数据范围内 |
| period | 是 | 7、14 或 30 | 同时决定趋势和文档排名统计周期 |
| todoLimit | 否 | 8 | 待办返回数量,范围 1–20 |
| rankingLimit | 否 | 10 | 排名返回数量,范围 1–20 |
| 参数 | 类型 | 必填 | 说明 |
| -------------- | ------- | ---- | ---------------------------------------------- |
| `campusId` | UUID | 是 | 当前首页选择的院区 |
| `period` | integer | 是 | 签署趋势和文档排名周期,只允许 `7`、`14`、`30` |
| `todoLimit` | integer | 否 | 待办数量,默认 `8`,最大 `20` |
| `rankingLimit` | integer | 否 | 排名数量,默认 `10`,最大 `20` |
请求通过 utils/request.ts 的单例发送,实际浏览器路径为 /api/v1/workbench/overview,由 Vite 开发代理转发到后端。
日期计算建议统一使用服务端的医院业务时区(上海为 `Asia/Shanghai`),响应中的时间字段使用 ISO-8601 格式。
## 3. 接口字段与页面映射
## 3. 返回结构
| 页面区域 | 后端字段 | 前端展示 |
| ---------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| 四张指标卡 | summary.todaySigned、todaySignedChange、pendingPatientSigning、todayOverdue、availableTemplates、coveredDepartments | 今日签署、较昨日变化、待签署、今日超时、可用模板及覆盖科室 |
| 签署趋势 | trend[].label、trend[].signedCount | 按后端业务时区生成的近 7、14 或 30 天趋势 |
| 我的待办 | todos[].taskId、patientId、patientName、documentName、departmentName、status、signMethod、updatedAt | 待办列表;点击 taskId 跳转签署工作台 |
| 文档排名 | documentRanking[].templateId、documentName、departmentName、signedCount | Top10 文档签署排名 |
接口继续使用当前统一响应包装:
趋势和排名都直接使用后端按 signedAt、医院业务时区聚合的结果;前端不再用 createdAt 或浏览器时区重新计算。
```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. 统计口径
后端 TodoItem.status 使用签署任务状态枚举。前端按下列方式展示,避免把生成中、失败或已作废任务误标为待签署:
### 4.1 指标卡
| 后端状态 | 页面状态 |
| ------------ | -------------------------- |
| CREATED | 待发送 |
| WAITING_SIGN | 待签署,并区分手写板或短信 |
| GENERATING | 文书生成中 |
| EXPIRED | 已超时 |
| SIGNED | 已签署 |
| VOIDED | 已作废 |
| FAILED | 处理失败 |
- `todaySigned`:`status=SIGNED` 且 `signedAt` 位于今天业务时区起止范围内的任务数。
- `todaySignedChange`:`todaySigned - yesterdaySigned`,正数表示上升,负数表示下降,`0` 表示持平。
- `pendingPatientSigning`:状态为 `CREATED`、`WAITING_SIGN` 或 `GENERATING` 的任务数;不按创建日期限制。
- `todayOverdue`:状态为 `EXPIRED` 且 `expiredAt` 位于今天业务时区起止范围内的任务数。
- `availableTemplates`:当前用户在指定院区有 `USE` 权限且已发布生效的模板数量,按 `templateId` 去重,不按版本行数重复计算。
- `coveredDepartments`:上述可用模板中非空 `departmentId` 的去重数量;全院通用模板不计入科室数。
pendingPatientSigning 的后端口径只统计 WAITING_SIGN;CREATED 和 GENERATING 不会被前端补入该指标。
### 4.2 签署趋势
## 5. 权限、错误与数据边界
返回最近 `period` 个自然日,包含今天,按日期升序排列。每天按 `signedAt` 统计 `SIGNED` 任务数,而不是按 `createdAt` 或 `updatedAt` 统计。
- 接口使用 X-Token 鉴权;任务数据范围和模板 ACL 由后端在 SQL 层执行。
- campusId 无权限或工作台无权限时,后端返回 403;参数错误返回 400;未登录返回 401。
- 患者标识为不可逆展示标识;前端不记录或补全身份证号、完整手机号和病历数据。
- 接口返回 timezone 和 generatedAt,统计日期以医院业务时区为准。
### 4.3 待办任务
## 6. 联调验收标准
待办包括 `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` 直接打开详情,即使任务不在签署工作台当前第一页。
- 切换院区后,请求携带新 campusId,四张指标卡、趋势、待办和排名同步更新。
- 切换 7、14、30 天后,趋势标题、趋势数据和文档排名同步变化。
- 返回空数组时,趋势、待办和排名显示各自空状态;指标卡显示 0。
- 服务端返回 GENERATING、FAILED、VOIDED 等状态时,页面标签与状态含义一致。
- Network 中不再出现首页为统计目的而循环拉取全部签署任务或模板的请求。
@@ -25,7 +25,9 @@ export function getCampuses(): Promise<CampusRecord[]> {
}
return request
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses')
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses', {
params: { page: 1, size: 200 },
})
.then(unwrapApiResponse)
.then((data) =>
getRecords(data).map((item) => ({
+1 -5
View File
@@ -8,16 +8,12 @@ const REPORT_PAGE_SIZE = 200
export const isReportsMockEnabled = isSigningMockEnabled
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return value === '本部院区' || value === '东院区' || value === '西院区'
}
function buildSigningApiOptions(data: ReportTaskData) {
return {
templates: data.templates,
campusNames: Object.fromEntries(
data.campuses
.filter((campus) => isWorkbenchCampus(campus.name))
.filter((campus) => Boolean(campus.name.trim()))
.map((campus) => [campus.id, campus.name]),
) as Record<string, WorkbenchCampus>,
}
+104 -200
View File
@@ -1,21 +1,23 @@
import { getCampuses } from '@/api/management/organization'
import { getSigningTasks, getSigningTemplates } from './signing'
import { unwrapApiResponse } from '@/utils/api-response'
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/common'
import type {
BackendSigningMethod,
BackendSigningTaskStatus,
HomeRankingPeriod,
SigningMethod,
WorkbenchCampus,
WorkbenchDocumentRanking,
WorkbenchOverviewQuery,
WorkbenchOverviewRequestDto,
WorkbenchOverviewResponse,
SigningApiOptions,
SigningTemplate,
SigningTaskRecord,
SigningTaskQuery,
WorkbenchOverviewResponseDto,
WorkbenchTodoStatus,
WorkbenchTodoTask,
WorkbenchTrendPoint,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
interface MockCampusOverview {
@@ -252,222 +254,124 @@ function createRanking(
.sort((left, right) => right.signedCount - left.signedCount)
}
function countAvailableTemplates(templates: SigningTemplate[]) {
return new Set(templates.map((template) => template.id)).size
}
const TODO_LIMIT = 8
const RANKING_LIMIT = 10
let campusIds: Map<string, string> | null = null
let campusIdsRequest: Promise<Map<string, string>> | null = null
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
function mapTodoStatus(status: BackendSigningTaskStatus): WorkbenchTodoStatus {
switch (status) {
case 'CREATED':
return 'created'
case 'WAITING_SIGN':
return 'pending'
case 'GENERATING':
return 'generating'
case 'EXPIRED':
return 'overdue'
case 'SIGNED':
return 'signed'
case 'VOIDED':
return 'voided'
case 'FAILED':
default:
return 'failed'
}
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 {
return value === '本部院区' || value === '东院区' || value === '西院区'
function mapSigningMethod(method: BackendSigningMethod): SigningMethod {
return method === 'SMS' ? 'sms' : 'pad'
}
function parseDate(value: string | undefined) {
if (!value) {
return null
}
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
return Number.isNaN(date.getTime()) ? null : date
}
function isSameDay(left: Date | null, right: Date) {
return Boolean(
left &&
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate(),
)
}
function getSignedDate(task: SigningTaskRecord) {
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) {
return task.status === 'pending' || task.status === 'signing'
}
function createLiveOverview(
tasks: SigningTaskRecord[],
templateCount: number,
coveredDepartments: number,
rankingPeriod: HomeRankingPeriod,
): WorkbenchOverviewResponse {
const today = new Date()
const signedTasks = tasks.filter((task) => task.status === 'signed')
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 todayOverdue = tasks.filter(
(task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today),
).length
const trend = Array.from({ length: rankingPeriod }, (_, index) => {
const date = new Date(today)
date.setHours(0, 0, 0, 0)
date.setDate(today.getDate() - rankingPeriod + index + 1)
return {
label: `${date.getMonth() + 1}/${date.getDate()}`,
value: signedTasks.filter((task) => isSameDay(getSignedDate(task), date)).length,
}
})
const todos = tasks
.filter((task) => isPendingTask(task) || task.status === 'expired')
.sort((left, right) => {
const leftDate = getUpdatedDate(left)?.getTime() ?? 0
const rightDate = getUpdatedDate(right)?.getTime() ?? 0
return rightDate - leftDate
})
.slice(0, 8)
.map((task) => ({
id: task.id,
patientId: task.patientId,
patientName: task.patientName,
documentName: task.documentName,
department: task.department,
status: task.status === 'expired' ? ('overdue' as const) : ('pending' as const),
method: task.method,
updatedAt: task.updatedAt,
}))
const rankingMap = new Map<string, WorkbenchDocumentRanking>()
signedTasks
.filter((task) => isWithinPeriod(getSignedDate(task), today, rankingPeriod))
.forEach((task) => {
const current = rankingMap.get(task.documentId)
rankingMap.set(task.documentId, {
id: current?.id ?? task.documentId,
documentName: task.documentName,
department: task.department,
signedCount: (current?.signedCount ?? 0) + 1,
})
})
function mapWorkbenchOverview(dto: WorkbenchOverviewResponseDto): WorkbenchOverviewResponse {
const summary = dto.summary
return {
summary: {
todaySigned,
todaySignedChange: todaySigned - yesterdaySigned,
pendingPatientSigning: pendingTasks.length,
todayOverdue,
availableTemplates: templateCount,
coveredDepartments,
todaySigned: summary?.todaySigned ?? 0,
todaySignedChange: summary?.todaySignedChange ?? 0,
pendingPatientSigning: summary?.pendingPatientSigning ?? 0,
todayOverdue: summary?.todayOverdue ?? 0,
availableTemplates: summary?.availableTemplates ?? 0,
coveredDepartments: summary?.coveredDepartments ?? 0,
},
trend,
todos,
documentRanking: [...rankingMap.values()]
.sort((left, right) => right.signedCount - left.signedCount)
.slice(0, 10),
trend: (dto.trend ?? []).map<WorkbenchTrendPoint>((point) => ({
label: point.label || point.date,
value: point.signedCount,
})),
todos: (dto.todos ?? []).map<WorkbenchTodoTask>((task) => ({
id: task.taskId,
patientId: task.patientId,
patientName: task.patientName,
documentName: task.documentName,
department: task.departmentName,
status: mapTodoStatus(task.status),
method: mapSigningMethod(task.signMethod),
updatedAt: task.updatedAt,
})),
documentRanking: (dto.documentRanking ?? []).map<WorkbenchDocumentRanking>((item) => ({
id: item.templateId,
documentName: item.documentName,
department: item.departmentName,
signedCount: item.signedCount,
})),
}
}
export function getWorkbenchOverview(
function getCampusIds() {
if (campusIds) {
return Promise.resolve(campusIds)
}
if (!campusIdsRequest) {
campusIdsRequest = getCampuses()
.then((campuses) => {
campusIds = new Map(campuses.map((campus) => [campus.name, campus.id]))
return campusIds
})
.finally(() => {
campusIdsRequest = null
})
}
return campusIdsRequest
}
async function resolveCampusId(campusName: WorkbenchCampus) {
const campusId = (await getCampusIds()).get(campusName)
if (!campusId) {
throw new Error('未找到院区:' + campusName)
}
return campusId
}
export async function getWorkbenchOverview(
query: WorkbenchOverviewQuery,
): Promise<WorkbenchOverviewResponse> {
if (useMockData) {
const overview = mockCampusOverviews[query.campus] ?? mockCampusOverviews['本部院区']
return Promise.resolve({
return {
summary: { ...overview.summary },
trend: overview.trend.map((point) => ({ ...point })),
todos: overview.todos.map((task) => ({ ...task })),
documentRanking: createRanking(overview.ranking, query.rankingPeriod),
})
}
}
return getCampuses().then(async (campuses) => {
const campus = campuses.find((item) => item.name === query.campus)
const campusId = query.campusId || (await resolveCampusId(query.campus))
const params: WorkbenchOverviewRequestDto = {
campusId,
period: query.rankingPeriod,
todoLimit: TODO_LIMIT,
rankingLimit: RANKING_LIMIT,
}
const response = await request.get<ApiResponse<WorkbenchOverviewResponseDto>>(
'/v1/workbench/overview',
{ params },
)
if (!campus) {
throw new Error(`未找到院区:${query.campus}`)
}
const campusIds = Object.fromEntries(campuses.map((item) => [item.name, item.id]))
const campusNames = Object.fromEntries(
campuses.filter((item) => isWorkbenchCampus(item.name)).map((item) => [item.id, item.name]),
) as Record<string, WorkbenchCampus>
const templates = await getSigningTemplates({ campusId: campus.id })
const tasks = await getAllSigningTasks(
{
page: 1,
pageSize: 200,
status: 'all',
dateRange: 'all',
campus: query.campus,
method: 'all',
},
{ templates, campusIds, campusNames },
)
return createLiveOverview(
tasks,
countAvailableTemplates(templates),
countCoveredDepartments(templates),
query.rankingPeriod,
)
})
return mapWorkbenchOverview(unwrapApiResponse(response))
}
+48 -2
View File
@@ -1,14 +1,23 @@
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
export type WorkbenchCampus = string
export type HomeRankingPeriod = 7 | 14 | 30
export interface WorkbenchOverviewQuery {
campus: WorkbenchCampus
campusId?: string
rankingPeriod: HomeRankingPeriod
}
/** /api/v1/workbench/overview 的请求 DTO。 */
export interface WorkbenchOverviewRequestDto {
campusId: string
period: HomeRankingPeriod
todoLimit?: number
rankingLimit?: number
}
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
export type PatientSex = '男' | '女' | '未知'
@@ -72,7 +81,8 @@ export interface WorkbenchTask {
updatedAt: string
}
export type WorkbenchTodoStatus = 'pending' | 'overdue'
export type WorkbenchTodoStatus =
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
export type SigningMethod = 'pad' | 'sms'
@@ -106,6 +116,42 @@ export interface WorkbenchOverviewResponse {
documentRanking: WorkbenchDocumentRanking[]
}
/** 后端工作台概览原始响应,组件只使用 WorkbenchOverviewResponse。 */
export interface WorkbenchOverviewResponseDto {
campusId: string
timezone: string
generatedAt: string
summary?: Partial<WorkbenchSummary> | null
trend?: WorkbenchOverviewTrendPointDto[] | null
todos?: WorkbenchOverviewTodoItemDto[] | null
documentRanking?: WorkbenchDocumentRankingDto[] | null
}
export interface WorkbenchOverviewTrendPointDto {
date: string
label: string
signedCount: number
}
export interface WorkbenchOverviewTodoItemDto {
taskId: string
patientId: string
patientName: string
documentName: string
departmentName: string
status: BackendSigningTaskStatus
signMethod: BackendSigningMethod
updatedAt: string
expiredAt?: string | null
}
export interface WorkbenchDocumentRankingDto {
templateId: string
documentName: string
departmentName: string
signedCount: number
}
export interface SigningTaskQuery extends PageQuery {
keyword?: string
status?: SigningTaskStatus | 'all'
+26 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
@@ -9,6 +9,21 @@ const appStore = useAppStore()
const pageTitle = computed(() => String(route.meta.title ?? '首页'))
const pageGroup = computed(() => String(route.meta.group ?? '工作台'))
const selectedCampusId = computed<string>({
get: () => appStore.selectedCampusId,
set: (id) => appStore.selectCampus(id),
})
const campusPlaceholder = computed(() => {
if (appStore.campusLoading) {
return '院区加载中…'
}
return appStore.campusLoadFailed ? '院区加载失败' : '暂无可用院区'
})
onMounted(() => {
void appStore.loadCampuses()
})
</script>
<template>
@@ -20,9 +35,16 @@ const pageGroup = computed(() => String(route.meta.group ?? '工作台'))
<span class="header-spacer" />
<label class="campus-selector">
<span class="campus-label">院区</span>
<select v-model="appStore.selectedCampus" aria-label="选择院区">
<option v-for="campus in appStore.campuses" :key="campus" :value="campus">
{{ campus }}
<select
v-model="selectedCampusId"
:disabled="appStore.campusLoading || !appStore.campusRecords.length"
aria-label="选择院区"
>
<option v-if="!appStore.campusRecords.length" value="">
{{ campusPlaceholder }}
</option>
<option v-for="campus in appStore.campusRecords" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</label>
+86 -3
View File
@@ -1,16 +1,99 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getCampuses } from '@/api/management/organization'
import type { CampusRecord } from '@/api/management/types'
import type { WorkbenchCampus } from '@/api/workbench/types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
const mockCampusRecords: CampusRecord[] = [
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
{ id: 'mock-campus-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
{ id: 'mock-campus-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
]
function getEnabledCampuses(records: CampusRecord[]) {
return [...records]
.filter((campus) => campus.status !== 'DISABLED')
.sort(
(left, right) => left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
)
}
export const useAppStore = defineStore('app', () => {
const environment = ref('接口联调环境')
const campuses = ref<WorkbenchCampus[]>(['本部院区', '东院区', '西院区'])
const selectedCampus = ref<WorkbenchCampus>('本部院区')
const campusRecords = ref<CampusRecord[]>(
useMockData ? getEnabledCampuses(mockCampusRecords) : [],
)
const selectedCampusId = ref(useMockData ? (campusRecords.value[0]?.id ?? '') : '')
const campusLoading = ref(false)
const campusLoaded = ref(useMockData)
const campusLoadFailed = ref(false)
let campusRequest: Promise<void> | null = null
const campuses = computed<WorkbenchCampus[]>(() =>
campusRecords.value.map((campus) => campus.name),
)
const selectedCampus = computed<WorkbenchCampus>(
() => campusRecords.value.find((campus) => campus.id === selectedCampusId.value)?.name ?? '',
)
function selectCampus(id: string) {
if (campusRecords.value.some((campus) => campus.id === id)) {
selectedCampusId.value = id
}
}
function ensureSelectedCampus() {
if (!campusRecords.value.some((campus) => campus.id === selectedCampusId.value)) {
selectedCampusId.value = campusRecords.value[0]?.id ?? ''
}
}
async function loadCampuses(force = false): Promise<void> {
if (useMockData) {
return
}
if (!force && campusLoaded.value) {
return
}
if (campusRequest) {
return campusRequest
}
campusLoading.value = true
campusLoadFailed.value = false
const request = getCampuses()
.then((records) => {
campusRecords.value = getEnabledCampuses(records)
ensureSelectedCampus()
campusLoaded.value = true
})
.catch(() => {
campusLoadFailed.value = true
})
.finally(() => {
campusLoading.value = false
campusRequest = null
})
campusRequest = request
return request
}
return {
environment,
campuses,
campusRecords,
selectedCampusId,
selectedCampus,
campusLoading,
campusLoaded,
campusLoadFailed,
loadCampuses,
selectCampus,
}
})
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HomeTrendPoint } from '../types'
import type { HomeRankingPeriod, HomeTrendPoint } from '../types'
const props = defineProps<{
points: HomeTrendPoint[]
period: HomeRankingPeriod
}>()
const chartWidth = 620
@@ -63,12 +64,17 @@ const gridLines = computed(() =>
<template>
<section class="home-panel trend-panel">
<div class="panel-heading">
<h2>近一周签署客户趋势</h2>
<span>单位:人次</span>
<h2>近{{ period }}天签署趋势</h2>
<span>单位:单</span>
</div>
<div v-if="points.length" class="trend-chart-wrap">
<svg class="trend-chart" viewBox="0 0 620 210" role="img" aria-label="近一周签署客户趋势图">
<svg
class="trend-chart"
viewBox="0 0 620 210"
role="img"
:aria-label="'近' + period + '天签署趋势图'"
>
<defs>
<linearGradient id="home-trend-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0e6e8c" stop-opacity="0.22" />
@@ -11,11 +11,22 @@ const emit = defineEmits<{
}>()
function getStatusLabel(item: HomeTodoItem) {
if (item.status === 'overdue') {
return '已超时'
switch (item.status) {
case 'created':
return '待发送'
case 'pending':
return item.method === 'pad' ? '待签署·手写板' : '待签署·短信'
case 'generating':
return '文书生成中'
case 'overdue':
return '已超时'
case 'signed':
return '已签署'
case 'voided':
return '已作废'
case 'failed':
return '处理失败'
}
return item.method === 'pad' ? '待签署·手写板' : '待签署·短信'
}
function getStatusClass(item: HomeTodoItem) {
@@ -23,11 +34,38 @@ function getStatusClass(item: HomeTodoItem) {
return 'todo-status--overdue'
}
return item.method === 'pad' ? 'todo-status--pad' : 'todo-status--sms'
if (item.status === 'pending') {
return item.method === 'pad' ? 'todo-status--pad' : 'todo-status--sms'
}
if (item.status === 'signed') {
return 'todo-status--success'
}
if (item.status === 'failed') {
return 'todo-status--danger'
}
return 'todo-status--muted'
}
function getHint(item: HomeTodoItem) {
return item.status === 'overdue' ? '⚠ 已超时,需重新发起' : '⏳ 等待患者签署'
switch (item.status) {
case 'created':
return '⏳ 等待发送'
case 'pending':
return '⏳ 等待患者签署'
case 'generating':
return '⌛ 正在生成签署文书'
case 'overdue':
return '⚠ 已超时,需重新发起'
case 'signed':
return '✓ 已完成签署'
case 'voided':
return '— 任务已作废'
case 'failed':
return '⚠ 任务处理失败'
}
}
</script>
@@ -170,11 +208,22 @@ function getHint(item: HomeTodoItem) {
background: #f3ecfb;
}
.todo-status--overdue {
.todo-status--overdue,
.todo-status--danger {
color: var(--err);
background: var(--err-l);
}
.todo-status--success {
color: var(--ok);
background: var(--ok-l);
}
.todo-status--muted {
color: var(--mut);
background: #eef3f5;
}
.todo-row--detail {
margin-top: 5px;
color: var(--mut);
@@ -89,8 +89,16 @@ async function loadOverview() {
loadFailed.value = false
try {
await appStore.loadCampuses()
const campusId = appStore.selectedCampusId
if (!campusId) {
throw new Error('当前用户没有可用院区')
}
const nextOverview = await getWorkbenchOverview({
campus: selectedCampus.value,
campusId,
rankingPeriod: rankingPeriod.value,
})
if (requestId !== overviewRequestId) {
@@ -189,7 +197,7 @@ onMounted(() => {
</div>
<div class="overview-grid">
<SigningTrendPanel :points="trendPoints" />
<SigningTrendPanel :points="trendPoints" :period="rankingPeriod" />
<TodoPanel :items="todoItems" @select="handleTodoSelect" @view-all="goToSigning()" />
</div>
@@ -18,7 +18,8 @@ export interface HomeTrendPoint {
value: number
}
export type HomeTodoStatus = 'pending' | 'overdue'
export type HomeTodoStatus =
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
export type HomeSigningMethod = 'pad' | 'sms'
@@ -53,7 +53,6 @@ const STATUS_VALUES: SigningTaskStatus[] = [
'failed',
]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
const CAMPUS_VALUES: WorkbenchCampus[] = ['本部院区', '东院区', '西院区']
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
@@ -165,7 +164,7 @@ function isSigningDateRange(value: string): value is SigningDateRange {
}
function isWorkbenchCampus(value: string): value is WorkbenchCampus {
return CAMPUS_VALUES.includes(value as WorkbenchCampus)
return appStore.campuses.includes(value)
}
function syncFilterFromRoute() {
@@ -201,12 +200,7 @@ async function loadOrganizationOptions() {
campusesResult.value.map((campus) => [campus.name, campus.id]),
)
campusNames.value = Object.fromEntries(
campusesResult.value
.filter(
(campus): campus is typeof campus & { name: WorkbenchCampus } =>
campus.name === '本部院区' || campus.name === '东院区' || campus.name === '西院区',
)
.map((campus) => [campus.id, campus.name]),
campusesResult.value.map((campus) => [campus.id, campus.name]),
)
}
@@ -558,6 +552,8 @@ async function handleOnlineResend() {
}
async function initializePage() {
await appStore.loadCampuses()
syncFilterFromRoute()
await loadOrganizationOptions()
await loadTemplates()
await loadTasks()
@@ -589,7 +585,6 @@ watch(
)
onMounted(() => {
syncFilterFromRoute()
void initializePage()
})
</script>