diff --git a/clinical-web/README.md b/clinical-web/README.md index ffc0a3f..1711ed6 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -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,不向后端发送患者快照、文档名称或明文手机号等页面字段。 diff --git a/clinical-web/docs/workbench-home-api.md b/clinical-web/docs/workbench-home-api.md index ade80fe..d732f59 100644 --- a/clinical-web/docs/workbench-home-api.md +++ b/clinical-web/docs/workbench-home-api.md @@ -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 中不再出现首页为统计目的而循环拉取全部签署任务或模板的请求。 diff --git a/clinical-web/src/api/management/organization.ts b/clinical-web/src/api/management/organization.ts index 6b6e3e0..f0a0bd7 100644 --- a/clinical-web/src/api/management/organization.ts +++ b/clinical-web/src/api/management/organization.ts @@ -25,7 +25,9 @@ export function getCampuses(): Promise { } return request - .get>>('/v1/campuses') + .get>>('/v1/campuses', { + params: { page: 1, size: 200 }, + }) .then(unwrapApiResponse) .then((data) => getRecords(data).map((item) => ({ diff --git a/clinical-web/src/api/management/reports.ts b/clinical-web/src/api/management/reports.ts index 758f8ca..6762591 100644 --- a/clinical-web/src/api/management/reports.ts +++ b/clinical-web/src/api/management/reports.ts @@ -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, } diff --git a/clinical-web/src/api/workbench/home.ts b/clinical-web/src/api/workbench/home.ts index 8a4c36a..8566a99 100644 --- a/clinical-web/src/api/workbench/home.ts +++ b/clinical-web/src/api/workbench/home.ts @@ -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 | null = null +let campusIdsRequest: Promise> | 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 { - 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() - 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((point) => ({ + label: point.label || point.date, + value: point.signedCount, + })), + todos: (dto.todos ?? []).map((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((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 { 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>( + '/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 - 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)) } diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index af1fbe0..d85f66a 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -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 | 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' diff --git a/clinical-web/src/components/layout/Header.vue b/clinical-web/src/components/layout/Header.vue index eff50f1..6a46b16 100644 --- a/clinical-web/src/components/layout/Header.vue +++ b/clinical-web/src/components/layout/Header.vue @@ -1,5 +1,5 @@