diff --git a/clinical-web/.env.example b/clinical-web/.env.example index 86fe0fe..9ec79e2 100644 --- a/clinical-web/.env.example +++ b/clinical-web/.env.example @@ -15,7 +15,12 @@ VITE_API_BASE_URL=/api # VITE_MOCK_SIGN_RECORDS=true # VITE_MOCK_DOCUMENTS=false # VITE_MOCK_DOCUMENT_PERMISSIONS=false +# 系统管理 → 报表分析(签署报表与导出任务,走 reports/signing/* 接口) # VITE_MOCK_REPORTS=false +# 报表分析目录下的四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。 +# 后端目前没有任何分析聚合接口,缺省开启演示数据;关掉只会得到「后端尚未提供接口」的空态。 +# 与上面的 VITE_MOCK_REPORTS 是两套菜单、两套后端面,不要合并成一个开关。 +# 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 d077d61..af324c3 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适 ## 当前状态 -已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台、签署记录查询和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。 +已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台、签署记录查询、文档库管理页面和「报表分析」四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。 文档库管理的真实模式已打通「模板 → 版本 → 正文」三段: @@ -111,10 +111,16 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供) ├─ /system/org 组织架构 / 员工角色绑定 ├─ /system/role 角色权限与数据范围 ├─ /doc/library 文档库管理 -├─ /report/index 报表分析 +├─ /report/index 报表分析(签署报表与导出任务,走 reports/signing/* 接口) +├─ /report/sign/overview 报表分析 / 签署总览 +├─ /report/sign/volume 报表分析 / 签署量分析 +├─ /report/sign/timeliness 报表分析 / 签署时效分析 +├─ /report/patient/structure 报表分析 / 患者结构分析 └─ /system/setting 系统设置 / 菜单设置 ``` +注意 `/report/index` 与另外四个报表页是**两套菜单、两套后端面**:前者是「系统管理 → 报表分析」下的签署报表与导出任务(真实接口已接入,见 [reports-api.md](./docs/reports-api.md));后者是「报表分析」目录下的四个独立菜单,后端目前没有任何分析聚合接口,因此只有演示数据(`VITE_MOCK_REPORT_ANALYTICS`)。两者的路由与 `componentPath` 都以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。 + 布局拆分为 `Menu`、`Header` 和 `Container` 三个公共组件,页面内容由各自的 View 负责。 页面目录按业务域组织:`auth` 保留登录页;工作台页面位于 `views/workbench`;管理页面位于 `views/management`。每个具体页面目录都有 `index.vue` 入口和 `components` 目录,用于继续拆分当前页面组件。 @@ -238,10 +244,26 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 演示数据缺省开启(`VITE_MOCK_SIGN_RECORDS`),且与签署工作台**分开开关**:两者共用一个开关会导致「关掉工作台的演示数据」把记录页一起带进空态。 +## 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析) + +四个菜单共用一套数据源与筛选条件: + +- `api/workbench/report-analytics.ts`:数据集。**后端没有任何分析聚合接口**,真实模式返回 `{ tasks: [], available: false }` 而不抛错,页面据此显示「后端尚未提供报表分析接口」的说明,不渲染一张全 0 的假报表。 +- `views/reports/aggregate.ts`:全部聚合逻辑,纯函数(不依赖 DOM 与 Vue),可用 Node 直跑断言。 +- `views/reports/filter-store.ts`:**四个菜单共用一份筛选条件**(模块级 `reactive`)。原型里它们是同一筛选条下的四个页签,各存一份会导致切页时筛选被重置、四个页面的数字看起来互相矛盾。 +- `views/reports/components/ReportPageShell.vue`:标题 + 筛选条 + 数据可用性闸门 + 导出弹窗,四个页面共用;页面只填自己的图表内容。 +- 图表是手写内联 SVG(`ReportBarChart` / `ReportBarList`),不引图表库:只需要柱状图与横向条形榜两种形态,引库要多几百 KB 且读不到主题 CSS 变量。渐变 id 用 `useId()` 取每实例唯一值。 + +**演示数据的两条硬约束**:① 用固定种子的 PRNG(mulberry32),不用 `Math.random` —— 否则每次刷新数字都在跳,截图对不上、断言写不了;② 时间锚定「距今天数」,不写死日期 —— 写死之后过几天再打开,近 7 天 / 近 14 天 / 本月全是空的,看起来像筛选坏了。 + +**边界**:① 时效指标只统计**已签署**任务(未完成任务不是「签得快」而是「还没签完」,计入会稀释均值),样本为 0 时显示「—」而不是 0;② 患者结构统计的是**人次**而非人数,同一患者多次签署会重复计数,页面上已写明;③ 导出是前端按条件生成 CSV(UTF-8 带 BOM、CRLF),与签署记录查询共用 `utils/csv-export.ts`。**后端提供导出任务后应改为服务端导出**:导出范围与导出人必须进审计日志,浏览器里做不到。真实接入聚合接口时,后端应按页面出四个聚合接口,**不要**照搬现在的形状把全量任务明细拉到浏览器里算。 + +演示数据缺省开启(`VITE_MOCK_REPORT_ANALYTICS`,与 `/report/index` 的 `VITE_MOCK_REPORTS` 分开)。 + 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 开发服务。 -生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省只有文档库之外的签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)使用演示数据,其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 +生产环境不会使用 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。 当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。 diff --git a/clinical-web/src/api/workbench/mock-catalog.ts b/clinical-web/src/api/workbench/mock-catalog.ts new file mode 100644 index 0000000..dab1430 --- /dev/null +++ b/clinical-web/src/api/workbench/mock-catalog.ts @@ -0,0 +1,165 @@ +/** + * 演示数据目录:文书 / 科室 / 院区。 + * + * 为什么单独抽一份:签署记录查询与报表分析都要按「文书 → 科室 / 类别」、 + * 「科室 → 院区」做归类统计。两处各写一份目录,改一个文书名就会让记录页与 + * 报表页的口径对不上 —— 这种漂移不会报错,只会让数字悄悄对不齐。所以目录 + * 只在这里维护一份,两边都从这里取。 + * + * 记录与报表引用的是「签署当时那一版」文书,可能与当前文库不同(模板停用、 + * 改版、归档都会让历史指向一份现在查不到的文书),因此目录自带文书名与类别, + * 不靠当前文库反查。 + */ + +import type { WorkbenchCampus } from './types' + +export interface MockDocumentDef { + id: string + name: string + category: string + department: string + /** 该文书的风险告知条款(演示正文用) */ + items: string[] +} + +export const mockDocumentCatalog: MockDocumentDef[] = [ + { + id: 'mock-doc-cardiac', + name: '心脏介入手术知情同意书', + category: '知情同意书', + department: '心内科', + items: [ + '术中、术后可能发生心律失常、心肌缺血甚至心肌梗死。', + '穿刺部位可能出现血肿、假性动脉瘤或动静脉瘘。', + '可能发生造影剂过敏反应,严重者可出现过敏性休克。', + '存在麻醉意外、血管破裂、血栓形成与栓塞等风险。', + '术后需按医嘱卧床、服药并按时复诊,擅自停药可能危及生命。', + ], + }, + { + id: 'mock-doc-mri', + name: 'MRI 磁共振检查知情同意书', + category: '知情同意书', + department: '医学影像科', + items: [ + '体内有心脏起搏器、金属植入物、动脉瘤夹者不得进入检查室。', + '检查中机器噪声较大,请配合佩戴耳塞并保持体位不动。', + '使用对比剂时可能出现恶心、皮疹等不良反应,极少数可发生严重过敏。', + '危重患者、不能配合者可能无法完成检查或需改期。', + '检查结果由诊断医师出具,最终以正式报告为准。', + ], + }, + { + id: 'mock-doc-endoscopy', + name: '内镜检查知情同意书', + category: '知情同意书', + department: '消化内科', + items: [ + '检查过程中可能出现咽喉不适、恶心、腹胀或轻度腹痛。', + '极少数情况下可能发生出血、穿孔等并发症,需进一步处理。', + '麻醉或镇静下检查存在呼吸抑制、误吸等风险。', + '检查中可能根据所见情况取活检或行息肉切除。', + '若检查前准备不充分,可能影响观察效果或需要重新检查。', + ], + }, + { + id: 'mock-doc-narcotic', + name: '麻醉药品、精神药品知情同意书', + category: '知情同意书', + department: '麻醉科·精神科', + items: [ + '麻精药品具有成瘾性(依赖性)风险,须严格按照医嘱使用。', + '不得套开、转借或转售麻精药品,转售涉嫌毒品犯罪。', + '需按医嘱定期复诊,长期使用者至少每 3 个月复诊一次。', + '过期、未使用的药品及废贴、空安瓿应交回医疗机构销毁。', + '可能出现恶心、呕吐、便秘、头晕、嗜睡、呼吸抑制等不良反应。', + ], + }, + { + id: 'mock-doc-surgery', + name: '手术麻醉风险告知书', + category: '风险告知书', + department: '普外科', + items: [ + '麻醉过程中可能出现呼吸循环波动、药物过敏或困难气道。', + '术中可能因病情变化扩大手术范围或改变手术方式。', + '术后可能发生出血、感染、切口愈合不良或深静脉血栓。', + '合并基础疾病者发生并发症的风险相应升高。', + '术后需配合镇痛、早期活动与康复训练。', + ], + }, + { + id: 'mock-doc-blood', + name: '输血治疗风险告知书', + category: '风险告知书', + department: '血液科', + items: [ + '可能出现发热、寒战、皮疹等输血反应。', + '极少数情况下可能发生溶血反应或输血相关急性肺损伤。', + '虽经严格筛查,仍存在窗口期病原体传播的极小概率。', + '大量输血可能引起凝血功能异常、电解质紊乱。', + '输血过程中请勿擅自离开病区,出现不适请立即告知医护人员。', + ], + }, + { + id: 'mock-doc-admission', + name: '住院须知及告知书', + category: '入院告知', + department: '医务处', + items: [ + '住院期间请遵守病区管理要求,不得擅自离院或外宿。', + '请如实告知既往病史、过敏史与用药情况。', + '贵重物品请自行妥善保管,避免带入大量现金。', + '医疗费用按医保政策与医院规定结算,欠费可能影响治疗安排。', + '诊疗方案可能根据病情变化调整,调整前会与患者或家属沟通。', + ], + }, + { + id: 'mock-doc-emergency', + name: '入院告知书(急诊留观)', + category: '入院告知', + department: '急诊科', + items: [ + '留观期间病情可能随时变化,需配合医护人员密切观察。', + '留观不等同于住院,如需住院将另行办理手续。', + '部分检查结果出具需要时间,请耐心等待并保持联系方式畅通。', + '如病情加重需转入抢救室或重症监护病房,将另行告知。', + '离院需经医师评估同意,擅自离院可能延误诊治。', + ], + }, +] + +/** 科室所属院区:演示数据按科室归属派生院区,不必逐条重复写一遍 */ +export const mockDepartmentCampus: Record = { + 心内科: '本部院区', + 消化内科: '本部院区', + '麻醉科·精神科': '本部院区', + 医务处: '本部院区', + 医学影像科: '东院区', + 急诊科: '东院区', + 普外科: '西院区', + 血液科: '西院区', +} + +export const mockDocumentById = new Map( + mockDocumentCatalog.map((document) => [document.id, document]), +) + +/** 演示科室清单(筛选下拉用):只列演示数据里出现过的科室,选了必定有结果 */ +export const mockCatalogDepartments: string[] = [ + ...new Set(mockDocumentCatalog.map((document) => document.department)), +] + +/** 演示文档类别清单(筛选下拉用) */ +export const mockCatalogCategories: string[] = [ + ...new Set(mockDocumentCatalog.map((document) => document.category)), +] + +/** + * 按科室取院区。 + * 演示数据按科室归属派生院区,不必逐条重复写一遍;取不到时回落到本部院区, + * 免得漏配一条映射就让某个科室在报表里变成「无院区」。 + */ +export function campusOfDepartment(department: string): WorkbenchCampus { + return mockDepartmentCampus[department] ?? '本部院区' +} diff --git a/clinical-web/src/api/workbench/records.ts b/clinical-web/src/api/workbench/records.ts index f868105..da1171e 100644 --- a/clinical-web/src/api/workbench/records.ts +++ b/clinical-web/src/api/workbench/records.ts @@ -17,6 +17,12 @@ import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature' import { mockFlags } from '@/utils/mock-flags' import { buildFieldSpanHtml } from '@/utils/signing-fields' +import { + campusOfDepartment, + mockDocumentById, + mockDocumentCatalog, + type MockDocumentDef, +} from './mock-catalog' import { getSigningTaskDetail, getSigningTaskEvents, getSigningTasks } from './signing' import type { PatientSex, @@ -31,7 +37,6 @@ import type { SigningTaskRecord, SigningTaskStatus, VisitType, - WorkbenchCampus, } from './types' const useMockData = mockFlags.signRecords @@ -43,154 +48,6 @@ const MAX_PAGES = 25 /** 导出/批量取回时单页条数 */ const BULK_PAGE_SIZE = 200 -/* ========================================================================= - 演示数据:文书目录 - ========================================================================= */ - -/** - * 演示文书目录。 - * - * 记录引用的是「签署当时那一版」文书,可能与当前文库不同(模板停用、改版、归档都会 - * 让历史记录指向一份现在查不到的文书),所以记录自带文书名与类别,不靠当前文库反查。 - */ -interface MockDocumentDef { - id: string - name: string - category: string - department: string - /** 该文书的风险告知条款(演示正文用) */ - items: string[] -} - -const MOCK_DOCUMENTS: MockDocumentDef[] = [ - { - id: 'mock-doc-cardiac', - name: '心脏介入手术知情同意书', - category: '知情同意书', - department: '心内科', - items: [ - '术中、术后可能发生心律失常、心肌缺血甚至心肌梗死。', - '穿刺部位可能出现血肿、假性动脉瘤或动静脉瘘。', - '可能发生造影剂过敏反应,严重者可出现过敏性休克。', - '存在麻醉意外、血管破裂、血栓形成与栓塞等风险。', - '术后需按医嘱卧床、服药并按时复诊,擅自停药可能危及生命。', - ], - }, - { - id: 'mock-doc-mri', - name: 'MRI 磁共振检查知情同意书', - category: '知情同意书', - department: '医学影像科', - items: [ - '体内有心脏起搏器、金属植入物、动脉瘤夹者不得进入检查室。', - '检查中机器噪声较大,请配合佩戴耳塞并保持体位不动。', - '使用对比剂时可能出现恶心、皮疹等不良反应,极少数可发生严重过敏。', - '危重患者、不能配合者可能无法完成检查或需改期。', - '检查结果由诊断医师出具,最终以正式报告为准。', - ], - }, - { - id: 'mock-doc-endoscopy', - name: '内镜检查知情同意书', - category: '知情同意书', - department: '消化内科', - items: [ - '检查过程中可能出现咽喉不适、恶心、腹胀或轻度腹痛。', - '极少数情况下可能发生出血、穿孔等并发症,需进一步处理。', - '麻醉或镇静下检查存在呼吸抑制、误吸等风险。', - '检查中可能根据所见情况取活检或行息肉切除。', - '若检查前准备不充分,可能影响观察效果或需要重新检查。', - ], - }, - { - id: 'mock-doc-narcotic', - name: '麻醉药品、精神药品知情同意书', - category: '知情同意书', - department: '麻醉科·精神科', - items: [ - '麻精药品具有成瘾性(依赖性)风险,须严格按照医嘱使用。', - '不得套开、转借或转售麻精药品,转售涉嫌毒品犯罪。', - '需按医嘱定期复诊,长期使用者至少每 3 个月复诊一次。', - '过期、未使用的药品及废贴、空安瓿应交回医疗机构销毁。', - '可能出现恶心、呕吐、便秘、头晕、嗜睡、呼吸抑制等不良反应。', - ], - }, - { - id: 'mock-doc-surgery', - name: '手术麻醉风险告知书', - category: '风险告知书', - department: '普外科', - items: [ - '麻醉过程中可能出现呼吸循环波动、药物过敏或困难气道。', - '术中可能因病情变化扩大手术范围或改变手术方式。', - '术后可能发生出血、感染、切口愈合不良或深静脉血栓。', - '合并基础疾病者发生并发症的风险相应升高。', - '术后需配合镇痛、早期活动与康复训练。', - ], - }, - { - id: 'mock-doc-blood', - name: '输血治疗风险告知书', - category: '风险告知书', - department: '血液科', - items: [ - '可能出现发热、寒战、皮疹等输血反应。', - '极少数情况下可能发生溶血反应或输血相关急性肺损伤。', - '虽经严格筛查,仍存在窗口期病原体传播的极小概率。', - '大量输血可能引起凝血功能异常、电解质紊乱。', - '输血过程中请勿擅自离开病区,出现不适请立即告知医护人员。', - ], - }, - { - id: 'mock-doc-admission', - name: '住院须知及告知书', - category: '入院告知', - department: '医务处', - items: [ - '住院期间请遵守病区管理要求,不得擅自离院或外宿。', - '请如实告知既往病史、过敏史与用药情况。', - '贵重物品请自行妥善保管,避免带入大量现金。', - '医疗费用按医保政策与医院规定结算,欠费可能影响治疗安排。', - '诊疗方案可能根据病情变化调整,调整前会与患者或家属沟通。', - ], - }, - { - id: 'mock-doc-emergency', - name: '入院告知书(急诊留观)', - category: '入院告知', - department: '急诊科', - items: [ - '留观期间病情可能随时变化,需配合医护人员密切观察。', - '留观不等同于住院,如需住院将另行办理手续。', - '部分检查结果出具需要时间,请耐心等待并保持联系方式畅通。', - '如病情加重需转入抢救室或重症监护病房,将另行告知。', - '离院需经医师评估同意,擅自离院可能延误诊治。', - ], - }, -] - -/** 科室所属院区:演示数据按科室归属派生院区,不必逐条重复写一遍 */ -const MOCK_DEPARTMENT_CAMPUS: Record = { - 心内科: '本部院区', - 消化内科: '本部院区', - '麻醉科·精神科': '本部院区', - 医务处: '本部院区', - 医学影像科: '东院区', - 急诊科: '东院区', - 普外科: '西院区', - 血液科: '西院区', -} - -const MOCK_DOCUMENT_BY_ID = new Map(MOCK_DOCUMENTS.map((document) => [document.id, document])) - -/** 演示科室清单(筛选下拉用):只列演示数据里出现过的科室,选了必定有结果 */ -export const mockRecordDepartments: string[] = [...new Set(MOCK_DOCUMENTS.map((d) => d.department))] - -/** 演示文档类别清单(筛选下拉用) */ -export const mockRecordCategories: string[] = [ - ...new Set(MOCK_DOCUMENTS.map((document) => document.category)), -] - /** * 演示正文。 * @@ -230,7 +87,7 @@ function buildMockDocumentHtml(document: MockDocumentDef): string { function getMockDocumentHtml(documentId: string): string { mockDocumentHtmlCache ??= new Map( - MOCK_DOCUMENTS.map((document) => [document.id, buildMockDocumentHtml(document)]), + mockDocumentCatalog.map((document) => [document.id, buildMockDocumentHtml(document)]), ) return mockDocumentHtmlCache.get(documentId) ?? '' @@ -253,7 +110,7 @@ interface MockRecordSeed { sex: PatientSex age: number visitType: VisitType - /** MOCK_DOCUMENTS 的 id */ + /** mockDocumentCatalog 的 id */ doc: string status: SigningTaskStatus method: SigningMethod @@ -311,7 +168,7 @@ const VISIT_NO_PREFIX: Record = { } function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord { - const document = MOCK_DOCUMENT_BY_ID.get(seed.doc) + const document = mockDocumentById.get(seed.doc) if (!document) { throw new Error(`演示签署记录引用了不存在的文书:${seed.doc}`) @@ -339,7 +196,7 @@ function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord { return { id: `SR${dateKey}${pad(index + 1)}`, templateVersionId: `${seed.doc}-v1`, - campus: MOCK_DEPARTMENT_CAMPUS[document.department] ?? '本部院区', + campus: campusOfDepartment(document.department), patientId: `P2026${10001 + index}`, patientName: seed.patient, sex: seed.sex, diff --git a/clinical-web/src/api/workbench/report-analytics.ts b/clinical-web/src/api/workbench/report-analytics.ts new file mode 100644 index 0000000..0759623 --- /dev/null +++ b/clinical-web/src/api/workbench/report-analytics.ts @@ -0,0 +1,294 @@ +/** + * 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)的数据源。 + * + * 现状:后端没有任何分析聚合接口,所以这四个菜单目前只有演示数据。 + * 这里刻意**返回任务级明细**而不是预先算好的聚合结果,原因是演示阶段聚合逻辑 + * 要能被页面与验证脚本反复调用(换筛选条件、下钻、导出都基于同一份明细)。 + * + * ⚠️ 真实接入时不要照搬这个形状:一次分析查询不该把全量任务明细拉到浏览器里算。 + * 后端应按页面给出四个聚合接口(总览指标 + 趋势、签署量分组、时效指标 + 分组、 + * 患者结构分组),届时这个模块退化成「按查询参数取聚合结果」, + * 页面侧的聚合函数随之删掉即可 —— 页面只消费聚合结果,不关心它从哪来。 + * + * 演示数据的两条硬约束(都踩过): + * 1. **确定性**:用固定种子的 PRNG,不用 Math.random。否则每次刷新数字都在跳, + * 截图对不上、验证脚本没法断言「筛选后行数应该变少」。 + * 2. **锚定「距今天数」**:任务的日期按「N 天前」生成,不写死具体日期。 + * 写死日期后过几天再打开,近 7 天 / 近 14 天 / 本月 全是空的, + * 演示时看起来像筛选坏了。 + */ + +import { mockFlags } from '@/utils/mock-flags' + +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 ReportAnalyticsDataset { + tasks: ReportTaskRow[] + /** + * 数据是否可用。真实模式下没有后端接口,这里返回 false, + * 页面据此显示「后端尚未提供分析接口」的说明,而不是渲染一张全 0 的假报表。 + */ + available: boolean + /** 数据生成时刻(`YYYY-MM-DD HH:mm`),页面用于「数据截至」提示 */ + 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 + } +} + +function pick(random: () => number, items: readonly T[]): T { + return items[Math.floor(random() * items.length)] as T +} + +function intBetween(random: () => number, min: number, max: number): number { + return min + Math.floor(random() * (max - min + 1)) +} + +/* ========================================================================= + 演示患者池 + ========================================================================= */ + +interface DemoPatient { + name: string + sex: PatientSex + age: number + visitType: VisitType +} + +/** + * 患者池。 + * + * 含 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: '其他' }, +] + +/* ========================================================================= + 演示任务生成 + ========================================================================= */ + +/** 状态分布:已签署占多数,其余各留一点,六种状态在总览统计块里都看得到 */ +const STATUS_WEIGHTS: { status: SigningTaskStatus; weight: number }[] = [ + { status: 'signed', weight: 68 }, + { 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 + } + } + + return 'signed' +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +function formatMoment(date: Date): string { + 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 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: SigningMethod = random() < 0.45 ? 'pad' : 'sms' + + // 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假 + 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) + + const isSigned = status === 'signed' + 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) + } + + // 按发起时间倒序:页面与导出的默认顺序都跟这里一致,不必各自再排一次 + return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) +} + +/* ========================================================================= + 对外接口 + ========================================================================= */ + +let cachedTasks: ReportTaskRow[] | null = null + +/** 演示数据只生成一次:确定性生成也要避免每次进页面都重跑一遍 */ +function readTasks(): ReportTaskRow[] { + cachedTasks ??= buildTasks() + + return cachedTasks +} + +/** + * 取报表分析数据集。 + * + * 真实模式下后端没有对应接口,返回 `available: false` 而不抛错: + * 报表页拿到空数据时的正确表现是「说明原因」,不是弹一个红色报错 —— + * 后端没上线不是用户的错,也不该看起来像页面坏了。 + */ +export async function getReportAnalyticsDataset(): Promise { + if (!useMockData) { + return { tasks: [], available: false, generatedAt: formatMoment(new Date()) } + } + + return { + tasks: readTasks(), + available: true, + generatedAt: formatMoment(new Date()), + } +} diff --git a/clinical-web/src/utils/csv-export.ts b/clinical-web/src/utils/csv-export.ts new file mode 100644 index 0000000..b33a7eb --- /dev/null +++ b/clinical-web/src/utils/csv-export.ts @@ -0,0 +1,67 @@ +/** + * CSV 生成与浏览器下载。 + * + * 抽成公共实现的直接原因:签署记录查询与报表分析都要「按当前条件导出全量」, + * 两边各写一份 CSV 转义迟早会分叉 —— 一边处理了换行一边没处理, + * 导出的文件里就会多出一行被 Excel 解析成错位的数据,而且很难一眼看出来。 + * + * ⚠️ 前端导出是**临时手段**:导出行为、导出人、导出范围都该写入审计日志, + * 浏览器里做不到。后端提供导出任务接口后,这里应整体替换为服务端导出。 + */ + +export interface CsvColumn { + label: string + value: (row: T) => string +} + +/** 含逗号、引号或换行的字段必须整体加引号,内部引号翻倍 */ +export function escapeCsvField(value: string): string { + return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value +} + +/** 文件名时间戳:`YYYYMMDDHHmm` */ +export function fileStamp(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, '0') + + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}` +} + +/** + * 触发浏览器下载。 + * + * 用 `setTimeout(revoke, 0)` 而不是立刻 revoke:立刻释放会让部分浏览器 + * 来不及读取 blob 就中断下载。 + */ +export function saveBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = url + link.download = fileName + link.click() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} + +/** 按列定义把行数据拼成 CSV 文本(含表头,行分隔用 CRLF) */ +export function toCsv(columns: readonly CsvColumn[], rows: readonly T[]): string { + return [ + columns.map((column) => escapeCsvField(column.label)).join(','), + ...rows.map((row) => columns.map((column) => escapeCsvField(column.value(row))).join(',')), + ].join('\r\n') +} + +/** + * 生成并下载 CSV。 + * + * 带 BOM:Excel 直接打开 UTF-8 CSV 时,没有 BOM 会把中文显示成乱码。 + */ +export function downloadCsv( + columns: readonly CsvColumn[], + rows: readonly T[], + fileName: string, +): void { + saveBlob( + new Blob([`\uFEFF${toCsv(columns, rows)}`], { type: 'text/csv;charset=utf-8' }), + fileName, + ) +} diff --git a/clinical-web/src/utils/mock-flags.ts b/clinical-web/src/utils/mock-flags.ts index 36cee09..1bb723c 100644 --- a/clinical-web/src/utils/mock-flags.ts +++ b/clinical-web/src/utils/mock-flags.ts @@ -43,6 +43,17 @@ export const mockFlags = { documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false), /** 报表分析 */ reports: resolveMockFlag(import.meta.env.VITE_MOCK_REPORTS, false), + /** + * 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。 + * + * 与上面的 `reports` 分开:`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/management/MenuResourceView.vue b/clinical-web/src/views/management/MenuResourceView.vue index 8782f62..8aa0d02 100644 --- a/clinical-web/src/views/management/MenuResourceView.vue +++ b/clinical-web/src/views/management/MenuResourceView.vue @@ -17,6 +17,18 @@ const componentRegistry: Record = { 'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')), 'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')), 'report/index': defineAsyncComponent(() => import('@/views/management/reports/index.vue')), + 'report/sign/overview/index': defineAsyncComponent( + () => import('@/views/reports/overview/index.vue'), + ), + 'report/sign/volume/index': defineAsyncComponent( + () => import('@/views/reports/volume/index.vue'), + ), + 'report/sign/timeliness/index': defineAsyncComponent( + () => import('@/views/reports/timeliness/index.vue'), + ), + 'report/patient/structure/index': defineAsyncComponent( + () => import('@/views/reports/patient-structure/index.vue'), + ), 'system/setting/index': defineAsyncComponent( () => import('@/views/management/settings/index.vue'), ), diff --git a/clinical-web/src/views/reports/aggregate.ts b/clinical-web/src/views/reports/aggregate.ts new file mode 100644 index 0000000..6fee40e --- /dev/null +++ b/clinical-web/src/views/reports/aggregate.ts @@ -0,0 +1,483 @@ +/** + * 报表分析的聚合逻辑:全部是纯函数,输入「任务明细 + 筛选条件」,输出页面直接渲染的结构。 + * + * 为什么单独一份而不是写进各页面:同一个「按科室的完成率」在总览的汇总矩阵、 + * 时效分析的分组条形榜、患者结构分析的院区分布里都要用;各页各写一遍, + * 迟早出现「同一个筛选条件,两个页面算出不同的数」——这种不一致在界面上 + * 没有任何提示,只能靠人盯着两个页面比对才能发现。 + * + * 纯函数还有个直接好处:不依赖 DOM 与 Vue,可以用 Node 直接跑断言(见验证脚本)。 + */ + +import type { ReportTaskRow } from '@/api/workbench/report-analytics' +import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types' + +import type { + ReportBarItem, + ReportFilterForm, + ReportGroupRow, + ReportPatientStructure, + ReportStatusCounts, + ReportSummaryDimension, + ReportTimelinessMetrics, + ReportTrendPoint, +} from './types' + +/** 超 24 小时(分钟):时效分析的「24 小时内完成率」分界 */ +export const WITHIN_24H_MINUTES = 1440 +/** 超 48 小时(分钟):超过签署链接有效期,算「超期完成」 */ +export const OVERDUE_MINUTES = 2880 + +/** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */ +const MAX_TREND_DAYS = 60 + +export const METHOD_LABELS: Record = { + pad: '手写板', + sms: '短信线上', +} + +/** + * 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」—— + * 那个状态在后端枚举里不存在,硬映射会让报表与签署记录页对同一个任务给出不同说法。 + */ +export const STATUS_LABELS: Record = { + pending: '待签署', + signing: '签署中', + signed: '已签署', + expired: '已超时', + void: '已作废', + failed: '处理失败', +} + +/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */ +export const STATUS_COLUMNS: SigningTaskStatus[] = [ + 'signed', + 'pending', + 'signing', + 'expired', + 'void', + 'failed', +] + +export const SUMMARY_DIMENSION_LABELS: Record = { + department: '科室', + category: '文档类别', + method: '签署方式', + 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, 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 表示「不做时间过滤」:自定义区间只填了一头时属于「条件还没填完」, + * 这时既不该按半截条件筛,也不该悄悄套一个默认窗口 —— 由页面禁用查询按钮, + * 这里只是给出一个确定的行为。 + */ +export function resolveDateWindow( + filter: ReportFilterForm, + today = new Date(), +): { from: string; to: string } | null { + const to = dateKeyOfDate(today) + + if (filter.dateRange === 'custom') { + 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.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 + }) +} + +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} 分钟` + } + + 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% 橙、其余红(原型口径) */ +export function completionRateColor(rate: number): string { + if (rate >= 90) { + return '#0f9d6c' + } + + return rate >= 80 ? '#d9821f' : '#d0534f' +} + +/** 条形榜调色板(与原型一致) */ +export 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/ReportBarChart.vue b/clinical-web/src/views/reports/components/ReportBarChart.vue new file mode 100644 index 0000000..5d4d01b --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportBarChart.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportBarList.vue b/clinical-web/src/views/reports/components/ReportBarList.vue new file mode 100644 index 0000000..85bd9cf --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportBarList.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportCard.vue b/clinical-web/src/views/reports/components/ReportCard.vue new file mode 100644 index 0000000..b621c00 --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportCard.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportExportDialog.vue b/clinical-web/src/views/reports/components/ReportExportDialog.vue new file mode 100644 index 0000000..0657eed --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportExportDialog.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportFilterBar.vue b/clinical-web/src/views/reports/components/ReportFilterBar.vue new file mode 100644 index 0000000..f750e00 --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportFilterBar.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportKpiTiles.vue b/clinical-web/src/views/reports/components/ReportKpiTiles.vue new file mode 100644 index 0000000..80912b1 --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportKpiTiles.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportPageShell.vue b/clinical-web/src/views/reports/components/ReportPageShell.vue new file mode 100644 index 0000000..9f4d0bd --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportPageShell.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportStatTiles.vue b/clinical-web/src/views/reports/components/ReportStatTiles.vue new file mode 100644 index 0000000..95edb61 --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportStatTiles.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/clinical-web/src/views/reports/components/ReportSummaryMatrix.vue b/clinical-web/src/views/reports/components/ReportSummaryMatrix.vue new file mode 100644 index 0000000..fd10b37 --- /dev/null +++ b/clinical-web/src/views/reports/components/ReportSummaryMatrix.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/clinical-web/src/views/reports/filter-store.ts b/clinical-web/src/views/reports/filter-store.ts new file mode 100644 index 0000000..4f78238 --- /dev/null +++ b/clinical-web/src/views/reports/filter-store.ts @@ -0,0 +1,44 @@ +/** + * 报表分析的筛选条件:四个菜单**共用同一份**。 + * + * 为什么不是每个页面各自持有一份:原型里这四项是同一个报表屏的四个页签, + * 筛选条在页签之上,切页签筛选条件保持不变。在我们的实现里它们是四个独立菜单, + * 如果各持一份,从「签署总览」切到「签署量分析」筛选就会被重置回默认值 —— + * 用户刚选好「东院区 · 近 14 天」,切个页面就白选了,而且四个页面的数字看起来 + * 会互相矛盾(其实是筛选条件不同,但界面上看不出来)。 + * + * 作用域是整个应用会话(模块级 reactive),刷新页面即回到默认值。 + * 不做持久化:报表筛选是「这一次看什么」,不是需要跨会话记住的偏好。 + */ + +import { reactive } from 'vue' + +import type { ReportDateRange, ReportFilterForm } from './types' + +/** + * 默认看近 14 天。 + * 比 7 天更能看出趋势形状,又不会像「本月」那样在月初只有两三个点。 + * 原型标记里写的是「近7天」,但其渲染逻辑的兜底值是 14 天,这里跟逻辑走。 + */ +const DEFAULT_DATE_RANGE: ReportDateRange = '14d' + +export const reportFilter = reactive({ + dateRange: DEFAULT_DATE_RANGE, + customFrom: '', + customTo: '', + campus: 'all', + department: '', + category: '', + method: 'all', +}) + +/** 恢复默认筛选:页面上的「重置」用 */ +export function resetReportFilter(): void { + reportFilter.dateRange = DEFAULT_DATE_RANGE + reportFilter.customFrom = '' + reportFilter.customTo = '' + reportFilter.campus = 'all' + reportFilter.department = '' + reportFilter.category = '' + reportFilter.method = 'all' +} diff --git a/clinical-web/src/views/reports/overview/index.vue b/clinical-web/src/views/reports/overview/index.vue new file mode 100644 index 0000000..a3caf71 --- /dev/null +++ b/clinical-web/src/views/reports/overview/index.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/clinical-web/src/views/reports/patient-structure/index.vue b/clinical-web/src/views/reports/patient-structure/index.vue new file mode 100644 index 0000000..7cdf3b1 --- /dev/null +++ b/clinical-web/src/views/reports/patient-structure/index.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/clinical-web/src/views/reports/timeliness/index.vue b/clinical-web/src/views/reports/timeliness/index.vue new file mode 100644 index 0000000..92e526c --- /dev/null +++ b/clinical-web/src/views/reports/timeliness/index.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/clinical-web/src/views/reports/types.ts b/clinical-web/src/views/reports/types.ts new file mode 100644 index 0000000..da2e319 --- /dev/null +++ b/clinical-web/src/views/reports/types.ts @@ -0,0 +1,128 @@ +/** + * 报表分析(四个菜单共用)的页面类型。 + * + * 聚合结果的形状在这里定义,四个页面都从这里取:同一份筛选条件下, + * 「按科室的发起/已签署/完成率」在总览的汇总矩阵、时效分析的分组条形榜、 + * 患者结构分析的院区分布里必须得出同一个数,各页各定义一套结构迟早对不上。 + */ + +import type { + PatientSex, + SigningMethod, + SigningTaskStatus, + VisitType, + WorkbenchCampus, +} from '@/api/workbench/types' +import type { ReportTaskRow } from '@/api/workbench/report-analytics' + +/** 时间范围:对齐原型的「近7天 / 近14天 / 本月 / 自定义」 */ +export type ReportDateRange = '7d' | '14d' | 'month' | 'custom' + +export interface ReportFilterForm { + dateRange: ReportDateRange + /** 自定义区间的起止(`YYYY-MM-DD`,含端点),仅 dateRange === 'custom' 时有意义 */ + customFrom: string + customTo: string + campus: WorkbenchCampus | 'all' + /** 空串表示不限 */ + department: string + /** 空串表示不限 */ + category: string + method: SigningMethod | 'all' +} + +/** 下拉选项。报表的筛选项取值都是字符串(含 `'all'` 这类哨兵值),默认按 string 处理 */ +export interface ReportFilterOption { + label: string + value: T +} + +/** 六种任务状态的计数:统计块、汇总矩阵、下钻都用这一个结构 */ +export interface ReportStatusCounts { + total: number + signed: number + pending: number + signing: number + expired: number + void: number + failed: number +} + +/** 分组统计行(按科室 / 文档类别 / 签署方式 / 院区) */ +export interface ReportGroupRow extends ReportStatusCounts { + key: string + /** 完成率(百分比,已取整)。发起为 0 时按 0 计 */ + completionRate: number +} + +/** 趋势点:同一天既要发起量也要完成量 */ +export interface ReportTrendPoint { + /** `YYYY-MM-DD`,用于跨图对齐同一天 */ + key: string + /** 坐标轴标签 `M/D` */ + label: string + created: number + signed: number +} + +/** 横向条形榜的一项 */ +export interface ReportBarItem { + label: string + value: number + /** 右侧文案;不给就显示数值本身 */ + display?: string + /** 指定条色;不给就按调色板轮换(时效页用它表达「超 24h 橙 / 超 48h 红」) */ + color?: string +} + +/** + * 柱状图的系列与点位。 + * + * 组件只认这一层抽象、不认业务字段:「发起 / 完成」也好、「平均时长」也好, + * 页面自己映射过来即可 —— 否则组件里会长出一堆 `if (kind === 'timeliness')`。 + */ +export interface ReportChartSeries { + label: string + /** 柱体颜色(渐变高光色) */ + color: string +} + +export interface ReportChartPoint { + label: string + /** 与 series 一一对应的取值 */ + values: number[] +} + +/** 签署时效指标。样本为 0 时指标为 null,页面显示「—」而不是 0 */ +export interface ReportTimelinessMetrics { + /** 平均签署时长(分钟) */ + averageMinutes: number | null + /** 24 小时内完成率(百分比,已取整) */ + within24hRate: number | null + /** 超期完成占比(完成用时超 48 小时,百分比,已取整) */ + overdueRate: number | null + /** 超时未签任务数(状态为已超时) */ + expiredTaskCount: number + /** 参与时效计算的已签署样本数 */ + signedSampleCount: number +} + +/** 汇总矩阵的分组维度 */ +export type ReportSummaryDimension = 'department' | 'category' | 'method' | 'campus' + +/** 导出字段定义:勾选项与实际取值一一对应 */ +export interface ReportExportColumn { + key: string + label: string + value: (row: ReportTaskRow) => string +} + +/** 患者结构的四个维度取值 */ +export interface ReportPatientStructure { + age: ReportBarItem[] + sex: ReportBarItem[] + visitType: ReportBarItem[] + campus: ReportBarItem[] +} + +export type { ReportTaskRow, PatientSex, SigningMethod, SigningTaskStatus, VisitType } diff --git a/clinical-web/src/views/reports/use-report-dataset.ts b/clinical-web/src/views/reports/use-report-dataset.ts new file mode 100644 index 0000000..d540a36 --- /dev/null +++ b/clinical-web/src/views/reports/use-report-dataset.ts @@ -0,0 +1,91 @@ +/** + * 报表分析的数据装载与筛选:四个菜单共用同一个数据集与同一份筛选条件。 + * + * 数据集放在模块级而不是每个页面各取一次:四个菜单的数据源完全一样, + * 各取一次会生成四份互不相干的演示数据 —— 从「签署总览」切到「签署量分析」, + * 累计发起数会变,看起来像统计口径不同,其实只是随机数不同。 + */ + +import { computed, ref } from 'vue' + +import { + getReportAnalyticsDataset, + type ReportAnalyticsDataset, + type ReportTaskRow, +} from '@/api/workbench/report-analytics' + +import { filterReportTasks, resolveDateWindow } from './aggregate' +import { reportFilter } from './filter-store' + +const dataset = ref(null) +const loading = ref(false) +const errorMessage = ref('') + +let pending: Promise | null = null + +/** + * 装载数据集。 + * + * 并发去重:页面挂载与筛选条刷新可能同时触发,重复请求没有意义。 + * 只取一次成功后就不再重取 —— 演示数据是确定性的,重取只会换掉「数据截至」的时刻。 + */ +export function loadReportDataset(): Promise { + if (dataset.value) { + return Promise.resolve() + } + + pending ??= (async () => { + loading.value = true + errorMessage.value = '' + + try { + dataset.value = await getReportAnalyticsDataset() + } catch { + errorMessage.value = '报表数据加载失败,请稍后重试' + } finally { + loading.value = false + pending = null + } + })() + + return pending +} + +/** 仅供演示 / 验证脚本:清掉缓存后重新装载 */ +export function resetReportDataset(): void { + dataset.value = null + pending = null +} + +export function useReportDataset() { + /** 当前筛选对应的时间窗口;null 表示自定义区间还没填完 */ + const window = computed(() => resolveDateWindow(reportFilter)) + + const allTasks = computed(() => dataset.value?.tasks ?? []) + + /** 当前筛选下的任务明细:四个页面的所有数字都从这里算 */ + const tasks = computed(() => filterReportTasks(allTasks.value, reportFilter)) + + const available = computed(() => dataset.value?.available ?? false) + const generatedAt = computed(() => dataset.value?.generatedAt ?? '') + + /** + * 自定义区间只填了一头时为 true。 + * 这种半截条件既不该按半截筛,也不该悄悄套默认窗口,页面据此提示用户补全。 + */ + const customRangeIncomplete = computed( + () => + reportFilter.dateRange === 'custom' && (!reportFilter.customFrom || !reportFilter.customTo), + ) + + return { + allTasks, + tasks, + window, + available, + generatedAt, + loading, + errorMessage, + customRangeIncomplete, + } +} diff --git a/clinical-web/src/views/reports/volume/index.vue b/clinical-web/src/views/reports/volume/index.vue new file mode 100644 index 0000000..8436491 --- /dev/null +++ b/clinical-web/src/views/reports/volume/index.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/clinical-web/src/views/workbench/records/index.vue b/clinical-web/src/views/workbench/records/index.vue index 84a7d72..40bb8ef 100644 --- a/clinical-web/src/views/workbench/records/index.vue +++ b/clinical-web/src/views/workbench/records/index.vue @@ -4,14 +4,13 @@ import { ElMessage } from 'element-plus' import { getCampuses, getDepartments } from '@/api/management/organization' import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts' +import { mockCatalogCategories, mockCatalogDepartments } from '@/api/workbench/mock-catalog' import { getAllSigningRecords, getSigningRecordDetail, getSigningRecordEvents, getSigningRecords, isSigningRecordsMockEnabled, - mockRecordCategories, - mockRecordDepartments, } from '@/api/workbench/records' import type { SigningApiOptions, @@ -22,6 +21,7 @@ import type { } from '@/api/workbench/types' import { useAppStore } from '@/stores/app' import { useLoginStore } from '@/stores/login' +import { downloadCsv, fileStamp, saveBlob } from '@/utils/csv-export' import { downloadSigningPdf } from '@/utils/signing-pdf' import SigningRecordDetailDialog from './components/SigningRecordDetailDialog.vue' @@ -143,7 +143,7 @@ const methodOptions: SigningRecordFilterOption[] = [ * 不用「当前页出现过的科室」——翻页会换掉可选项,筛完再想换一个科室就找不到了。 */ const departmentOptions = computed(() => { - const names = isSigningRecordsMockEnabled ? mockRecordDepartments : departmentNames.value + const names = isSigningRecordsMockEnabled ? mockCatalogDepartments : departmentNames.value return [ { label: '全部科室', value: '' }, ...names.map((name) => ({ label: name, value: name })), @@ -153,7 +153,7 @@ const departmentOptions = computed(() => { const categoryOptions = computed(() => { // 真实模式下后端签署任务接口不支持按类别检索,这里给不出可选项,由筛选条禁用该字段 const categories = isSigningRecordsMockEnabled - ? mockRecordCategories + ? mockCatalogCategories : [...new Set(rows.value.map((row) => row.category).filter(Boolean))] return [ @@ -309,16 +309,6 @@ function closeRecord() { detailEvents.value = [] } -function saveBlob(blob: Blob, fileName: string) { - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - - link.href = url - link.download = fileName - link.click() - window.setTimeout(() => URL.revokeObjectURL(url), 0) -} - async function handleDownloadPdf() { const record = detailRecord.value @@ -370,10 +360,6 @@ async function handleDownloadPdf() { } } -function escapeCsvField(value: string) { - return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value -} - /** * 导出当前筛选条件下的全部记录。 * @@ -405,25 +391,7 @@ async function handleExport() { return } - const lines = [ - EXPORT_COLUMNS.map((column) => escapeCsvField(column.label)).join(','), - ...records.map((record) => - EXPORT_COLUMNS.map((column) => escapeCsvField(column.value(record))).join(','), - ), - ] - const now = new Date() - const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String( - now.getDate(), - ).padStart(2, '0')}${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart( - 2, - '0', - )}` - - // 带 BOM:Excel 直接打开 UTF-8 CSV 才不会把中文显示成乱码 - saveBlob( - new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), - `签署记录_${stamp}.csv`, - ) + downloadCsv(EXPORT_COLUMNS, records, `签署记录_${fileStamp()}.csv`) ElMessage.success(`已导出 ${records.length} 条签署记录`) } catch { ElMessage.error('导出失败,请稍后重试')