diff --git a/clinical-web/README.md b/clinical-web/README.md index b672066..35b2d78 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -163,6 +163,8 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 报表页在真实模式下使用 `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` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。 diff --git a/clinical-web/docs/workbench-home-api.md b/clinical-web/docs/workbench-home-api.md new file mode 100644 index 0000000..ade80fe --- /dev/null +++ b/clinical-web/docs/workbench-home-api.md @@ -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` 直接打开详情,即使任务不在签署工作台当前第一页。 diff --git a/clinical-web/src/api/workbench/home.ts b/clinical-web/src/api/workbench/home.ts index e254325..8a4c36a 100644 --- a/clinical-web/src/api/workbench/home.ts +++ b/clinical-web/src/api/workbench/home.ts @@ -8,7 +8,10 @@ import type { WorkbenchDocumentRanking, WorkbenchOverviewQuery, WorkbenchOverviewResponse, + SigningApiOptions, + SigningTemplate, SigningTaskRecord, + SigningTaskQuery, WorkbenchTodoTask, WorkbenchTrendPoint, } from './types' @@ -26,6 +29,7 @@ const mockCampusOverviews: Record = { 本部院区: { summary: { todaySigned: 36, + todaySignedChange: 2, pendingPatientSigning: 18, todayOverdue: 2, availableTemplates: 128, @@ -93,6 +97,7 @@ const mockCampusOverviews: Record = { 东院区: { summary: { todaySigned: 22, + todaySignedChange: 1, pendingPatientSigning: 11, todayOverdue: 1, availableTemplates: 76, @@ -160,6 +165,7 @@ const mockCampusOverviews: Record = { 西院区: { summary: { todaySigned: 17, + todaySignedChange: 2, pendingPatientSigning: 9, todayOverdue: 2, availableTemplates: 54, @@ -246,6 +252,47 @@ function createRanking( .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 { + 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 { return value === '本部院区' || value === '东院区' || value === '西院区' } @@ -268,8 +315,31 @@ function isSameDay(left: Date | null, right: Date) { ) } -function getTaskDate(task: SigningTaskRecord) { - return parseDate(task.createdAt) ?? parseDate(task.updatedAt) +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) { @@ -284,10 +354,15 @@ function createLiveOverview( ): WorkbenchOverviewResponse { const today = new Date() 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 todayOverdue = tasks.filter( - (task) => task.status === 'expired' && isSameDay(getTaskDate(task), today), + (task) => task.status === 'expired' && isSameDay(getExpiredDate(task), today), ).length const trend = Array.from({ length: rankingPeriod }, (_, index) => { @@ -297,15 +372,15 @@ function createLiveOverview( return { 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 .filter((task) => isPendingTask(task) || task.status === 'expired') .sort((left, right) => { - const leftDate = getTaskDate(left)?.getTime() ?? 0 - const rightDate = getTaskDate(right)?.getTime() ?? 0 + const leftDate = getUpdatedDate(left)?.getTime() ?? 0 + const rightDate = getUpdatedDate(right)?.getTime() ?? 0 return rightDate - leftDate }) .slice(0, 8) @@ -321,19 +396,22 @@ function createLiveOverview( })) const rankingMap = new Map() - signedTasks.forEach((task) => { - const current = rankingMap.get(task.documentName) - rankingMap.set(task.documentName, { - id: current?.id ?? task.documentId, - documentName: task.documentName, - department: task.department, - signedCount: (current?.signedCount ?? 0) + 1, + 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, + }) }) - }) return { summary: { todaySigned, + todaySignedChange: todaySigned - yesterdaySigned, pendingPatientSigning: pendingTasks.length, todayOverdue, availableTemplates: templateCount, @@ -361,38 +439,35 @@ export function getWorkbenchOverview( }) } - return Promise.allSettled([getSigningTemplates(), getCampuses()]).then( - async ([templatesResult, campusesResult]) => { - const templates = templatesResult.status === 'fulfilled' ? templatesResult.value : [] - const campusNames: Record = - campusesResult.status === 'fulfilled' - ? (Object.fromEntries( - campusesResult.value - .filter((campus) => isWorkbenchCampus(campus.name)) - .map((campus) => [campus.id, campus.name]), - ) as Record) - : {} - const tasksResponse = await getSigningTasks( - { - page: 1, - pageSize: 200, - status: 'all', - dateRange: 'all', - campus: 'all', - method: 'all', - }, - { templates, campusNames }, - ) - const scopedTasks = Object.keys(campusNames).length - ? tasksResponse.records.filter((task) => task.campus === query.campus) - : tasksResponse.records + return getCampuses().then(async (campuses) => { + const campus = campuses.find((item) => item.name === query.campus) - return createLiveOverview( - scopedTasks, - templates.length, - new Set(templates.map((template) => template.department)).size, - query.rankingPeriod, - ) - }, - ) + 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, + ) + }) } diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index a43b3eb..af1fbe0 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -55,6 +55,7 @@ export interface SigningTemplate { export interface WorkbenchSummary { todaySigned: number + todaySignedChange: number pendingPatientSigning: number todayOverdue: number availableTemplates: number diff --git a/clinical-web/src/views/workbench/home/index.vue b/clinical-web/src/views/workbench/home/index.vue index 9b95822..076e934 100644 --- a/clinical-web/src/views/workbench/home/index.vue +++ b/clinical-web/src/views/workbench/home/index.vue @@ -27,11 +27,21 @@ const loading = ref(true) const loadFailed = ref(false) const overview = ref(null) const rankingPeriod = ref(14) +let overviewRequestId = 0 const selectedCampus = computed(() => appStore.selectedCampus) const metrics = computed(() => { 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 [ { @@ -39,8 +49,8 @@ const metrics = computed(() => { label: '今日已签署(单)', value: summary?.todaySigned ?? 0, tone: 'success', - detail: '▲ 较昨日 +2 · 点击查看', - detailTone: 'up', + detail: signedChangeDetail, + detailTone: signedChangeTone, }, { key: 'pending', @@ -74,26 +84,42 @@ const todoItems = computed(() => overview.value?.todos ?? []) const rankingRows = computed(() => overview.value?.documentRanking ?? []) async function loadOverview() { + const requestId = ++overviewRequestId loading.value = true loadFailed.value = false try { - overview.value = await getWorkbenchOverview({ + const nextOverview = await getWorkbenchOverview({ campus: selectedCampus.value, rankingPeriod: rankingPeriod.value, }) + if (requestId !== overviewRequestId) { + return + } + + overview.value = nextOverview } catch { + if (requestId !== overviewRequestId) { + return + } + + overview.value = null loadFailed.value = true ElMessage.error('首页数据加载失败,请稍后重试') } finally { - loading.value = false + if (requestId === overviewRequestId) { + loading.value = false + } } } function goToSigning(query: Record = {}) { void router.push({ name: 'workbench-signing', - query, + query: { + campus: selectedCampus.value, + ...query, + }, }) } diff --git a/clinical-web/src/views/workbench/signing/index.vue b/clinical-web/src/views/workbench/signing/index.vue index 505e4f8..b1389a8 100644 --- a/clinical-web/src/views/workbench/signing/index.vue +++ b/clinical-web/src/views/workbench/signing/index.vue @@ -53,6 +53,7 @@ const STATUS_VALUES: SigningTaskStatus[] = [ 'failed', ] const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all'] +const CAMPUS_VALUES: WorkbenchCampus[] = ['本部院区', '东院区', '西院区'] const statusLabels: Record = { pending: '待签署', @@ -163,12 +164,18 @@ function isSigningDateRange(value: string): value is 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() { const status = getQueryString(route.query.status) const range = getQueryString(route.query.range) + const campus = getQueryString(route.query.campus) filter.status = status === 'all' || isSigningStatus(status) ? status : 'all' filter.dateRange = isSigningDateRange(range) ? range : 'all' + filter.campus = isWorkbenchCampus(campus) ? campus : 'all' } 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 routeTaskId = getQueryString(route.query.taskId) - const nextTaskId = - [preferredTaskId, selectedTaskId.value, routeTaskId].find((id) => id && taskIds.has(id)) ?? - response.records[0]?.id ?? - null + const matchedTaskId = [preferredTaskId, selectedTaskId.value, routeTaskId].find( + (id) => id && taskIds.has(id), + ) + const nextTaskId = matchedTaskId ?? (routeTaskId || response.records[0]?.id || null) selectedTaskId.value = nextTaskId await loadTaskDetail(nextTaskId) @@ -561,7 +568,7 @@ async function handleTaskCreated(task: SigningTaskRecord) { } watch( - () => [route.query.status, route.query.range, route.query.taskId], + () => [route.query.status, route.query.range, route.query.campus, route.query.taskId], () => { syncFilterFromRoute() page.value = 1