From f155c5154fdacb59feff916598459feb2dc85817 Mon Sep 17 00:00:00 2001 From: xy Date: Wed, 30 Sep 2026 18:27:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=8A=A5=E8=A1=A8=E5=88=86=E6=9E=90=E5=8F=8A=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=98=8E=E7=BB=86=E4=B8=8E=E5=BC=82=E6=AD=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/.env.example | 3 - clinical-web/README.md | 26 +- .../src/api/workbench/report-analytics.ts | 659 ++++++++++-------- clinical-web/src/utils/mock-flags.ts | 10 - clinical-web/src/views/reports/aggregate.ts | 444 +----------- .../reports/components/ReportExportDialog.vue | 182 +++-- .../reports/components/ReportFilterBar.vue | 14 +- .../reports/components/ReportPageShell.vue | 102 ++- .../components/ReportSummaryMatrix.vue | 39 +- .../src/views/reports/overview/index.vue | 158 ++++- .../views/reports/patient-structure/index.vue | 47 +- .../src/views/reports/timeliness/index.vue | 71 +- .../src/views/reports/use-report-dataset.ts | 124 ++-- .../src/views/reports/volume/index.vue | 31 +- 14 files changed, 928 insertions(+), 982 deletions(-) diff --git a/clinical-web/.env.example b/clinical-web/.env.example index 5a3df2f..ed0ec11 100644 --- a/clinical-web/.env.example +++ b/clinical-web/.env.example @@ -20,9 +20,6 @@ VITE_API_BASE_URL=/api # VITE_MOCK_SIGN_RECORDS=true # VITE_MOCK_DOCUMENTS=false # VITE_MOCK_DOCUMENT_PERMISSIONS=false -# 报表分析目录下的四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。 -# 后端目前没有任何分析聚合接口,缺省开启演示数据;关掉只会得到「后端尚未提供接口」的空态。 -# VITE_MOCK_REPORT_ANALYTICS=true # VITE_MOCK_USERS=false # VITE_MOCK_ORGANIZATION=false # VITE_MOCK_SETTINGS=false diff --git a/clinical-web/README.md b/clinical-web/README.md index 92c97b3..9b87d1c 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -118,9 +118,9 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供) └─ /system/setting 系统设置 / 菜单设置 ``` -「系统管理 → 报表分析」(`/report/index`,签署报表与导出任务,走 `reports/signing/*` 接口)已整体下线:页面、接口模块、`VITE_MOCK_REPORTS` 开关与路由别名一并移除。后端菜单资源树暂时仍会下发该菜单,前端在 `stores/login.ts` 的 `RETIRED_MENU_PATHS` 里统一屏蔽,侧边栏不展示、路由守卫也会重定向走,不会出现「前端暂未接入组件」的死链;后端下架该菜单资源后可以删掉这个集合。 +「系统管理 → 报表分析」(旧 `/report/index` 菜单)仍由 `stores/login.ts` 的 `RETIRED_MENU_PATHS` 屏蔽,避免旧菜单资源形成死链。它与下面四个独立分析菜单使用不同的页面路由。 -`/report/sign/overview` 等四个是**另一套**:「报表分析」目录下的四个独立菜单,后端目前没有任何分析聚合接口,因此只有演示数据(`VITE_MOCK_REPORT_ANALYTICS`)。路由与 `componentPath` 都以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。 +`/report/sign/overview` 等四个页面是「报表分析」目录下的独立菜单。后端 V34 迁移创建菜单资源并授权给系统管理员、医务管理和审计员;路由与 `componentPath` 仍以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。 布局拆分为 `Menu`、`Header` 和 `Container` 三个公共组件,页面内容由各自的 View 负责。 @@ -257,31 +257,29 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` ## 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析) -四个菜单共用一套数据源与筛选条件: +四个菜单共用一套后端数据源与筛选条件: -- `api/workbench/report-analytics.ts`:数据集。**后端没有任何分析聚合接口**,真实模式返回 `{ tasks: [], available: false }` 而不抛错,页面据此显示「后端尚未提供报表分析接口」的说明,不渲染一张全 0 的假报表。 -- `views/reports/aggregate.ts`:全部聚合逻辑,纯函数(不依赖 DOM 与 Vue),可用 Node 直跑断言。 +- `api/workbench/report-analytics.ts`:报表查询、下钻分页和导出任务客户端。 +- 后端统一使用 `/api/v1/reports/signing`:`GET /options` 提供筛选项,`GET /analytics` 一次返回状态、趋势、分组、时效与人口统计聚合,`GET /analytics/tasks` 分页返回分组下钻明细;接口沿用 `report:sign:view` 权限及当前用户的数据范围。 +- `views/reports/aggregate.ts`:日期窗口、展示标签与时长格式化等前端辅助逻辑;聚合指标由数据库计算,浏览器不再拉取全量任务后自行统计。 - `views/reports/filter-store.ts`:**四个菜单共用一份筛选条件**(模块级 `reactive`)。原型里它们是同一筛选条下的四个页签,各存一份会导致切页时筛选被重置、四个页面的数字看起来互相矛盾。 -- `views/reports/components/ReportPageShell.vue`:标题 + 筛选条 + 数据可用性闸门 + 导出弹窗,四个页面共用;页面只填自己的图表内容。 +- `views/reports/components/ReportPageShell.vue`:读取后端筛选项、共用筛选条、数据加载状态与导出弹窗;页面只填自己的图表内容。 - 图表是手写内联 SVG(`ReportBarChart` / `ReportBarList`),不引图表库:只需要柱状图与横向条形榜两种形态,引库要多几百 KB 且读不到主题 CSS 变量。渐变 id 用 `useId()` 取每实例唯一值。 +- 下钻和超期清单只返回脱敏患者姓名与患者标识;人口结构接口只返回数据库聚合计数,不向浏览器传输患者姓名、年龄或性别明细。 +- 导出通过 `POST /export-jobs` 创建异步任务,`GET /export-jobs/{jobId}` 轮询状态,完成后调用响应中的下载路径。后端按白名单导出脱敏字段并记录审计。部署需设置 `medisign.sign.report.export.worker.enabled=true` 启动导出 worker;默认关闭。 -**演示数据的两条硬约束**:① 用固定种子的 PRNG(mulberry32),不用 `Math.random` —— 否则每次刷新数字都在跳,截图对不上、断言写不了;② 时间锚定「距今天数」,不写死日期 —— 写死之后过几天再打开,近 7 天 / 近 14 天 / 本月全是空的,看起来像筛选坏了。 - -**边界**:① 时效指标只统计**已签署**任务(未完成任务不是「签得快」而是「还没签完」,计入会稀释均值),样本为 0 时显示「—」而不是 0;② 患者结构统计的是**人次**而非人数,同一患者多次签署会重复计数,页面上已写明;③ 导出是前端按条件生成 CSV(UTF-8 带 BOM、CRLF),与签署记录查询共用 `utils/csv-export.ts`。**后端提供导出任务后应改为服务端导出**:导出范围与导出人必须进审计日志,浏览器里做不到。真实接入聚合接口时,后端应按页面出四个聚合接口,**不要**照搬现在的形状把全量任务明细拉到浏览器里算。 - -演示数据缺省开启(`VITE_MOCK_REPORT_ANALYTICS`)。 +**统计口径**:时效指标只统计已签署任务;样本为 0 时显示「—」。患者结构统计的是任务人次而非去重人数,同一患者的多次签署会重复计数。筛选支持后端任务实际使用的 PAD 与 SMS 两种签署方式。 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_PROXY_TARGET=http://127.0.0.1:18080 npm run dev` 覆盖目标。修改代理或环境变量后需要重启 Vite 开发服务。 +开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到本机 `http://127.0.0.1:18080`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径;启动日志会显示实际代理目标。要联调测试服务器,可用 `VITE_API_PROXY_TARGET=https://ipad.shenynet.com npm run dev` 覆盖目标。修改代理或环境变量后需要重启 Vite 开发服务。 -生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省使用演示数据的只有签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)与「报表分析」目录下的四个菜单(`VITE_MOCK_REPORT_ANALYTICS`,后端没有分析聚合接口),其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 +生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省使用演示数据的只有签署工作台(`VITE_MOCK_SIGNING`)和签署记录查询(`VITE_MOCK_SIGN_RECORDS`);报表分析使用真实后端接口。各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。 文档库正文编辑器:选中「患者签名」占位符后,可以自动识别 PDF 位置,或生成 PDF 预览后输入坐标。阴影框支持拖动定位,宽高输入即时更新;点击「更新该项」或保存正文写入位置。正文变化后需重新生成预览。预览使用示例患者,实际患者文字长度可能影响排版,建议为填写项预留固定空间。重复的患者签名占位符不会自动选取。后端在保存及发布时检查页码与页面边界。 - ### 工作台患者填写项 真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。 diff --git a/clinical-web/src/api/workbench/report-analytics.ts b/clinical-web/src/api/workbench/report-analytics.ts index 30cadd8..48d243d 100644 --- a/clinical-web/src/api/workbench/report-analytics.ts +++ b/clinical-web/src/api/workbench/report-analytics.ts @@ -1,328 +1,427 @@ -/** - * 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)的数据源。 - * - * 现状:后端没有任何分析聚合接口,所以这四个菜单目前只有演示数据。 - * 这里刻意**返回任务级明细**而不是预先算好的聚合结果,原因是演示阶段聚合逻辑 - * 要能被页面与验证脚本反复调用(换筛选条件、下钻、导出都基于同一份明细)。 - * - * ⚠️ 真实接入时不要照搬这个形状:一次分析查询不该把全量任务明细拉到浏览器里算。 - * 后端应按页面给出四个聚合接口(总览指标 + 趋势、签署量分组、时效指标 + 分组、 - * 患者结构分组),届时这个模块退化成「按查询参数取聚合结果」, - * 页面侧的聚合函数随之删掉即可 —— 页面只消费聚合结果,不关心它从哪来。 - * - * 演示数据的两条硬约束(都踩过): - * 1. **确定性**:用固定种子的 PRNG,不用 Math.random。否则每次刷新数字都在跳, - * 截图对不上、验证脚本没法断言「筛选后行数应该变少」。 - * 2. **锚定「距今天数」**:任务的日期按「N 天前」生成,不写死具体日期。 - * 写死日期后过几天再打开,近 7 天 / 近 14 天 / 本月 全是空的, - * 演示时看起来像筛选坏了。 - */ +import type { PageResult, ApiResponse } from '@/types/common' +import { unwrapApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' -import { mockFlags } from '@/utils/mock-flags' +import type { SigningMethod, SigningTaskStatus, WorkbenchCampus } from './types' -import { mockDocumentCatalog, campusOfDepartment } from './mock-catalog' -import type { - PatientSex, - SigningMethod, - SigningTaskStatus, - VisitType, - WorkbenchCampus, -} from './types' - -const useMockData = mockFlags.reportAnalytics - -export const isReportAnalyticsMockEnabled = useMockData - -/** - * 演示数据覆盖的天数。 - * 比最长的时间筛选(本月按 1 号起算,最多 31 天)略短,够用且不至于让 - * 「本月」看起来总是差几天;真要展示整月,把这里调到 31 即可。 - */ -const DEMO_SPAN_DAYS = 30 - -/** 演示任务量级:约 5 条/天,够让趋势图与排行榜有形状,又不会让表格卡顿 */ -const DEMO_TASK_COUNT = 152 - -/** 一条报表任务行:聚合所需的全部维度都在这里,页面不再回查其它数据源 */ export interface ReportTaskRow { id: string patientId: string patientName: string - sex: PatientSex - age: number - visitType: VisitType - visitNo: string campus: WorkbenchCampus department: string documentName: string category: string method: SigningMethod status: SigningTaskStatus - /** 发起时间,`YYYY-MM-DD HH:mm`(与签署记录同一格式,便于直接比较与展示) */ createdAt: string - /** 签署完成时间;未完成的任务没有这一项 */ signedAt?: string - /** 签署用时(分钟);只有已签署的任务有。超 1440 为超 24 小时,超 2880 为超期完成 */ signMinutes?: number } +export interface ReportGroupRow { + key: string + total: number + signed: number + confirming: number + pending: number + signing: number + expired: number + void: number + failed: number + completionRate: number +} + +export interface ReportBarItem { + label: string + value: number + display?: string + color?: string +} + export interface ReportAnalyticsDataset { - tasks: ReportTaskRow[] - /** - * 数据是否可用。真实模式下没有后端接口,这里返回 false, - * 页面据此显示「后端尚未提供分析接口」的说明,而不是渲染一张全 0 的假报表。 - */ - available: boolean - /** 数据生成时刻(`YYYY-MM-DD HH:mm`),页面用于「数据截至」提示 */ + timezone: string generatedAt: string -} - -/* ========================================================================= - 确定性随机 - ========================================================================= */ - -/** mulberry32:小、快、固定种子可复现 */ -function createRandom(seed: number): () => number { - let state = seed >>> 0 - - return () => { - state = (state + 0x6d2b79f5) >>> 0 - let t = state - t = Math.imul(t ^ (t >>> 15), t | 1) - t ^= t + Math.imul(t ^ (t >>> 7), t | 61) - - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + fromDate: string + toDate: string + statusCounts: Omit + trend: { key: string; label: string; created: number; signed: number }[] + departments: ReportGroupRow[] + categories: ReportGroupRow[] + methods: ReportGroupRow[] + campuses: ReportGroupRow[] + documents: ReportGroupRow[] + timeliness: { + averageMinutes: number | null + within24hRate: number | null + overdueRate: number | null + expiredTaskCount: number + signedSampleCount: number + departmentAverages: ReportBarItem[] + dailyAverages: { date: string; averageMinutes: number; sampleCount: number }[] + overdueTaskCount: number + overdueTasks: { + id: string + patientId: string + patientName: string + department: string + documentName: string + createdAt: string + signedAt: string + signMinutes: number + }[] + } + patientStructure: { + age: ReportBarItem[] + sex: ReportBarItem[] + visitType: ReportBarItem[] + campus: ReportBarItem[] } } -function pick(random: () => number, items: readonly T[]): T { - return items[Math.floor(random() * items.length)] as T +export interface ReportOptions { + campuses: { id: string; name: string }[] + departments: { id: string; campusId: string; name: string }[] + templateCategories: string[] } -function intBetween(random: () => number, min: number, max: number): number { - return min + Math.floor(random() * (max - min + 1)) +export interface ReportAnalyticsQuery { + createdFrom: string + createdTo: string + timezone: string + campusId?: string + departmentId?: string + templateCategory?: string + signMethod?: 'PAD' | 'SMS' } -/* ========================================================================= - 演示患者池 - ========================================================================= */ - -interface DemoPatient { - name: string - sex: PatientSex - age: number - visitType: VisitType +export interface ReportFilterInput { + dateRange: '7d' | '14d' | 'month' | 'custom' + customFrom: string + customTo: string + campus: string + department: string + category: string + method: SigningMethod | 'all' } -/** - * 患者池。 - * - * 含 3 名未成年患者:儿科知情同意同样由监护人签署,报表统计的是患者本人年龄。 - * 未成年占比刻意保持在三成以下(3/24),与「综合医院里儿科签署只是小部分」的实际相符。 - * - * ⚠️ **不要把「每个年龄段在任意时间窗内都非空」当作验收标准**:未成年任务只有十几条, - * 近 7 天窗口里恰好一条都没有是正常的(实测 2026-09-24 当天就是这样)。 - * 年龄段分档的正确性由分档边界(17/18、40/41、60/61)来保证,不是由「每档都非空」。 - * 为了让窄窗口好看而硬塞样本,会让年龄段分布失去真实形状。 - */ -const DEMO_PATIENTS: DemoPatient[] = [ - { name: '周雅琴', sex: '女', age: 29, visitType: '门诊' }, - { name: '李淑芬', sex: '女', age: 67, visitType: '住院' }, - { name: '赵美英', sex: '女', age: 58, visitType: '住院' }, - { name: '陈志强', sex: '男', age: 61, visitType: '住院' }, - { name: '王建国', sex: '男', age: 71, visitType: '住院' }, - { name: '孙浩然', sex: '男', age: 54, visitType: '门诊' }, - { name: '刘伟', sex: '男', age: 45, visitType: '门诊' }, - { name: '吴桂芳', sex: '女', age: 73, visitType: '住院' }, - { name: '黄志明', sex: '男', age: 66, visitType: '门诊' }, - { name: '郑海燕', sex: '女', age: 38, visitType: '门诊' }, - { name: '徐丽华', sex: '女', age: 50, visitType: '体检' }, - { name: '马晓东', sex: '男', age: 42, visitType: '其他' }, - { name: '林俊杰', sex: '男', age: 34, visitType: '体检' }, - { name: '何秀英', sex: '女', age: 62, visitType: '住院' }, - { name: '邓小燕', sex: '女', age: 27, visitType: '门诊' }, - { name: '曹明辉', sex: '男', age: 57, visitType: '住院' }, - { name: '袁丽', sex: '女', age: 19, visitType: '门诊' }, - { name: '沈国强', sex: '男', age: 78, visitType: '住院' }, - { name: '崔文静', sex: '女', age: 46, visitType: '体检' }, - { name: '侯志远', sex: '男', age: 36, visitType: '门诊' }, - { name: '龚雨萱', sex: '女', age: 6, visitType: '门诊' }, - { name: '范子墨', sex: '男', age: 14, visitType: '住院' }, - { name: '柳思彤', sex: '女', age: 16, visitType: '门诊' }, - { name: '严志刚', sex: '男', age: 52, visitType: '其他' }, -] +interface ReportGroupDto { + key: string + total: number + signed: number + confirming: number + pending: number + signing: number + expired: number + voided: number + failed: number + completionRate: number +} -/* ========================================================================= - 演示任务生成 - ========================================================================= */ - -/** 状态分布:已签署占多数,其余各留一点,七种状态在总览统计块里都看得到 */ -const STATUS_WEIGHTS: { status: SigningTaskStatus; weight: number }[] = [ - { status: 'signed', weight: 62 }, - { status: 'confirming', weight: 6 }, - { status: 'pending', weight: 9 }, - { status: 'signing', weight: 5 }, - { status: 'expired', weight: 8 }, - { status: 'void', weight: 5 }, - { status: 'failed', weight: 5 }, -] - -const STATUS_TOTAL_WEIGHT = STATUS_WEIGHTS.reduce((sum, item) => sum + item.weight, 0) - -function pickStatus(random: () => number): SigningTaskStatus { - let roll = random() * STATUS_TOTAL_WEIGHT - - for (const item of STATUS_WEIGHTS) { - roll -= item.weight - - if (roll <= 0) { - return item.status - } +interface ReportAnalyticsDto { + timezone: string + generatedAt: string + fromDate: string + toDate: string + statusCounts: { + total: number + signed: number + confirming: number + pending: number + signing: number + expired: number + voided: number + failed: number } - - return 'signed' -} - -/** - * 四种签署方式的抽样权重。 - * - * 现场两种(手写板 / Pad)占比高,线上两种(短信 / 公众号)略低 —— 四种都要出现, - * 否则「签署方式分布」这个报表维度会缺行,演示时看不到新方式。 - * 权重和不必是 100,`pickMethod` 会自己算总和。 - */ -const METHOD_WEIGHTS: readonly { method: SigningMethod; weight: number }[] = [ - { method: 'pad', weight: 38 }, - { method: 'ipad', weight: 24 }, - { method: 'sms', weight: 26 }, - { method: 'wx', weight: 12 }, -] - -const METHOD_TOTAL_WEIGHT = METHOD_WEIGHTS.reduce((sum, item) => sum + item.weight, 0) - -function pickMethod(random: () => number): SigningMethod { - let roll = random() * METHOD_TOTAL_WEIGHT - - for (const item of METHOD_WEIGHTS) { - roll -= item.weight - - if (roll <= 0) { - return item.method - } + trend: { date: string; created: number; signed: number }[] + departments: ReportGroupDto[] + categories: ReportGroupDto[] + methods: ReportGroupDto[] + campuses: ReportGroupDto[] + documents: ReportGroupDto[] + timeliness: { + averageMinutes: number | null + within24hRate: number | null + overdueRate: number | null + expiredTaskCount: number + signedSampleCount: number + departmentAverages: { label: string; averageMinutes: number; sampleCount: number }[] + dailyAverages: { label: string; averageMinutes: number; sampleCount: number }[] + overdueTaskCount: number + overdueTasks: { + taskId: string + patientDisplayId: string + patientNameMasked: string + department: string + documentName: string + createdAt: string + signedAt: string + elapsedMinutes: number + }[] + } + patientStructure: { + age: { label: string; value: number }[] + sex: { label: string; value: number }[] + visitType: { label: string; value: number }[] + campus: { label: string; value: number }[] } - - return 'pad' } -function pad(value: number): string { - return String(value).padStart(2, '0') +interface ReportOptionsDto { + campuses: { id: string; name: string }[] + departments: { id: string; campusId: string; name: string }[] + templateCategories: string[] } -function formatMoment(date: Date): string { +interface ReportTaskDto { + taskId: string + patientDisplayId: string + patientNameMasked: string + status: string + signMethod: string + createdAt: string + signedAt: string | null + templateName: string + templateCategory: string + campusName: string + departmentName: string +} + +interface BackendPage { + records?: T[] + items?: T[] + total?: number + page?: number + size?: number + pageSize?: number +} + +export interface ReportTasksQuery extends ReportAnalyticsQuery { + page: number + size: number + dimension?: 'department' | 'category' | 'method' | 'campus' + groupValue?: string +} + +export interface ReportExportRequest extends ReportTasksQuery { + timeDimension: 'CREATED' | 'SIGNED' | 'EXPIRED' + sort: string + direction: 'asc' | 'desc' + format: 'CSV' | 'XLSX' + columns: string[] +} + +export interface ReportExportJob { + jobId: string + status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED' + resultCount: number | null + errorMessage: string | null + downloadPath: string | null +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) + return value + .replace('T', ' ') + .replace(/\.\d+Z$/, '') + .replace(/Z$/, '') + const pad = (part: number) => String(part).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } -/** - * 签署用时(分钟)。 - * - * 分三段构造,保证「24 小时内完成率」与「超期完成」两张表都拿得到样本: - * 大部分在 3~120 分钟,一部分落在 24 小时附近,少数超过 48 小时有效期。 - */ -function buildSignMinutes(random: () => number): number { - const roll = random() - - if (roll < 0.82) { - return intBetween(random, 3, 720) - } - - if (roll < 0.94) { - return intBetween(random, 720, 2880) - } - - return intBetween(random, 2881, 6600) +function mapGroup(group: ReportGroupDto): ReportGroupRow { + const { voided, ...counts } = group + return { ...counts, void: voided } } -function buildTasks(): ReportTaskRow[] { - const random = createRandom(20260924) - const rows: ReportTaskRow[] = [] - const today = new Date() - - for (let index = 0; index < DEMO_TASK_COUNT; index += 1) { - const patient = pick(random, DEMO_PATIENTS) - const document = pick(random, mockDocumentCatalog) - const status = pickStatus(random) - // 四种方式按现场/线上偏重的权重抽,保证「签署方式分布」四个维度都有量 - const method = pickMethod(random) - - // 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假 - const daysAgo = Math.floor(DEMO_SPAN_DAYS * Math.pow(random(), 1.35)) - - const createdAt = new Date(today) - createdAt.setDate(createdAt.getDate() - daysAgo) - createdAt.setHours(intBetween(random, 8, 19), intBetween(random, 0, 59), 0, 0) - - // 待科室确认的任务患者也已经签完了,签署时间与用时都要有 —— - // 否则「签署时效分析」会把它们当成"还没签",24 小时内完成率被算低 - const isSigned = status === 'signed' || status === 'confirming' - const signMinutes = isSigned ? buildSignMinutes(random) : undefined - - const row: ReportTaskRow = { - id: `RT${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`, - patientId: `P${202610000 + (DEMO_PATIENTS.indexOf(patient) + 1)}`, - patientName: patient.name, - sex: patient.sex, - age: patient.age, - visitType: patient.visitType, - visitNo: `${patient.visitType === '住院' ? 'ZY' : patient.visitType === '体检' ? 'TJ' : 'MZ'}${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`, - campus: campusOfDepartment(document.department), - department: document.department, - documentName: document.name, - category: document.category, - method, - status, - createdAt: formatMoment(createdAt), - } - - if (signMinutes !== undefined) { - const signedAt = new Date(createdAt.getTime() + signMinutes * 60_000) - row.signMinutes = signMinutes - row.signedAt = formatMoment(signedAt) - } - - rows.push(row) +function mapStatus(value: string): SigningTaskStatus { + switch (value) { + case 'WAITING_SIGN': + return 'pending' + case 'WAITING_CONFIRM': + return 'confirming' + case 'CREATED': + case 'GENERATING': + return 'signing' + case 'SIGNED': + return 'signed' + case 'EXPIRED': + return 'expired' + case 'VOIDED': + return 'void' + default: + return 'failed' } - - // 按发起时间倒序:页面与导出的默认顺序都跟这里一致,不必各自再排一次 - return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) } -/* ========================================================================= - 对外接口 - ========================================================================= */ - -let cachedTasks: ReportTaskRow[] | null = null - -/** 演示数据只生成一次:确定性生成也要避免每次进页面都重跑一遍 */ -function readTasks(): ReportTaskRow[] { - cachedTasks ??= buildTasks() - - return cachedTasks +function mapMethod(value: string): SigningMethod { + return value === 'SMS' ? 'sms' : 'pad' } -/** - * 取报表分析数据集。 - * - * 真实模式下后端没有对应接口,返回 `available: false` 而不抛错: - * 报表页拿到空数据时的正确表现是「说明原因」,不是弹一个红色报错 —— - * 后端没上线不是用户的错,也不该看起来像页面坏了。 - */ -export async function getReportAnalyticsDataset(): Promise { - if (!useMockData) { - return { tasks: [], available: false, generatedAt: formatMoment(new Date()) } - } - +function mapTask(task: ReportTaskDto): ReportTaskRow { + const signedAt = task.signedAt ? formatDateTime(task.signedAt) : undefined + const createdAt = formatDateTime(task.createdAt) + const elapsed = task.signedAt + ? Math.max( + 0, + Math.round( + (new Date(task.signedAt).getTime() - new Date(task.createdAt).getTime()) / 60_000, + ), + ) + : undefined return { - tasks: readTasks(), - available: true, - generatedAt: formatMoment(new Date()), + id: task.taskId, + patientId: task.patientDisplayId, + patientName: task.patientNameMasked, + campus: task.campusName, + department: task.departmentName, + documentName: task.templateName, + category: task.templateCategory, + method: mapMethod(task.signMethod), + status: mapStatus(task.status), + createdAt, + signedAt, + signMinutes: elapsed, } } + +export function buildReportAnalyticsQuery( + filter: ReportFilterInput, + window: { from: string; to: string }, +): ReportAnalyticsQuery { + const start = new Date(`${window.from}T00:00:00`) + const end = new Date(`${window.to}T00:00:00`) + end.setDate(end.getDate() + 1) + const query: ReportAnalyticsQuery = { + createdFrom: start.toISOString(), + createdTo: end.toISOString(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai', + } + if (filter.campus && filter.campus !== 'all') query.campusId = filter.campus + if (filter.department) query.departmentId = filter.department + if (filter.category) query.templateCategory = filter.category + if (filter.method !== 'all') query.signMethod = filter.method.toUpperCase() as 'PAD' | 'SMS' + return query +} + +export async function getReportOptions(): Promise { + const response = await request.get>('/v1/reports/signing/options') + return unwrapApiResponse(response) +} + +export async function getReportAnalytics( + query: ReportAnalyticsQuery, +): Promise { + const response = await request.get>( + '/v1/reports/signing/analytics', + { params: query }, + ) + const dto = unwrapApiResponse(response) + const { voided, ...statusCounts } = dto.statusCounts + return { + timezone: dto.timezone, + generatedAt: formatDateTime(dto.generatedAt), + fromDate: dto.fromDate, + toDate: dto.toDate, + statusCounts: { ...statusCounts, void: voided }, + trend: dto.trend.map((point) => ({ + key: point.date, + label: `${Number(point.date.slice(5, 7))}/${Number(point.date.slice(8, 10))}`, + created: point.created, + signed: point.signed, + })), + departments: dto.departments.map(mapGroup), + categories: dto.categories.map(mapGroup), + methods: dto.methods.map(mapGroup), + campuses: dto.campuses.map(mapGroup), + documents: dto.documents.map(mapGroup), + timeliness: { + averageMinutes: dto.timeliness.averageMinutes, + within24hRate: dto.timeliness.within24hRate, + overdueRate: dto.timeliness.overdueRate, + expiredTaskCount: dto.timeliness.expiredTaskCount, + signedSampleCount: dto.timeliness.signedSampleCount, + departmentAverages: dto.timeliness.departmentAverages.map((row) => ({ + label: row.label, + value: row.averageMinutes, + })), + dailyAverages: dto.timeliness.dailyAverages.map((row) => ({ + date: row.label, + averageMinutes: row.averageMinutes, + sampleCount: row.sampleCount, + })), + overdueTaskCount: dto.timeliness.overdueTaskCount, + overdueTasks: dto.timeliness.overdueTasks.map((row) => ({ + id: row.taskId, + patientId: row.patientDisplayId, + patientName: row.patientNameMasked, + department: row.department, + documentName: row.documentName, + createdAt: formatDateTime(row.createdAt), + signedAt: formatDateTime(row.signedAt), + signMinutes: row.elapsedMinutes, + })), + }, + patientStructure: { + age: dto.patientStructure.age, + sex: dto.patientStructure.sex, + visitType: dto.patientStructure.visitType, + campus: dto.patientStructure.campus, + }, + } +} + +export async function getReportAnalyticsTasks( + query: ReportTasksQuery, +): Promise> { + const response = await request.get>>( + '/v1/reports/signing/analytics/tasks', + { + params: query, + }, + ) + const page = unwrapApiResponse(response) + return { + records: (page.records ?? page.items ?? []).map(mapTask), + total: page.total ?? 0, + page: page.page ?? query.page, + pageSize: page.pageSize ?? page.size ?? query.size, + } +} + +export function createReportExportJob(input: ReportExportRequest): Promise { + return request + .post>('/v1/reports/signing/export-jobs', { + ...input, + page: 1, + size: 200, + timeDimension: 'CREATED', + sort: 'createdAt', + direction: 'desc', + }) + .then(unwrapApiResponse) +} + +export function getReportExportJob(jobId: string): Promise { + return request + .get>( + `/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`, + ) + .then(unwrapApiResponse) +} + +export async function downloadReportExport(path: string, filename: string): Promise { + if (!/^\/api\/v1\/reports\/signing\/export-jobs\/[0-9a-f-]{36}\/download$/i.test(path)) { + throw new Error('后端返回的导出下载地址无效') + } + const apiPath = path.replace(/^\/api(?=\/)/, '') + const blob = await request.get(apiPath, { responseType: 'blob' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) +} diff --git a/clinical-web/src/utils/mock-flags.ts b/clinical-web/src/utils/mock-flags.ts index 0dd0ebb..14a09b3 100644 --- a/clinical-web/src/utils/mock-flags.ts +++ b/clinical-web/src/utils/mock-flags.ts @@ -43,16 +43,6 @@ export const mockFlags = { documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false), /** 审计日志:已有只读后端查询接口,直接读取数据库记录 */ audit: resolveMockFlag(import.meta.env.VITE_MOCK_AUDIT, false), - /** - * 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。 - * - * 「系统管理 → 报表分析」(原 `reports` 开关,走 reports/signing/* 接口)已整体下线, - * 开关与页面一并移除;这里是「报表分析」目录下的四个独立菜单,是另一套菜单、 - * 另一套后端面,不受影响。 - * - * 缺省开着:后端目前没有任何分析聚合接口,真实模式只会得到空态。 - */ - reportAnalytics: resolveMockFlag(import.meta.env.VITE_MOCK_REPORT_ANALYTICS, true), /** 用户与权限:用户、角色、接口权限目录 */ users: resolveMockFlag(import.meta.env.VITE_MOCK_USERS, false), /** diff --git a/clinical-web/src/views/reports/aggregate.ts b/clinical-web/src/views/reports/aggregate.ts index d63d6ab..1bee007 100644 --- a/clinical-web/src/views/reports/aggregate.ts +++ b/clinical-web/src/views/reports/aggregate.ts @@ -1,60 +1,17 @@ -/** - * 报表分析的聚合逻辑:全部是纯函数,输入「任务明细 + 筛选条件」,输出页面直接渲染的结构。 - * - * 为什么单独一份而不是写进各页面:同一个「按科室的完成率」在总览的汇总矩阵、 - * 时效分析的分组条形榜、患者结构分析的院区分布里都要用;各页各写一遍, - * 迟早出现「同一个筛选条件,两个页面算出不同的数」——这种不一致在界面上 - * 没有任何提示,只能靠人盯着两个页面比对才能发现。 - * - * 纯函数还有个直接好处:不依赖 DOM 与 Vue,可以用 Node 直接跑断言(见验证脚本)。 - */ +/** 展示层使用的报表标签、日期窗口和格式化函数。聚合指标由后端计算。 */ -import type { ReportTaskRow } from '@/api/workbench/report-analytics' import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types' import { formatSigningMethodTitle, SIGNING_METHODS } from '@/utils/signing-method' -import type { - ReportBarItem, - ReportFilterForm, - ReportGroupRow, - ReportPatientStructure, - ReportStatusCounts, - ReportSummaryDimension, - ReportTimelinessMetrics, - ReportTrendPoint, -} from './types' +import type { ReportBarItem, ReportFilterForm, ReportSummaryDimension } from './types' -/** 超 24 小时(分钟):时效分析的「24 小时内完成率」分界 */ -export const WITHIN_24H_MINUTES = 1440 -/** 超 48 小时(分钟):超过签署链接有效期,算「超期完成」 */ +/** 超 48 小时(分钟):超期完成任务的展示阈值。 */ export const OVERDUE_MINUTES = 2880 -/** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */ -const MAX_TREND_DAYS = 60 - -/** - * 签署方式维度文案。 - * 口径统一走 utils/signing-method:四种方式各成一行,不能只列 pad / sms—— - * 报表维度是按任务实际取值聚合的,缺一行会让 Pad / 公众号的任务在图表里显示成空白。 - * 文案取原型的 methodLabel(`✍️ 手写板签署` 这种带图标的完整写法), - * 与报表明细表的「方式」列一致。 - * - * ⚠️ 这是**查表**,没有 `readSigningMethod` 那样的未知取值兜底:`METHOD_LABELS[未知值]` - * 会得到 undefined,汇总矩阵会多出一行没有名字的分组。今天不会触发(`SigningMethod` - * 是封闭联合,取值只来自本仓库的演示数据),但接真实接口时必须在边界上先把后端取值 - * 归一到这四个(见 api/workbench/signing.ts 的 normalizeSigningMethod),不要指望这里兜底。 - */ export const METHOD_LABELS: Record = Object.fromEntries( SIGNING_METHODS.map((option) => [option.key, formatSigningMethodTitle(option.key)]), ) as Record -/** - * 状态文案。七种状态与后端枚举一一对应。 - * - * `confirming`(待科室确认)是**节点状态**:患者已经签完、只差科室复核签章。 - * 它在报表里单列一栏而不是并进「已签署」—— 并进去会让「已签署」虚高, - * 而科室复核本来就是这类文书的关键卡点,混掉就看不见卡在哪了。 - */ export const STATUS_LABELS: Record = { pending: '待签署', signing: '签署中', @@ -65,7 +22,6 @@ export const STATUS_LABELS: Record = { failed: '处理失败', } -/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */ export const STATUS_COLUMNS: SigningTaskStatus[] = [ 'signed', 'confirming', @@ -83,65 +39,15 @@ export const SUMMARY_DIMENSION_LABELS: Record = campus: '院区', } -/** 年龄段分档:顺序固定,不按数量排 —— 年龄天然有序,重排反而看不出分布形状 */ -const AGE_BUCKETS: { label: string; match: (age: number) => boolean }[] = [ - { label: '0-17 岁', match: (age) => age < 18 }, - { label: '18-40 岁', match: (age) => age >= 18 && age <= 40 }, - { label: '41-60 岁', match: (age) => age >= 41 && age <= 60 }, - { label: '61 岁以上', match: (age) => age > 60 }, -] - function pad(value: number): string { return String(value).padStart(2, '0') } -/** 把 `YYYY-MM-DD HH:mm` 截成日期键 */ -export function dateKeyOf(moment: string): string { - return moment.slice(0, 10) -} - function dateKeyOfDate(date: Date): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } -/** 日期键 → 坐标轴标签 `M/D` */ -export function axisLabelOf(dateKey: string): string { - const [, month, day] = dateKey.split('-') - - return `${Number(month)}/${Number(day)}` -} - -/** 任务状态 → 计数里的字段名:状态枚举与计数字段一一对应,直接索引即可 */ -export function emptyCounts(): ReportStatusCounts { - return { - total: 0, - signed: 0, - confirming: 0, - pending: 0, - signing: 0, - expired: 0, - void: 0, - failed: 0, - } -} - -export function addToCounts(counts: ReportStatusCounts, status: ReportTaskRow['status']): void { - counts.total += 1 - counts[status] += 1 -} - -/** 完成率:已签署 ÷ 发起。发起为 0 时按 0 计,不返回 NaN */ -export function completionRateOf(counts: ReportStatusCounts): number { - return counts.total ? Math.round((counts.signed / counts.total) * 100) : 0 -} - -/** - * 解析筛选的时间窗口,返回闭区间 `{ from, to }`(都是 `YYYY-MM-DD`)。 - * - * 返回 null 表示「不做时间过滤」:自定义区间只填了一头时属于「条件还没填完」, - * 这时既不该按半截条件筛,也不该悄悄套一个默认窗口 —— 由页面禁用查询按钮, - * 这里只是给出一个确定的行为。 - */ +/** 解析报表筛选的闭区间日期;时间戳边界在 API 层转换为左闭右开。 */ export function resolveDateWindow( filter: ReportFilterForm, today = new Date(), @@ -149,361 +55,45 @@ export function resolveDateWindow( const to = dateKeyOfDate(today) if (filter.dateRange === 'custom') { - if (!filter.customFrom || !filter.customTo) { - return null - } - - // 起点晚于终点时按用户填的两个端点反着理解,比返回空结果友好 + if (!filter.customFrom || !filter.customTo) return null return filter.customFrom <= filter.customTo ? { from: filter.customFrom, to: filter.customTo } : { from: filter.customTo, to: filter.customFrom } } const from = new Date(today) - if (filter.dateRange === 'month') { from.setDate(1) } else { - const days = filter.dateRange === '7d' ? 7 : 14 - from.setDate(from.getDate() - (days - 1)) + from.setDate(from.getDate() - (filter.dateRange === '7d' ? 6 : 13)) } - from.setHours(0, 0, 0, 0) - return { from: dateKeyOfDate(from), to } } -/** 按筛选条件过滤任务明细 */ -export function filterReportTasks( - tasks: readonly ReportTaskRow[], - filter: ReportFilterForm, - today = new Date(), -): ReportTaskRow[] { - const window = resolveDateWindow(filter, today) - - return tasks.filter((task) => { - const day = dateKeyOf(task.createdAt) - - if (window && (day < window.from || day > window.to)) { - return false - } - - if (filter.campus !== 'all' && task.campus !== filter.campus) { - return false - } - - if (filter.department && task.department !== filter.department) { - return false - } - - if (filter.category && task.category !== filter.category) { - return false - } - - if (filter.method !== 'all' && task.method !== filter.method) { - return false - } - - return true - }) +/** 日期键 → 坐标轴标签 `M/D`。 */ +export function axisLabelOf(dateKey: string): string { + const [, month, day] = dateKey.split('-') + return `${Number(month)}/${Number(day)}` } -export function countByStatus(tasks: readonly ReportTaskRow[]): ReportStatusCounts { - const counts = emptyCounts() - - tasks.forEach((task) => addToCounts(counts, task.status)) - - return counts -} - -/** - * 每日趋势:同一天分别统计发起量与完成量。 - * - * 窗口天数由 `window` 决定,而不是「有数据的那几天」—— 中间没有任务的日期 - * 也要占一根空柱,否则横轴会被压缩,趋势的疏密就看不出真实节奏了。 - */ -export function buildTrend( - tasks: readonly ReportTaskRow[], - window: { from: string; to: string } | null, -): ReportTrendPoint[] { - const createdMap = new Map() - const signedMap = new Map() - - tasks.forEach((task) => { - const created = dateKeyOf(task.createdAt) - createdMap.set(created, (createdMap.get(created) ?? 0) + 1) - - if (task.signedAt) { - const signed = dateKeyOf(task.signedAt) - signedMap.set(signed, (signedMap.get(signed) ?? 0) + 1) - } - }) - - const keys = window ? enumerateDays(window.from, window.to) : [...createdMap.keys()].sort() - - return keys.map((key) => ({ - key, - label: axisLabelOf(key), - created: createdMap.get(key) ?? 0, - signed: signedMap.get(key) ?? 0, - })) -} - -/** 枚举闭区间内的每一天,超过 MAX_TREND_DAYS 时只保留最后 N 天 */ -function enumerateDays(from: string, to: string): string[] { - const keys: string[] = [] - const cursor = new Date(`${from}T00:00:00`) - const end = new Date(`${to}T00:00:00`) - - // 上限兜底:自定义区间传个跨年的范围时,别把浏览器画死 - while (cursor <= end && keys.length < 400) { - keys.push(dateKeyOfDate(cursor)) - cursor.setDate(cursor.getDate() + 1) - } - - return keys.length > MAX_TREND_DAYS ? keys.slice(-MAX_TREND_DAYS) : keys -} - -/** 分组键:四种维度共用一套取值口径 */ -export function groupKeyOf(task: ReportTaskRow, dimension: ReportSummaryDimension): string { - if (dimension === 'category') { - return task.category || '其他' - } - - if (dimension === 'method') { - return METHOD_LABELS[task.method] - } - - if (dimension === 'campus') { - return task.campus || '其他' - } - - return task.department || '其他' -} - -/** 按维度分组统计,按发起量倒序(原型口径:最大的分组排最前) */ -export function groupTasks( - tasks: readonly ReportTaskRow[], - dimension: ReportSummaryDimension, -): ReportGroupRow[] { - const buckets = new Map() - - tasks.forEach((task) => { - const key = groupKeyOf(task, dimension) - const counts = buckets.get(key) ?? emptyCounts() - - addToCounts(counts, task.status) - buckets.set(key, counts) - }) - - return [...buckets.entries()] - .map(([key, counts]) => ({ - key, - ...counts, - completionRate: completionRateOf(counts), - })) - .sort((a, b) => b.total - a.total || a.key.localeCompare(b.key, 'zh-Hans-CN')) -} - -/** 排行条形榜:按数量倒序,可截断(文档 TOP10) */ -export function buildRanking( - tasks: readonly ReportTaskRow[], - keyOf: (task: ReportTaskRow) => string, - limit?: number, -): ReportBarItem[] { - const buckets = new Map() - - tasks.forEach((task) => { - const key = keyOf(task) - buckets.set(key, (buckets.get(key) ?? 0) + 1) - }) - - const items = [...buckets.entries()] - .map(([label, value]) => ({ label, value })) - .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label, 'zh-Hans-CN')) - - return limit === undefined ? items : items.slice(0, limit) -} - -/** - * 时效指标。 - * - * 样本口径:只有**已签署**的任务参与平均时长与两个比率的分母。 - * 把未完成任务也算进去会把平均值稀释成一个没有意义的数 —— 它们不是「签得快」, - * 而是「还没签完」。所以样本为 0 时三个指标返回 null,页面显示「—」而不是 0。 - */ -export function buildTimeliness(tasks: readonly ReportTaskRow[]): ReportTimelinessMetrics { - const signed = tasks.filter((task) => task.signMinutes !== undefined) - - if (!signed.length) { - return { - averageMinutes: null, - within24hRate: null, - overdueRate: null, - expiredTaskCount: tasks.filter((task) => task.status === 'expired').length, - signedSampleCount: 0, - } - } - - const total = signed.reduce((sum, task) => sum + (task.signMinutes ?? 0), 0) - const within24h = signed.filter((task) => (task.signMinutes ?? 0) <= WITHIN_24H_MINUTES).length - const overdue = signed.filter((task) => (task.signMinutes ?? 0) > OVERDUE_MINUTES).length - - return { - averageMinutes: Math.round(total / signed.length), - within24hRate: Math.round((within24h / signed.length) * 100), - overdueRate: Math.round((overdue / signed.length) * 100), - expiredTaskCount: tasks.filter((task) => task.status === 'expired').length, - signedSampleCount: signed.length, - } -} - -/** 各科室平均签署时长,按均值倒序;颜色按 24h / 48h 阈值分级 */ -export function buildDurationByDepartment(tasks: readonly ReportTaskRow[]): ReportBarItem[] { - const sum = new Map() - - tasks.forEach((task) => { - if (task.signMinutes === undefined) { - return - } - - const key = task.department || '其他' - const bucket = sum.get(key) ?? { total: 0, count: 0 } - - bucket.total += task.signMinutes - bucket.count += 1 - sum.set(key, bucket) - }) - - return [...sum.entries()] - .map(([label, bucket]) => { - const average = Math.round(bucket.total / bucket.count) - - return { - label, - value: average, - display: formatDuration(average), - color: durationColor(average), - } - }) - .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label, 'zh-Hans-CN')) -} - -/** 每日平均签署时长(分钟),按完成日期归集 */ -export function buildDailyAverageDuration( - tasks: readonly ReportTaskRow[], - window: { from: string; to: string } | null, -): ReportBarItem[] { - const sum = new Map() - - tasks.forEach((task) => { - if (task.signMinutes === undefined || !task.signedAt) { - return - } - - const key = dateKeyOf(task.signedAt) - const bucket = sum.get(key) ?? { total: 0, count: 0 } - - bucket.total += task.signMinutes - bucket.count += 1 - sum.set(key, bucket) - }) - - const keys = window ? enumerateDays(window.from, window.to) : [...sum.keys()].sort() - - return keys.map((key) => { - const bucket = sum.get(key) - - return { - label: axisLabelOf(key), - value: bucket ? Math.round(bucket.total / bucket.count) : 0, - } - }) -} - -/** 超期完成任务:完成用时超过 48 小时的已签署任务,用时长的排前面 */ -export function buildOverdueTasks(tasks: readonly ReportTaskRow[]): ReportTaskRow[] { - return tasks - .filter((task) => (task.signMinutes ?? 0) > OVERDUE_MINUTES) - .sort((a, b) => (b.signMinutes ?? 0) - (a.signMinutes ?? 0)) -} - -/** 患者结构:年龄段固定顺序,其余按数量倒序(与原型一致) */ -export function buildPatientStructure(tasks: readonly ReportTaskRow[]): ReportPatientStructure { - // 年龄段只扫一遍:四档各 filter 一次是 4 倍工作量,而且四档之间还容易写歪边界 - const ageCounts = AGE_BUCKETS.map(() => 0) - - tasks.forEach((task) => { - const index = AGE_BUCKETS.findIndex((bucket) => bucket.match(task.age)) - - if (index >= 0) { - ageCounts[index] += 1 - } - }) - - const age = AGE_BUCKETS.map((bucket, index) => ({ - label: bucket.label, - value: ageCounts[index] ?? 0, - display: `${ageCounts[index] ?? 0} 人次`, - })) - - const total = tasks.length || 1 - - const sex = buildRanking(tasks, (task) => task.sex).map((item) => ({ - ...item, - display: `${item.value} 人次 · ${Math.round((item.value / total) * 100)}%`, - })) - - const visitType = buildRanking(tasks, (task) => task.visitType).map((item) => ({ - ...item, - display: `${item.value} 人次`, - })) - - const campus = buildRanking(tasks, (task) => task.campus || '其他').map((item) => ({ - ...item, - display: `${item.value} 人次`, - })) - - return { age, sex, visitType, campus } -} - -/** 时长展示:对齐原型口径(不足 1 小时用分钟,超过用小时且保留一位小数) */ +/** 不足一小时显示分钟,超过一小时显示小时。 */ export function formatDuration(minutes: number | null | undefined): string { - if (minutes === null || minutes === undefined || Number.isNaN(minutes)) { - return '—' - } - - if (minutes < 60) { - return `${minutes} 分钟` - } - + if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return '—' + if (minutes < 60) return `${minutes} 分钟` const hours = minutes / 60 - return `${hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours)} 小时` } -/** 时长分级配色:24 小时内绿、48 小时内橙、超过红 */ -export function durationColor(minutes: number): string { - if (minutes <= WITHIN_24H_MINUTES) { - return '#0f9d6c' - } - - return minutes <= OVERDUE_MINUTES ? '#d9821f' : '#d0534f' -} - -/** 完成率配色:≥90% 绿、≥80% 橙、其余红(原型口径) */ +/** 完成率配色:≥90% 绿、≥80% 橙、其余红。 */ export function completionRateColor(rate: number): string { - if (rate >= 90) { - return '#0f9d6c' - } - + if (rate >= 90) return '#0f9d6c' return rate >= 80 ? '#d9821f' : '#d0534f' } -/** 条形榜调色板(与原型一致) */ -export const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f'] +const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f'] -/** 取条形颜色:显式给了就用,否则按调色板轮换 */ +/** 横向条形图优先使用数据指定颜色,否则循环使用主题调色板。 */ export function barColorOf(item: ReportBarItem, index: number): string { return item.color ?? BAR_PALETTE[index % BAR_PALETTE.length] ?? '#0e6e8c' } diff --git a/clinical-web/src/views/reports/components/ReportExportDialog.vue b/clinical-web/src/views/reports/components/ReportExportDialog.vue index e50b60d..844f57d 100644 --- a/clinical-web/src/views/reports/components/ReportExportDialog.vue +++ b/clinical-web/src/views/reports/components/ReportExportDialog.vue @@ -2,33 +2,34 @@ import { computed, ref, watch } from 'vue' import { ElMessage } from 'element-plus' -import type { ReportTaskRow } from '@/api/workbench/report-analytics' -import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types' -import { downloadCsv, fileStamp } from '@/utils/csv-export' -import { REPORT_METHOD_FILTERS } from '@/utils/signing-method' +import { + buildReportAnalyticsQuery, + createReportExportJob, + downloadReportExport, + getReportExportJob, + type ReportExportJob, +} from '@/api/workbench/report-analytics' +import type { WorkbenchCampus } from '@/api/workbench/types' +import { toUserMessage } from '@/utils/api-response' -import { filterReportTasks, formatDuration, METHOD_LABELS, STATUS_LABELS } from '../aggregate' -import type { ReportDateRange, ReportExportColumn, ReportFilterForm, ReportFilterOption } from '../types' +import { resolveDateWindow } from '../aggregate' +import type { ReportDateRange, ReportFilterForm, ReportFilterOption } from '../types' /** - * 导出弹窗。 + * 报表导出通过服务端异步任务完成,任务创建、下载都会由后端记录审计。 * * 「导出范围」在弹窗里可以独立调整,而不是固定沿用页面上的筛选:导出通常是 * 「把范围放宽一点拿全量数据」,如果只能导出当前所见,用户得先改页面筛选、 * 导出、再改回来。 * - * ⚠️ 前端导出只是权宜之计:导出行为、导出人与导出范围必须写入审计日志, - * 浏览器里做不到。后端提供导出任务接口后应整体替换(参考签署记录查询的同类说明)。 */ const props = defineProps<{ visible: boolean - /** 未筛选的全量明细:弹窗自己按导出范围过滤 */ - tasks: ReportTaskRow[] /** 弹窗打开时用于初始化导出范围 */ filter: ReportFilterForm campuses: ReportFilterOption[] - departments: ReportFilterOption[] + departments: (ReportFilterOption & { campusId?: string })[] categories: ReportFilterOption[] }>() @@ -43,9 +44,10 @@ const dateRangeOptions: ReportFilterOption[] = [ { label: '自定义…', value: 'custom' }, ] -const methodOptions: ReportFilterOption[] = [ +const methodOptions: ReportFilterOption[] = [ { label: '全部方式', value: 'all' }, - ...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })), + { label: '手写板', value: 'pad' }, + { label: '短信', value: 'sms' }, ] /** @@ -54,28 +56,19 @@ const methodOptions: ReportFilterOption[] = [ * 前九项与原型一致,默认勾选;其余是台账里常要的补充字段,默认不勾 —— * 默认全勾会让导出的表宽到没法看,而默认只给最少字段又要用户每次手动加。 */ -const EXPORT_COLUMNS: ReportExportColumn[] = [ - { key: 'id', label: '任务号', value: (row) => row.id }, - { key: 'patientName', label: '患者姓名', value: (row) => row.patientName }, - { key: 'documentName', label: '文档名称', value: (row) => row.documentName }, - { key: 'department', label: '科室', value: (row) => row.department }, - { key: 'method', label: '签署方式', value: (row) => METHOD_LABELS[row.method] }, - { key: 'status', label: '状态', value: (row) => STATUS_LABELS[row.status] }, - { key: 'createdAt', label: '发起时间', value: (row) => row.createdAt }, - { key: 'signedAt', label: '完成时间', value: (row) => row.signedAt ?? '' }, - { - key: 'signMinutes', - label: '签署用时', - // 表格里空值显示「—」,但 CSV 给空单元格:表格里的破折号进到 Excel 会变成文本 - value: (row) => (row.signMinutes === undefined ? '' : formatDuration(row.signMinutes)), - }, - { key: 'patientId', label: '患者ID', value: (row) => row.patientId }, - { key: 'sex', label: '性别', value: (row) => row.sex }, - { key: 'age', label: '年龄', value: (row) => String(row.age) }, - { key: 'visitNo', label: '就诊号', value: (row) => row.visitNo }, - { key: 'visitType', label: '就诊类型', value: (row) => row.visitType }, - { key: 'campus', label: '院区', value: (row) => row.campus }, - { key: 'category', label: '文档类别', value: (row) => row.category }, +const EXPORT_COLUMNS = [ + { key: 'taskId', label: '任务号' }, + { key: 'patientDisplayId', label: '患者脱敏标识' }, + { key: 'patientNameMasked', label: '患者姓名(脱敏)' }, + { key: 'templateName', label: '文档名称' }, + { key: 'departmentName', label: '科室' }, + { key: 'signMethod', label: '签署方式' }, + { key: 'status', label: '状态' }, + { key: 'createdAt', label: '发起时间' }, + { key: 'signedAt', label: '完成时间' }, + { key: 'signDurationSeconds', label: '签署用时(秒)' }, + { key: 'campusName', label: '院区' }, + { key: 'templateCategory', label: '文档类别' }, ] const DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key) @@ -83,6 +76,17 @@ const DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key) const draft = ref({ ...props.filter }) const checkedKeys = ref([...DEFAULT_CHECKED]) const exporting = ref(false) +const activeJobId = ref('') +const activeJob = ref(null) + +const draftDepartmentOptions = computed(() => + props.departments.filter( + (department) => + !department.value || + draft.value.campus === 'all' || + department.campusId === draft.value.campus, + ), +) // 每次打开都按「当前页面筛选」重置:上次调过的导出范围留到下一次会让人导错数据 watch( @@ -95,7 +99,17 @@ watch( }, ) -const scopedTasks = computed(() => filterReportTasks(props.tasks, draft.value)) +watch( + () => draft.value.campus, + () => { + if ( + draft.value.department && + !draftDepartmentOptions.value.some((option) => option.value === draft.value.department) + ) { + draft.value.department = '' + } + }, +) const customRangeIncomplete = computed( () => draft.value.dateRange === 'custom' && (!draft.value.customFrom || !draft.value.customTo), @@ -104,43 +118,86 @@ const customRangeIncomplete = computed( const selectedColumns = computed(() => EXPORT_COLUMNS.filter((column) => checkedKeys.value.includes(column.key)), ) - const canExport = computed( - () => !customRangeIncomplete.value && scopedTasks.value.length > 0 && selectedColumns.value.length > 0, + () => + !exporting.value && + (Boolean(activeJobId.value) || + (!customRangeIncomplete.value && selectedColumns.value.length > 0)), ) +function wait(milliseconds: number) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)) +} + +async function pollExportJob(jobId: string): Promise { + let latest = activeJob.value + for (let attempt = 0; attempt < 45; attempt += 1) { + latest = await getReportExportJob(jobId) + activeJob.value = latest + if (latest.status === 'SUCCEEDED' || latest.status === 'FAILED' || latest.status === 'EXPIRED') + return latest + if (attempt < 44) await wait(2000) + } + return latest! +} + +async function finishExport(job: ReportExportJob) { + if (job.status === 'SUCCEEDED' && job.downloadPath) { + await downloadReportExport(job.downloadPath, `签署报表_${job.jobId}.csv`) + ElMessage.success(`已导出 ${job.resultCount ?? 0} 条签署任务`) + activeJobId.value = '' + activeJob.value = null + emit('close') + } else if (job.status === 'FAILED' || job.status === 'EXPIRED') { + activeJobId.value = '' + activeJob.value = null + ElMessage.error(job.errorMessage || '导出失败,请稍后重试') + } else { + ElMessage.info('导出任务仍在后台处理,可稍后再次点击继续查看') + } +} + function handleClose() { emit('close') } -function handleExport() { - if (customRangeIncomplete.value) { +async function handleExport() { + if (!activeJobId.value && customRangeIncomplete.value) { ElMessage.info('自定义时间范围需要同时填写起止日期') return } - if (!selectedColumns.value.length) { + if (!activeJobId.value && !selectedColumns.value.length) { ElMessage.info('请至少选择一个导出字段') return } - if (!scopedTasks.value.length) { - ElMessage.info('当前导出范围内没有可导出的签署任务') - return - } - if (exporting.value) { return } exporting.value = true - try { - downloadCsv(selectedColumns.value, scopedTasks.value, `签署报表_${fileStamp()}.csv`) - ElMessage.success(`已导出 ${scopedTasks.value.length} 条签署任务`) - emit('close') - } catch { - ElMessage.error('导出失败,请稍后重试') + if (!activeJobId.value) { + const dateWindow = resolveDateWindow(draft.value) + if (!dateWindow) throw new Error('自定义时间范围需要同时填写起止日期') + const query = buildReportAnalyticsQuery(draft.value, dateWindow) + const job = await createReportExportJob({ + ...query, + page: 1, + size: 200, + timeDimension: 'CREATED', + sort: 'createdAt', + direction: 'desc', + format: 'CSV', + columns: selectedColumns.value.map((column) => column.key), + }) + activeJobId.value = job.jobId + activeJob.value = job + } + await finishExport(await pollExportJob(activeJobId.value)) + } catch (error) { + ElMessage.error(toUserMessage(error, '导出任务创建或下载失败')) } finally { exporting.value = false } @@ -188,7 +245,11 @@ function handleExport() {