feat(报表分析): 新增签署总览等四个报表菜单并拆分演示数据开关

This commit is contained in:
yelan
2026-09-24 21:03:39 +08:00
parent abe7e17582
commit 78402859fd
26 changed files with 3618 additions and 193 deletions
+5
View File
@@ -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
+25 -3
View File
@@ -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`。
@@ -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<string, WorkbenchCampus> = {
心内科: '本部院区',
消化内科: '本部院区',
'麻醉科·精神科': '本部院区',
医务处: '本部院区',
医学影像科: '东院区',
急诊科: '东院区',
普外科: '西院区',
血液科: '西院区',
}
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] ?? '本部院区'
}
+10 -153
View File
@@ -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<string, WorkbenchCampus> = {
心内科: '本部院区',
消化内科: '本部院区',
'麻醉科·精神科': '本部院区',
医务处: '本部院区',
医学影像科: '东院区',
急诊科: '东院区',
普外科: '西院区',
血液科: '西院区',
}
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<VisitType, string> = {
}
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,
@@ -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<T>(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<ReportAnalyticsDataset> {
if (!useMockData) {
return { tasks: [], available: false, generatedAt: formatMoment(new Date()) }
}
return {
tasks: readTasks(),
available: true,
generatedAt: formatMoment(new Date()),
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* CSV 生成与浏览器下载。
*
* 抽成公共实现的直接原因:签署记录查询与报表分析都要「按当前条件导出全量」,
* 两边各写一份 CSV 转义迟早会分叉 —— 一边处理了换行一边没处理,
* 导出的文件里就会多出一行被 Excel 解析成错位的数据,而且很难一眼看出来。
*
* ⚠️ 前端导出是**临时手段**:导出行为、导出人、导出范围都该写入审计日志,
* 浏览器里做不到。后端提供导出任务接口后,这里应整体替换为服务端导出。
*/
export interface CsvColumn<T> {
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<T>(columns: readonly CsvColumn<T>[], 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<T>(
columns: readonly CsvColumn<T>[],
rows: readonly T[],
fileName: string,
): void {
saveBlob(
new Blob([`\uFEFF${toCsv(columns, rows)}`], { type: 'text/csv;charset=utf-8' }),
fileName,
)
}
+11
View File
@@ -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),
/**
@@ -17,6 +17,18 @@ const componentRegistry: Record<string, Component> = {
'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'),
),
+483
View File
@@ -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<SigningMethod, string> = {
pad: '手写板',
sms: '短信线上',
}
/**
* 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」——
* 那个状态在后端枚举里不存在,硬映射会让报表与签署记录页对同一个任务给出不同说法。
*/
export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */
export const STATUS_COLUMNS: SigningTaskStatus[] = [
'signed',
'pending',
'signing',
'expired',
'void',
'failed',
]
export const SUMMARY_DIMENSION_LABELS: Record<ReportSummaryDimension, string> = {
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<string, number>()
const signedMap = new Map<string, number>()
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<string, ReportStatusCounts>()
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<string, number>()
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<string, { total: number; count: number }>()
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<string, { total: number; count: number }>()
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'
}
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import type { ReportChartPoint, ReportChartSeries } from '../types'
/**
* 柱状图(单系列 / 双系列共用)。
*
* 为什么手写 SVG 而不是引图表库:报表页只需要柱状图与横向条形榜两种形态,
* 引一个图表库要多几百 KB,还得为它单独做主题适配(主题色来自 CSS 变量,
* 图表库多半读不到)。原型也是内联 SVG,这里沿用同一套画法,视觉与原型一致。
*/
const props = withDefaults(
defineProps<{
points: ReportChartPoint[]
series: ReportChartSeries[]
unit?: string
/** 空态文案 */
emptyText?: string
}>(),
{ unit: '单', emptyText: '当前条件下暂无数据' },
)
const VIEW_WIDTH = 1240
const VIEW_HEIGHT = 200
const PADDING_X = 30
const PADDING_TOP = 26
const AXIS_HEIGHT = 26
/**
* 同页可能出现多个图表,渐变 id 必须各不相同:
* 同名 <defs> 会互相覆盖,第二个图表的柱子会变成第一个的颜色。
*
* 用 `useId()` 取每实例唯一值,而不是模块级自增计数器 ——
* 计数器需要在 setup 阶段自增一次,多实例场景下语义上就是个全局可变状态,
* 而 `useId()` 本身就是为「每实例唯一且服务端渲染也一致」设计的。
*/
const chartUid = `report-bar-${useId()}`
const maxValue = computed(() => {
const values = props.points.flatMap((point) => point.values)
return Math.max(...values, 3) * 1.2
})
const gridLines = computed(() => {
const usable = VIEW_HEIGHT - PADDING_TOP - AXIS_HEIGHT
return [0, 1, 2, 3].map((index) => PADDING_TOP + (usable / 3) * index)
})
const baseline = VIEW_HEIGHT - AXIS_HEIGHT
/** 每个点位的柱子几何:双系列并排,单系列居中加宽 */
const bars = computed(() => {
const slot = (VIEW_WIDTH - PADDING_X * 2) / Math.max(props.points.length, 1)
const seriesCount = Math.max(props.series.length, 1)
const gap = 3
const barWidth =
seriesCount > 1 ? Math.min((slot - gap * (seriesCount - 1)) * 0.34, 20) : Math.min(slot * 0.55, 34)
const groupWidth = barWidth * seriesCount + gap * (seriesCount - 1)
return props.points.map((point, pointIndex) => {
const groupLeft = PADDING_X + slot * pointIndex + (slot - groupWidth) / 2
return {
label: point.label,
bars: point.values.map((value, seriesIndex) => {
const height = (value / maxValue.value) * (baseline - PADDING_TOP)
return {
x: groupLeft + (barWidth + gap) * seriesIndex,
y: baseline - height,
width: barWidth,
height: Math.max(height, 2),
value,
}
}),
}
})
})
function axisX(pointIndex: number): number {
const slot = (VIEW_WIDTH - PADDING_X * 2) / Math.max(props.points.length, 1)
return PADDING_X + slot * pointIndex + slot / 2
}
</script>
<template>
<div v-if="points.length" class="chart">
<svg :viewBox="`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`" role="img">
<defs>
<linearGradient
v-for="(item, index) in series"
:id="`${chartUid}-${index}`"
:key="item.label"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0" :stop-color="item.color" stop-opacity="0.82" />
<stop offset="1" :stop-color="item.color" />
</linearGradient>
</defs>
<line
v-for="line in gridLines"
:key="line"
:x1="PADDING_X"
:y1="line"
:x2="VIEW_WIDTH - PADDING_X"
:y2="line"
stroke="#e6eef2"
stroke-dasharray="3 4"
/>
<g v-for="(group, groupIndex) in bars" :key="group.label + groupIndex">
<rect
v-for="(bar, barIndex) in group.bars"
:key="barIndex"
:x="bar.x"
:y="bar.y"
:width="bar.width"
:height="bar.height"
rx="4"
:fill="bar.value ? `url(#${chartUid}-${barIndex})` : '#e2ebef'"
>
<title>{{ group.label }} · {{ series[barIndex]?.label }}:{{ bar.value }} {{ unit }}</title>
</rect>
<text
v-if="group.bars.length === 1 && group.bars[0]?.value"
:x="axisX(groupIndex)"
:y="(group.bars[0]?.y ?? 0) - 6"
text-anchor="middle"
font-size="11"
fill="#0a4f66"
font-weight="bold"
>
{{ group.bars[0]?.value }}
</text>
<text
:x="axisX(groupIndex)"
:y="VIEW_HEIGHT - 10"
text-anchor="middle"
font-size="10.5"
fill="#687f8b"
>
{{ group.label }}
</text>
</g>
</svg>
<div v-if="series.length > 1" class="legend">
<span v-for="item in series" :key="item.label" class="legend__item">
<i :style="{ background: item.color }" />
{{ item.label }}
</span>
</div>
</div>
<div v-else class="chart-empty">{{ emptyText }}</div>
</template>
<style scoped>
.chart {
width: 100%;
}
.chart svg {
display: block;
width: 100%;
height: auto;
}
.legend {
display: flex;
gap: 18px;
justify-content: flex-end;
margin-top: 2px;
color: var(--mut);
font-size: 12px;
}
.legend__item {
display: inline-flex;
gap: 6px;
align-items: center;
}
.legend__item i {
width: 8px;
height: 8px;
border-radius: 2px;
}
.chart-empty {
display: grid;
min-height: 180px;
color: var(--mut);
font-size: 12.5px;
place-items: center;
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ReportBarItem } from '../types'
import { barColorOf } from '../aggregate'
/**
* 横向条形榜:科室签署量排行、签署方式分布、文档 TOP10、患者结构各维度都用它。
*
* 条长按「本组最大值」归一,而不是按总量占比 —— 排行榜要回答的是
* 「谁比谁多、差多少」,按总量归一会让除第一名以外的条都短得看不出差别。
*/
const props = withDefaults(
defineProps<{
items: ReportBarItem[]
/** 空态文案 */
emptyText?: string
/** 标签列宽度(患者结构页的年龄段标签更长,需要放宽) */
labelWidth?: number
}>(),
{ emptyText: '当前条件下暂无数据', labelWidth: 110 },
)
const maxValue = computed(() => Math.max(...props.items.map((item) => item.value), 1))
function widthOf(item: ReportBarItem): string {
// 最小 2%:值为 0 的分组也要留一丝可见的条,否则看起来像「这一行没渲染出来」
return `${Math.max((item.value / maxValue.value) * 100, item.value > 0 ? 2 : 0)}%`
}
</script>
<template>
<div v-if="items.length" class="bar-list">
<div v-for="(item, index) in items" :key="item.label" class="bar-row">
<span class="bar-row__label" :style="{ width: `${labelWidth}px` }" :title="item.label">
{{ item.label }}
</span>
<div class="bar-row__track">
<div
class="bar-row__fill"
:style="{ width: widthOf(item), background: barColorOf(item, index) }"
/>
</div>
<b class="bar-row__value">{{ item.display ?? item.value }}</b>
</div>
</div>
<div v-else class="bar-empty">{{ emptyText }}</div>
</template>
<style scoped>
.bar-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.bar-row {
display: flex;
gap: 10px;
align-items: center;
}
.bar-row__label {
flex-shrink: 0;
overflow: hidden;
color: var(--ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-row__track {
height: 14px;
flex: 1;
overflow: hidden;
background: #eef4f6;
border-radius: 7px;
}
.bar-row__fill {
height: 100%;
border-radius: 7px;
transition: width 0.25s ease;
}
.bar-row__value {
width: 84px;
flex-shrink: 0;
color: var(--ink);
font-size: 12px;
text-align: right;
}
.bar-empty {
padding: 22px 0;
color: var(--mut);
font-size: 12.5px;
text-align: center;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
/**
* 报表卡片外壳:标题 + 副标题 + 右上角操作区 + 内容。
*
* 单独抽出来的原因:四个页面一共十几张卡片,标题行(字号、间距、副标题颜色、
* 「右上角留一个操作位」)如果各写一遍,改一次样式要改十几处,
* 而且很容易出现某一张卡片的标题比别的粗一点这种看不出来又很显脏的差异。
*/
defineProps<{
title: string
/** 标题右侧的灰色说明,用于交代口径(如「按任务关联患者统计」) */
subtitle?: string
/** 去掉内容区上边距,用于表格类内容自带表头的场景 */
flush?: boolean
}>()
</script>
<template>
<section class="report-card">
<header class="report-card__head">
<h3>{{ title }}</h3>
<span v-if="subtitle" class="report-card__sub">{{ subtitle }}</span>
<span class="report-card__spacer" />
<slot name="actions" />
</header>
<div class="report-card__body" :class="{ 'is-flush': flush }">
<slot />
</div>
</section>
</template>
<style scoped>
.report-card {
padding: 14px 18px 16px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.report-card__head {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.report-card__head h3 {
margin: 0;
color: var(--ink);
font-size: 13.5px;
font-weight: 700;
}
.report-card__sub {
color: var(--mut);
font-size: 12px;
}
.report-card__spacer {
flex: 1;
}
.report-card__body.is-flush {
margin-top: -12px;
}
</style>
@@ -0,0 +1,419 @@
<script setup lang="ts">
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 { filterReportTasks, formatDuration, METHOD_LABELS, STATUS_LABELS } from '../aggregate'
import type { ReportDateRange, ReportExportColumn, ReportFilterForm, ReportFilterOption } from '../types'
/**
* 导出弹窗。
*
* 「导出范围」在弹窗里可以独立调整,而不是固定沿用页面上的筛选:导出通常是
* 「把范围放宽一点拿全量数据」,如果只能导出当前所见,用户得先改页面筛选、
* 导出、再改回来。
*
* ⚠️ 前端导出只是权宜之计:导出行为、导出人与导出范围必须写入审计日志,
* 浏览器里做不到。后端提供导出任务接口后应整体替换(参考签署记录查询的同类说明)。
*/
const props = defineProps<{
visible: boolean
/** 未筛选的全量明细:弹窗自己按导出范围过滤 */
tasks: ReportTaskRow[]
/** 弹窗打开时用于初始化导出范围 */
filter: ReportFilterForm
campuses: ReportFilterOption<WorkbenchCampus | 'all'>[]
departments: ReportFilterOption[]
categories: ReportFilterOption[]
}>()
const emit = defineEmits<{
close: []
}>()
const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
{ label: '近 7 天', value: '7d' },
{ label: '近 14 天', value: '14d' },
{ label: '本月', value: 'month' },
{ label: '自定义…', value: 'custom' },
]
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
{ label: '全部方式', value: 'all' },
{ label: '手写板', value: 'pad' },
{ label: '短信线上', value: 'sms' },
]
/**
* 导出列。
*
* 前九项与原型一致,默认勾选;其余是台账里常要的补充字段,默认不勾 ——
* 默认全勾会让导出的表宽到没法看,而默认只给最少字段又要用户每次手动加。
*/
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 DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key)
const draft = ref<ReportFilterForm>({ ...props.filter })
const checkedKeys = ref<string[]>([...DEFAULT_CHECKED])
const exporting = ref(false)
// 每次打开都按「当前页面筛选」重置:上次调过的导出范围留到下一次会让人导错数据
watch(
() => props.visible,
(visible) => {
if (visible) {
draft.value = { ...props.filter }
checkedKeys.value = [...DEFAULT_CHECKED]
}
},
)
const scopedTasks = computed(() => filterReportTasks(props.tasks, draft.value))
const customRangeIncomplete = computed(
() => draft.value.dateRange === 'custom' && (!draft.value.customFrom || !draft.value.customTo),
)
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,
)
function handleClose() {
emit('close')
}
function handleExport() {
if (customRangeIncomplete.value) {
ElMessage.info('自定义时间范围需要同时填写起止日期')
return
}
if (!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('导出失败,请稍后重试')
} finally {
exporting.value = false
}
}
</script>
<template>
<el-dialog
:model-value="props.visible"
width="680px"
top="8vh"
class="report-export-dialog"
:close-on-click-modal="false"
@update:model-value="handleClose"
>
<template #header>
<div class="dialog-header">
<strong>导出签署报表</strong>
<span class="dialog-header__sub">报表分析 · 按当前口径导出任务明细</span>
</div>
</template>
<section class="section">
<h4 class="section__title">导出范围</h4>
<div class="field-grid">
<label class="field">
<span>时间范围</span>
<select v-model="draft.dateRange" aria-label="导出时间范围">
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="field">
<span>院区</span>
<select v-model="draft.campus" aria-label="导出院区">
<option v-for="option in campuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="field">
<span>科室</span>
<select v-model="draft.department" aria-label="导出科室">
<option v-for="option in departments" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="field">
<span>文档类别</span>
<select v-model="draft.category" aria-label="导出文档类别">
<option v-for="option in categories" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="field">
<span>签署方式</span>
<select v-model="draft.method" aria-label="导出签署方式">
<option v-for="option in methodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<div v-if="draft.dateRange === 'custom'" class="field-grid">
<label class="field">
<span>起始日期</span>
<input v-model="draft.customFrom" type="date" aria-label="导出自定义起始日期" />
</label>
<label class="field">
<span>结束日期</span>
<input v-model="draft.customTo" type="date" aria-label="导出自定义结束日期" />
</label>
</div>
<p class="hint" :class="{ 'is-warn': customRangeIncomplete }">
<template v-if="customRangeIncomplete">自定义时间范围需要同时填写起止日期。</template>
<template v-else>本次将导出 <b>{{ scopedTasks.length }}</b> 条签署任务。</template>
</p>
</section>
<section class="section">
<h4 class="section__title">导出内容</h4>
<div class="columns">
<label v-for="column in EXPORT_COLUMNS" :key="column.key" class="column">
<input v-model="checkedKeys" type="checkbox" :value="column.key" />
<span>{{ column.label }}</span>
</label>
</div>
<p class="hint">
文件格式:CSV(UTF-8,带 BOM,Excel 可直接打开)。Excel / PDF 需后端导出任务支持,当前未接入。
</p>
</section>
<p class="note">
导出行为、导出人与导出范围均需写入审计日志,可在「审计日志」模块追溯;当前为前端导出,尚未留痕。
</p>
<template #footer>
<div class="dialog-footer">
<button type="button" class="action-button action-button--ghost" @click="handleClose">
取消
</button>
<button
type="button"
class="action-button"
:disabled="!canExport || exporting"
@click="handleExport"
>
{{ exporting ? '导出中…' : '开始导出' }}
</button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-header {
display: flex;
gap: 10px;
align-items: baseline;
}
.dialog-header strong {
color: var(--ink);
font-size: 15px;
}
.dialog-header__sub {
color: var(--mut);
font-size: 12px;
}
.section {
margin-bottom: 18px;
}
.section__title {
margin: 0 0 10px;
color: var(--ink);
font-size: 13px;
font-weight: 700;
}
.field-grid {
display: grid;
gap: 10px 16px;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.field {
display: flex;
gap: 8px;
align-items: center;
color: var(--mut);
font-size: 12.5px;
}
.field span {
flex-shrink: 0;
}
.field select,
.field input {
width: 100%;
min-width: 0;
padding: 6px 8px;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.field select:focus,
.field input:focus {
outline: none;
border-color: var(--brand);
}
.columns {
display: grid;
gap: 8px 12px;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.column {
display: flex;
gap: 6px;
align-items: center;
color: var(--ink);
font-size: 12.5px;
cursor: pointer;
}
.column input {
accent-color: var(--brand);
}
.hint {
margin: 10px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
}
.hint b {
color: var(--brand);
}
.hint.is-warn {
color: var(--err);
}
.note {
padding: 9px 12px;
margin: 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
background: #f5f9fa;
border-radius: 6px;
}
.dialog-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.action-button {
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
transition: background 0.15s ease;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
.action-button:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #eef4f6;
}
.action-button--ghost:disabled {
color: #9aabb3;
background: #fff;
border-color: var(--line);
}
</style>
@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
import { reportFilter, resetReportFilter } from '../filter-store'
import type { ReportDateRange, ReportFilterOption } from '../types'
/**
* 报表筛选条:四个菜单共用同一条,直接绑定共享的 `reportFilter`。
*
* 与「签署记录查询」的筛选条不同,这里不做草稿态 + 查询按钮:
* 报表是「换个条件立刻看结果」的探索型页面,每次改条件都要再点一次查询很别扭;
* 原型也是 `onchange` 直接重渲染。
*
* 四个页面绑同一份筛选,切页时条件保持不变 —— 用户刚选好「东院区 · 近 14 天」,
* 切到另一个报表页却被打回默认值,会让人以为两个页面的数字互相矛盾。
*/
defineProps<{
campuses: ReportFilterOption<WorkbenchCampus | 'all'>[]
departments: ReportFilterOption[]
categories: ReportFilterOption[]
}>()
const emit = defineEmits<{
export: []
}>()
const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
{ label: '近 7 天', value: '7d' },
{ label: '近 14 天', value: '14d' },
{ label: '本月', value: 'month' },
{ label: '自定义…', value: 'custom' },
]
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
{ label: '全部方式', value: 'all' },
{ label: '手写板', value: 'pad' },
{ label: '短信线上', value: 'sms' },
]
/**
* 重置只改共享的 `reportFilter`,不往外抛事件:筛选条是唯一的写入方,
* 页面拿到的事件除了「我已经重置了」之外没有别的信息可用,
* 多一个没人监听的事件只会让人以为重置还有别的副作用。
*/
function handleReset() {
resetReportFilter()
}
</script>
<template>
<form class="filter-card" @submit.prevent>
<label class="filter-field">
<span>时间</span>
<select v-model="reportFilter.dateRange" aria-label="筛选时间范围">
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<template v-if="reportFilter.dateRange === 'custom'">
<label class="filter-field">
<span>起</span>
<input v-model="reportFilter.customFrom" type="date" aria-label="自定义起始日期" />
</label>
<label class="filter-field">
<span>止</span>
<input v-model="reportFilter.customTo" type="date" aria-label="自定义结束日期" />
</label>
</template>
<label class="filter-field">
<span>院区</span>
<select v-model="reportFilter.campus" aria-label="筛选院区">
<option v-for="option in campuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>科室</span>
<select v-model="reportFilter.department" aria-label="筛选科室">
<option v-for="option in departments" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>文档类别</span>
<select v-model="reportFilter.category" aria-label="筛选文档类别">
<option v-for="option in categories" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>方式</span>
<select v-model="reportFilter.method" aria-label="筛选签署方式">
<option v-for="option in methodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<span class="filter-spacer" />
<button type="button" class="action-button action-button--ghost" @click="handleReset">
重置
</button>
<button type="button" class="action-button action-button--ghost" @click="emit('export')">
导出
</button>
</form>
</template>
<style scoped>
.filter-card {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.filter-field {
display: flex;
min-width: 104px;
gap: 6px;
align-items: center;
color: var(--mut);
font-size: 12.5px;
}
.filter-field select,
.filter-field input {
width: auto;
min-width: 82px;
padding: 7px 9px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.filter-field select:focus,
.filter-field input:focus {
outline: none;
border-color: var(--brand);
}
.filter-field input[type='date'] {
min-width: 132px;
}
.filter-spacer {
flex: 1;
min-width: 12px;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
transition: background 0.15s ease;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
@media (max-width: 1200px) {
.filter-spacer {
display: none;
}
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* 指标卡:签署时效分析顶部的四个 KPI(平均签署时长 / 24 小时内完成率 /
* 超期完成占比 / 超时未签任务)。
*
* 与 ReportStatTiles 分开:那一条是「同质的一排数字」,这里是「每个指标各有
* 自己的图标与语义色」。硬塞进一个组件会长出一个 variant 分支,
* 两边的样式互相牵扯,改一边就得回归另一边。
*
* 值允许为 null:样本为 0 时指标算不出来,显示「—」而不是 0 ——
* 0 会被读成「真的很差」,而事实是「没有样本」。
*/
type ReportKpiIcon = 'clock' | 'check' | 'alert' | 'warning'
defineProps<{
items: {
label: string
value: string | number | null
icon: ReportKpiIcon
/** 数值与图标的主色 */
color: string
/** 图标底色:用主题里配好的浅色(--info-l 等),不在组件里现算混色 */
softColor: string
}[]
}>()
const ICON_PATHS: Record<ReportKpiIcon, string> = {
clock: 'M12 7v5l3 3',
check: 'M20 6L9 17l-5-5',
alert: 'M12 8v5M12 16h.01',
warning: 'M12 9v4M12 17h.01M10.3 3.9L2.4 18a2 2 0 0 0 1.7 3h16a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z',
}
</script>
<template>
<div class="kpi-grid">
<div v-for="item in items" :key="item.label" class="kpi">
<span class="kpi__icon" :style="{ background: item.softColor }">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle
v-if="item.icon === 'clock' || item.icon === 'alert'"
cx="12"
cy="12"
r="9"
:stroke="item.color"
stroke-width="2"
/>
<path
:d="ICON_PATHS[item.icon]"
:stroke="item.color"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<div class="kpi__text">
<div class="kpi__value" :style="{ color: item.color }">
{{ item.value === null ? '—' : item.value }}
</div>
<div class="kpi__label">{{ item.label }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.kpi-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.kpi {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.kpi__icon {
display: grid;
width: 42px;
height: 42px;
flex-shrink: 0;
border-radius: 10px;
place-items: center;
}
.kpi__text {
min-width: 0;
}
.kpi__value {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.kpi__label {
margin-top: 2px;
color: var(--mut);
font-size: 12px;
}
</style>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { mockCatalogCategories, mockCatalogDepartments } from '@/api/workbench/mock-catalog'
import type { WorkbenchCampus } from '@/api/workbench/types'
import { useAppStore } from '@/stores/app'
import { reportFilter } from '../filter-store'
import type { ReportFilterOption } from '../types'
import { loadReportDataset, useReportDataset } from '../use-report-dataset'
import ReportExportDialog from './ReportExportDialog.vue'
import ReportFilterBar from './ReportFilterBar.vue'
/**
* 报表页面外壳:标题 + 筛选条 + 数据可用性闸门 + 导出弹窗,四个菜单共用。
*
* 为什么把闸门和导出也放进来:这两件事四个页面一字不差。各写一遍的话,
* 「后端没有接口」的说明文案会在四个页面里慢慢分叉,导出列也会各自漂移 ——
* 到时候同一个任务在总览导出和时效导出里字段都不一样。
*/
const props = defineProps<{
title: string
description: string
}>()
const appStore = useAppStore()
const { allTasks, tasks, available, generatedAt, loading, errorMessage, customRangeIncomplete } =
useReportDataset()
const exportVisible = ref(false)
const campusOptions = computed<ReportFilterOption<WorkbenchCampus | 'all'>[]>(() => [
{ label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
])
/**
* 科室与类别取演示词表:报表的演示数据本身就来自这份词表,
* 用组织接口的科室清单会出现「筛了某个科室但一条数据都没有」的假空态。
* 真实模式下面板不渲染(没有数据可筛),这里无需再区分口径。
*/
const departmentOptions: ReportFilterOption[] = [
{ label: '全部科室', value: '' },
...mockCatalogDepartments.map((name) => ({ label: name, value: name })),
]
const categoryOptions: ReportFilterOption[] = [
{ label: '全部类别', value: '' },
...mockCatalogCategories.map((name) => ({ label: name, value: name })),
]
const metaText = computed(() =>
generatedAt.value ? `数据截至 ${generatedAt.value}` : '正在读取数据…',
)
onMounted(async () => {
await appStore.loadCampuses()
await loadReportDataset()
})
</script>
<template>
<section class="report-page">
<div class="page-intro">
<div>
<h1>{{ props.title }}</h1>
<p>{{ props.description }}</p>
</div>
<div class="page-intro__meta">
<span v-if="available">当前筛选 {{ tasks.length }} 条任务</span>
<span>{{ metaText }}</span>
</div>
</div>
<template v-if="loading && !available">
<div class="report-state">正在读取报表数据…</div>
</template>
<template v-else-if="errorMessage">
<div class="report-state report-state--error">{{ errorMessage }}</div>
</template>
<template v-else-if="!available">
<div class="report-state">
<strong>后端尚未提供报表分析接口</strong>
<p>
报表分析目前只有演示数据。真实模式(关闭演示数据)下这里没有可用的聚合结果,
因此不渲染任何统计数字 —— 显示一张全 0 的报表会让人以为「今天真的没人签」。
</p>
<p class="report-state__hint">
需要查看演示数据时,把 <code>VITE_MOCK_REPORT_ANALYTICS</code> 设为 <code>true</code> 后重启开发服务。
</p>
</div>
</template>
<template v-else>
<ReportFilterBar
:campuses="campusOptions"
:departments="departmentOptions"
:categories="categoryOptions"
@export="exportVisible = true"
/>
<p v-if="customRangeIncomplete" class="report-warn">
自定义时间范围只填了一头,当前<b>未按时间过滤</b>;补齐起止日期后统计窗口才会生效。
</p>
<slot />
<ReportExportDialog
:visible="exportVisible"
:tasks="allTasks"
:filter="reportFilter"
:campuses="campusOptions"
:departments="departmentOptions"
:categories="categoryOptions"
@close="exportVisible = false"
/>
</template>
</section>
</template>
<style scoped>
.report-page {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.page-intro {
display: flex;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14px;
}
.page-intro h1 {
margin: 0;
color: var(--ink);
font-size: 19px;
letter-spacing: 0.5px;
}
.page-intro p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12.5px;
}
.page-intro__meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--mut);
font-size: 12px;
white-space: nowrap;
}
.page-intro__meta span {
padding: 5px 9px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 6px;
}
.report-state {
padding: 26px 22px;
color: var(--mut);
font-size: 12.5px;
line-height: 1.8;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.report-state strong {
display: block;
margin-bottom: 6px;
color: var(--ink);
font-size: 14px;
}
.report-state p {
margin: 0 0 6px;
}
.report-state__hint {
color: var(--mut);
}
.report-state code {
padding: 1px 5px;
font-size: 11.5px;
background: #eef4f6;
border-radius: 4px;
}
.report-state--error {
color: var(--err);
}
.report-warn {
padding: 9px 14px;
margin: 0 0 14px;
color: var(--warn);
font-size: 12px;
line-height: 1.7;
background: var(--warn-l);
border-radius: 6px;
}
.report-warn b {
color: var(--warn);
}
@media (max-width: 680px) {
.page-intro {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
/**
* 统计块条:签署总览顶部的六个数字(累计发起 / 已签署 / 待签署 / 签署中 / 已超时 / 已作废)。
*
* 每块的强调色走内联 `--c`,与原型一致:颜色是数据含义的一部分
* (绿=完成、红=超时),不能交给 nth-child 之类的选择器去轮换 ——
* 那样一旦以后调整块数或顺序,颜色就会跟含义错位。
*/
defineProps<{
items: { label: string; value: number | string; color: string }[]
}>()
</script>
<template>
<div class="stat-tiles">
<div
v-for="item in items"
:key="item.label"
class="stat-tile"
:style="{ '--c': item.color }"
>
<div class="stat-tile__value">{{ item.value }}</div>
<div class="stat-tile__label">{{ item.label }}</div>
</div>
</div>
</template>
<style scoped>
.stat-tiles {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.stat-tile {
padding: 14px 16px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.stat-tile__value {
color: var(--c);
font-size: 24px;
font-weight: 700;
line-height: 1.15;
}
.stat-tile__label {
margin-top: 4px;
color: var(--mut);
font-size: 12px;
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
import { completionRateColor, groupTasks, STATUS_COLUMNS, STATUS_LABELS } from '../aggregate'
import type { ReportGroupRow, ReportStatusCounts, ReportSummaryDimension } from '../types'
/**
* 签署汇总矩阵:把当前筛选下的任务按一个维度汇总,并支持下钻。
*
* 维度可切换(科室 / 文档类别 / 签署方式 / 院区)而不是固定按科室:
* 「哪个科室签得慢」和「哪类文书完成率低」是两个完全不同的问题,
* 固定一个维度就得让用户自己去交叉两张表。
*/
const props = defineProps<{
tasks: ReportTaskRow[]
/** 当前选中的分组维度(由页面持有,便于切换维度时重置下钻) */
dimension: ReportSummaryDimension
}>()
const emit = defineEmits<{
'update:dimension': [dimension: ReportSummaryDimension]
/** 点某一行下钻;key 为 null 表示看全部 */
drill: [key: string | null]
}>()
const dimensionOptions: { label: string; value: ReportSummaryDimension }[] = [
{ label: '按科室', value: 'department' },
{ label: '按文档类别', value: 'category' },
{ label: '按签署方式', value: 'method' },
{ label: '按院区', value: 'campus' },
]
const rows = computed(() => groupTasks(props.tasks, props.dimension))
const totals = computed<ReportStatusCounts>(() =>
rows.value.reduce<ReportStatusCounts>(
(sum, row) => ({
total: sum.total + row.total,
signed: sum.signed + row.signed,
pending: sum.pending + row.pending,
signing: sum.signing + row.signing,
expired: sum.expired + row.expired,
void: sum.void + row.void,
failed: sum.failed + row.failed,
}),
{ total: 0, signed: 0, pending: 0, signing: 0, expired: 0, void: 0, failed: 0 },
),
)
const totalRate = computed(() =>
totals.value.total ? Math.round((totals.value.signed / totals.value.total) * 100) : 0,
)
/** 当前 hover 的行:给整行一个指针提示,避免用户不知道可以点 */
const hoveredKey = ref<string | null>(null)
function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]): number {
return row[status]
}
</script>
<template>
<div class="matrix">
<div class="matrix__bar">
<span class="matrix__hint">点击任意行可下钻查看该分组明细</span>
<span class="matrix__spacer" />
<label class="matrix__dim">
汇总口径
<select
:value="dimension"
aria-label="选择汇总口径"
@change="emit('update:dimension', ($event.target as HTMLSelectElement).value as ReportSummaryDimension)"
>
<option v-for="option in dimensionOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<div class="matrix__scroll">
<table class="matrix__table">
<thead>
<tr>
<th>{{ dimensionOptions.find((item) => item.value === dimension)?.label.slice(1) }}</th>
<th>发起</th>
<th v-for="status in STATUS_COLUMNS" :key="status">{{ STATUS_LABELS[status] }}</th>
<th>完成率</th>
</tr>
</thead>
<tbody>
<tr v-if="!rows.length">
<td :colspan="STATUS_COLUMNS.length + 3" class="matrix__empty">
当前条件下暂无数据
</td>
</tr>
<tr
v-else
class="matrix__row matrix__row--total"
:class="{ 'is-hover': hoveredKey === '' }"
@mouseenter="hoveredKey = ''"
@mouseleave="hoveredKey = null"
@click="emit('drill', null)"
>
<td><b>合计</b></td>
<td><b>{{ totals.total }}</b></td>
<td v-for="status in STATUS_COLUMNS" :key="status">{{ totals[status] }}</td>
<td>
<b :style="{ color: completionRateColor(totalRate) }">{{ totalRate }}%</b>
</td>
</tr>
<tr
v-for="row in rows"
:key="row.key"
class="matrix__row"
:class="{ 'is-hover': hoveredKey === row.key }"
:title="`点击查看「${row.key}」的明细`"
@mouseenter="hoveredKey = row.key"
@mouseleave="hoveredKey = null"
@click="emit('drill', row.key)"
>
<td><b>{{ row.key }}</b></td>
<td>{{ row.total }}</td>
<td v-for="status in STATUS_COLUMNS" :key="status">{{ valueOf(row, status) }}</td>
<td>
<b :style="{ color: completionRateColor(row.completionRate) }">
{{ row.completionRate }}%
</b>
</td>
</tr>
</tbody>
</table>
</div>
<p class="matrix__note">
完成率 = 已签署 ÷ 发起;低于 80% 标红提示。下钻明细只读,需要查看文书原文请到「签署记录查询」。
</p>
</div>
</template>
<style scoped>
.matrix__bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.matrix__hint,
.matrix__dim {
color: var(--mut);
font-size: 12px;
}
.matrix__spacer {
flex: 1;
}
.matrix__dim {
display: inline-flex;
gap: 6px;
align-items: center;
}
.matrix__dim select {
padding: 5px 8px;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.matrix__scroll {
overflow-x: auto;
}
.matrix__table {
width: 100%;
min-width: 720px;
font-size: 13px;
border-collapse: collapse;
}
.matrix__table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.matrix__table td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.matrix__row {
cursor: pointer;
}
.matrix__row.is-hover td {
background: #f7fbfc;
}
.matrix__row--total td {
background: #f5f9fa;
}
.matrix__empty {
padding: 26px 12px;
color: var(--mut);
text-align: center;
}
.matrix__note {
margin: 10px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
}
</style>
@@ -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<ReportFilterForm>({
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'
}
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
import {
buildTrend,
countByStatus,
formatDuration,
groupKeyOf,
METHOD_LABELS,
STATUS_LABELS,
SUMMARY_DIMENSION_LABELS,
} from '../aggregate'
import ReportBarChart from '../components/ReportBarChart.vue'
import ReportCard from '../components/ReportCard.vue'
import ReportPageShell from '../components/ReportPageShell.vue'
import ReportStatTiles from '../components/ReportStatTiles.vue'
import ReportSummaryMatrix from '../components/ReportSummaryMatrix.vue'
import type { ReportChartPoint, ReportSummaryDimension } from '../types'
import { useReportDataset } from '../use-report-dataset'
/**
* 签署总览:先看总量,再看趋势,最后按维度拆开并支持下钻。
*
* 三层是刻意分开的:统计块回答「现在什么水平」,趋势回答「在变好还是变坏」,
* 汇总矩阵回答「问题出在谁身上」。挤在一张图里就哪个问题都回答不清楚。
*/
const { tasks, window: dateWindow } = useReportDataset()
const counts = computed(() => countByStatus(tasks.value))
/**
* 六个状态全列出(含处理失败)。
* 只挑几个显示会让各块加起来对不上「累计发起」,看到的人会先怀疑统计错了。
*/
const tiles = computed(() => [
{ label: '累计发起任务', value: counts.value.total, color: '#0e6e8c' },
{ label: '已签署', value: counts.value.signed, color: '#0f9d6c' },
{ label: '待签署', value: counts.value.pending, color: '#3a7bd5' },
{ label: '签署中', value: counts.value.signing, color: '#7a4dbb' },
{ label: '已超时', value: counts.value.expired, color: '#d9821f' },
{ label: '已作废', value: counts.value.void, color: '#d0534f' },
{ label: '处理失败', value: counts.value.failed, color: '#b4475f' },
])
const trendPoints = computed<ReportChartPoint[]>(() =>
buildTrend(tasks.value, dateWindow.value).map((point) => ({
label: point.label,
values: [point.created, point.signed],
})),
)
const trendSeries = [
{ label: '每日发起', color: '#0e6e8c' },
{ label: '每日完成', color: '#0f9d6c' },
]
const dimension = ref<ReportSummaryDimension>('department')
/**
* 下钻状态拆成「是否展开」+「哪一组」两个值。
*
* 不能只用 key === null 表示「没下钻」:汇总矩阵的合计行发出的就是 null(表示看全部),
* 一个值扛两种含义时,点合计行会没有任何反应。
*/
const drillVisible = ref(false)
const drillKey = ref<string | null>(null)
function handleDimensionChange(next: ReportSummaryDimension) {
dimension.value = next
drillVisible.value = false
drillKey.value = null
}
function handleDrill(key: string | null) {
drillKey.value = key
drillVisible.value = true
}
const drillRows = computed<ReportTaskRow[]>(() => {
if (drillKey.value === null) {
return tasks.value
}
return tasks.value.filter((task) => groupKeyOf(task, dimension.value) === drillKey.value)
})
const drillTitle = computed(() =>
drillKey.value ? `明细 · ${drillKey.value}` : '全部任务明细',
)
const drillMeta = computed(() =>
`${drillKey.value ? `${SUMMARY_DIMENSION_LABELS[dimension.value]} = ${drillKey.value}` : '当前筛选条件'} · 共 ${drillRows.value.length} 条`,
)
function durationText(row: ReportTaskRow) {
return row.signMinutes === undefined ? '—' : formatDuration(row.signMinutes)
}
</script>
<template>
<ReportPageShell
title="签署总览"
description="按时间与组织维度查看签署任务的总量、完成情况与每日趋势;数据口径与「签署记录查询」一致。"
>
<ReportStatTiles :items="tiles" />
<ReportCard title="每日发起与完成趋势" subtitle="发起按任务创建日统计,完成按签署完成日统计" class="gap-top">
<ReportBarChart :points="trendPoints" :series="trendSeries" unit="单" />
</ReportCard>
<ReportCard title="签署汇总矩阵" class="gap-top">
<ReportSummaryMatrix
:tasks="tasks"
:dimension="dimension"
@update:dimension="handleDimensionChange"
@drill="handleDrill"
/>
</ReportCard>
<ReportCard v-if="drillVisible" :title="drillTitle" class="gap-top">
<template #actions>
<span class="drill-meta">{{ drillMeta }}</span>
<button type="button" class="link-button" @click="handleDrill(null)">显示全部</button>
<button type="button" class="link-button" @click="drillVisible = false">收起</button>
</template>
<div class="drill-scroll">
<table class="drill-table">
<thead>
<tr>
<th>任务号</th>
<th>患者</th>
<th>文档</th>
<th>科室</th>
<th>方式</th>
<th>状态</th>
<th>发起时间</th>
<th>签署用时</th>
</tr>
</thead>
<tbody>
<tr v-if="!drillRows.length">
<td colspan="8" class="drill-empty">该分组在当前筛选条件下暂无任务</td>
</tr>
<tr v-for="row in drillRows" :key="row.id">
<td class="mono">{{ row.id }}</td>
<td><b>{{ row.patientName }}</b></td>
<td>{{ row.documentName }}</td>
<td>{{ row.department }}</td>
<td>{{ METHOD_LABELS[row.method] }}</td>
<td>{{ STATUS_LABELS[row.status] }}</td>
<td>{{ row.createdAt }}</td>
<td>{{ durationText(row) }}</td>
</tr>
</tbody>
</table>
</div>
<p class="drill-note">
下钻明细只读,用于核对数字来源;需要查看文书原文请到「签署记录查询」。
</p>
</ReportCard>
</ReportPageShell>
</template>
<style scoped>
.gap-top {
margin-top: 16px;
}
.drill-meta {
color: var(--mut);
font-size: 12px;
}
.link-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
.link-button:hover {
background: var(--brand-l);
}
.drill-scroll {
overflow-x: auto;
}
.drill-table {
width: 100%;
min-width: 900px;
font-size: 13px;
border-collapse: collapse;
}
.drill-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.drill-table td {
padding: 9px 12px;
border-bottom: 1px solid #eef4f6;
}
.drill-table .mono {
color: var(--mut);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.drill-empty {
padding: 26px 12px;
color: var(--mut);
text-align: center;
}
.drill-note {
margin: 10px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed } from 'vue'
import { buildPatientStructure } from '../aggregate'
import ReportBarList from '../components/ReportBarList.vue'
import ReportCard from '../components/ReportCard.vue'
import ReportPageShell from '../components/ReportPageShell.vue'
import type { ReportBarItem } from '../types'
import { useReportDataset } from '../use-report-dataset'
/**
* 患者结构分析:年龄段 / 性别 / 就诊类型 / 院区四个分布。
*
* 口径是**人次**而不是人数:统计对象是任务关联的患者,同一位患者做了两次检查
* 就会计两次。这一点必须在页面上写出来 —— 否则看到「门诊 120 人次」会以为是
* 120 个患者,进而拿去做人群占比,结论就偏了。
*/
const { tasks } = useReportDataset()
const structure = computed(() => buildPatientStructure(tasks.value))
function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
return items.map((item, index) => ({ ...item, color: colors[index % colors.length] }))
}
const ageItems = computed(() =>
withColors(structure.value.age, ['#18a0c0', '#0e6e8c', '#0f9d6c', '#7a4dbb']),
)
const sexItems = computed(() => withColors(structure.value.sex, ['#3a7bd5', '#d4537e']))
</script>
<template>
<ReportPageShell
title="患者结构分析"
description="按任务关联患者的年龄、性别、就诊类型与所属院区查看签署人群结构,用于判断签署需求主要来自哪类患者。"
>
<div class="pat-row">
<ReportCard title="年龄段分布" subtitle="按任务关联患者统计 · 未成年患者由监护人签署">
<ReportBarList :items="ageItems" :label-width="90" />
</ReportCard>
<ReportCard title="性别分布" subtitle="按任务关联患者统计">
<ReportBarList :items="sexItems" :label-width="60" />
</ReportCard>
</div>
<div class="pat-row">
<ReportCard title="就诊类型分布" subtitle="门诊 / 住院 / 体检 / 其他">
<ReportBarList :items="structure.visitType" :label-width="70" />
</ReportCard>
<ReportCard title="院区患者分布" subtitle="按患者所属院区统计">
<ReportBarList :items="structure.campus" :label-width="100" />
</ReportCard>
</div>
<p class="pat-note">
四个维度统计的都是<b>人次</b>而非人数:同一患者在当前筛选条件下有多次签署任务会重复计数,
因此各维度合计等于任务数,不等于去重后的患者数。需要按人去重请由后端提供患者维度聚合。
</p>
</ReportPageShell>
</template>
<style scoped>
.pat-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.pat-row > * {
flex: 1;
min-width: 0;
}
.pat-note {
margin: 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
}
@media (max-width: 1200px) {
.pat-row {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
buildDailyAverageDuration,
buildDurationByDepartment,
buildOverdueTasks,
buildTimeliness,
formatDuration,
OVERDUE_MINUTES,
} from '../aggregate'
import ReportBarChart from '../components/ReportBarChart.vue'
import ReportBarList from '../components/ReportBarList.vue'
import ReportCard from '../components/ReportCard.vue'
import ReportKpiTiles from '../components/ReportKpiTiles.vue'
import ReportPageShell from '../components/ReportPageShell.vue'
import type { ReportChartPoint } from '../types'
import { useReportDataset } from '../use-report-dataset'
/**
* 签署时效分析:四个指标 + 两个分布 + 一张超期清单。
*
* 指标的样本口径只算已签署任务。把未完成的任务也算进平均时长会把均值稀释成
* 一个没有意义的数 —— 它们不是「签得快」,而是「还没签完」;所以没有样本时
* 指标显示「—」而不是 0,0 会被读成「表现极差」。
*/
const { tasks, window: dateWindow } = useReportDataset()
const metrics = computed(() => buildTimeliness(tasks.value))
const kpis = computed(() => [
{
label: '平均签署时长',
value:
metrics.value.averageMinutes === null ? null : formatDuration(metrics.value.averageMinutes),
icon: 'clock' as const,
color: '#3a7bd5',
softColor: 'var(--info-l)',
},
{
label: '24 小时内完成率',
value: metrics.value.within24hRate === null ? null : `${metrics.value.within24hRate}%`,
icon: 'check' as const,
color: '#0f9d6c',
softColor: 'var(--ok-l)',
},
{
label: '超期完成占比',
value: metrics.value.overdueRate === null ? null : `${metrics.value.overdueRate}%`,
icon: 'alert' as const,
color: '#d9821f',
softColor: 'var(--warn-l)',
},
{
label: '超时未签任务',
value: metrics.value.expiredTaskCount,
icon: 'warning' as const,
color: '#d0534f',
softColor: 'var(--err-l)',
},
])
const departmentDuration = computed(() => buildDurationByDepartment(tasks.value))
const dailyDurationPoints = computed<ReportChartPoint[]>(() =>
buildDailyAverageDuration(tasks.value, dateWindow.value).map((item) => ({
label: item.label,
values: [item.value],
})),
)
const dailyDurationSeries = [{ label: '平均签署时长', color: '#18a0c0' }]
const overdueTasks = computed(() => buildOverdueTasks(tasks.value))
const sampleNote = computed(() =>
metrics.value.signedSampleCount
? `基于 ${metrics.value.signedSampleCount} 条已签署任务`
: '当前条件下没有已签署任务',
)
</script>
<template>
<ReportPageShell
title="签署时效分析"
description="查看签署耗时水平与超期情况,定位签字拖得最久的科室与时段。"
>
<ReportKpiTiles :items="kpis" />
<div class="time-row gap-top">
<ReportCard title="各科室平均签署时长" subtitle="超 24 小时标橙 · 超 48 小时标红">
<ReportBarList :items="departmentDuration" />
</ReportCard>
<ReportCard title="每日平均签署时长" :subtitle="`近 ${dailyDurationPoints.length} 天完成任务均值(分钟)`">
<ReportBarChart
:points="dailyDurationPoints"
:series="dailyDurationSeries"
unit="分钟"
/>
</ReportCard>
</div>
<ReportCard
title="超期完成任务"
:subtitle="`完成用时超过 48 小时链接有效期(${OVERDUE_MINUTES / 60} 小时)的已签署任务 · ${sampleNote}`"
class="gap-top"
>
<div class="overdue-scroll">
<table class="overdue-table">
<thead>
<tr>
<th>任务号</th>
<th>患者</th>
<th>科室</th>
<th>文档</th>
<th>发起时间</th>
<th>签署完成</th>
<th>用时</th>
</tr>
</thead>
<tbody>
<tr v-if="!overdueTasks.length">
<td colspan="7" class="overdue-empty">当前条件下无超期完成任务</td>
</tr>
<tr v-for="row in overdueTasks" :key="row.id">
<td class="mono">{{ row.id }}</td>
<td><b>{{ row.patientName }}</b></td>
<td>{{ row.department }}</td>
<td>{{ row.documentName }}</td>
<td>{{ row.createdAt }}</td>
<td>{{ row.signedAt }}</td>
<td><span class="overdue-tag">{{ formatDuration(row.signMinutes) }}</span></td>
</tr>
</tbody>
</table>
</div>
</ReportCard>
</ReportPageShell>
</template>
<style scoped>
.gap-top {
margin-top: 16px;
}
.time-row {
display: flex;
gap: 16px;
}
.time-row > * {
flex: 1;
min-width: 0;
}
.overdue-scroll {
overflow-x: auto;
}
.overdue-table {
width: 100%;
min-width: 900px;
font-size: 13px;
border-collapse: collapse;
}
.overdue-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.overdue-table td {
padding: 9px 12px;
border-bottom: 1px solid #eef4f6;
}
.overdue-table .mono {
color: var(--mut);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.overdue-empty {
padding: 26px 12px;
color: var(--mut);
text-align: center;
}
.overdue-tag {
display: inline-block;
padding: 2px 8px;
color: var(--err);
font-size: 11.5px;
background: var(--err-l);
border-radius: 4px;
}
@media (max-width: 1200px) {
.time-row {
flex-direction: column;
}
}
</style>
+128
View File
@@ -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<T = string> {
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 }
@@ -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<ReportAnalyticsDataset | null>(null)
const loading = ref(false)
const errorMessage = ref('')
let pending: Promise<void> | null = null
/**
* 装载数据集。
*
* 并发去重:页面挂载与筛选条刷新可能同时触发,重复请求没有意义。
* 只取一次成功后就不再重取 —— 演示数据是确定性的,重取只会换掉「数据截至」的时刻。
*/
export function loadReportDataset(): Promise<void> {
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<ReportTaskRow[]>(() => dataset.value?.tasks ?? [])
/** 当前筛选下的任务明细:四个页面的所有数字都从这里算 */
const tasks = computed<ReportTaskRow[]>(() => 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,
}
}
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed } from 'vue'
import { buildRanking, METHOD_LABELS } from '../aggregate'
import ReportBarList from '../components/ReportBarList.vue'
import ReportCard from '../components/ReportCard.vue'
import ReportPageShell from '../components/ReportPageShell.vue'
import type { ReportBarItem } from '../types'
import { useReportDataset } from '../use-report-dataset'
/**
* 签署量分析:四个排行榜,回答「量集中在谁身上」。
*
* 口径:只统计**已签署**的任务。原型同此(`done.forEach`),页面上也写明了 ——
* 「签署量」问的是真的签掉了多少,把待签署、已作废一起算进来会让排行失去意义。
*/
const { tasks } = useReportDataset()
const signedTasks = computed(() => tasks.value.filter((task) => task.status === 'signed'))
/** 按原型的分色口径给榜单配色:同一个榜单里颜色只是区分项,不表达好坏 */
function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
return items.map((item, index) => ({ ...item, color: colors[index % colors.length] }))
}
const byDepartment = computed(() =>
buildRanking(signedTasks.value, (task) => task.department || '其他'),
)
const byMethod = computed(() =>
withColors(
buildRanking(signedTasks.value, (task) => METHOD_LABELS[task.method]),
['#0e6e8c', '#18a0c0', '#7a4dbb'],
),
)
const byCategory = computed(() =>
withColors(
buildRanking(signedTasks.value, (task) => task.category || '其他'),
['#0f9d6c', '#d9821f', '#0e6e8c'],
),
)
const topDocuments = computed(() =>
withColors(
buildRanking(signedTasks.value, (task) => task.documentName, 10),
['#18a0c0', '#0e6e8c', '#0f9d6c'],
),
)
const summary = computed(() => {
const total = tasks.value.length
const signed = signedTasks.value.length
return `已签署 ${signed} 单,占当前筛选 ${total} 单的 ${total ? Math.round((signed / total) * 100) : 0}%`
})
</script>
<template>
<ReportPageShell
title="签署量分析"
description="按科室、签署方式、文档类别与单份文档四个口径查看签署完成量分布,用于判断签署工作量集中在哪里。"
>
<div class="vol-summary">{{ summary }}</div>
<div class="vol-row">
<ReportCard title="科室签署量排行" subtitle="按科室统计签署完成量">
<ReportBarList :items="byDepartment" />
</ReportCard>
<ReportCard title="签署方式分布" subtitle="手写板 / 短信线上">
<ReportBarList :items="byMethod" :label-width="90" />
</ReportCard>
</div>
<div class="vol-row">
<ReportCard title="文档类型签署量" subtitle="按文档类别(文档库中维护)">
<ReportBarList :items="byCategory" />
</ReportCard>
<ReportCard title="文档签署 TOP10" subtitle="按单份文档统计">
<ReportBarList :items="topDocuments" :label-width="180" />
</ReportCard>
</div>
<p class="vol-note">
本页只统计已签署任务:待签署与已作废的任务尚未产生签署量,计入排行会掩盖真实工作量。
需要看发起量与完成率请到「签署总览」的汇总矩阵。
</p>
</ReportPageShell>
</template>
<style scoped>
.vol-summary {
padding: 10px 16px;
margin-bottom: 14px;
color: var(--mut);
font-size: 12.5px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.vol-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.vol-row > * {
flex: 1;
min-width: 0;
}
.vol-note {
margin: 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
}
@media (max-width: 1200px) {
.vol-row {
flex-direction: column;
}
}
</style>
@@ -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<SigningMethod | 'all'>[] = [
* 不用「当前页出现过的科室」——翻页会换掉可选项,筛完再想换一个科室就找不到了。
*/
const departmentOptions = computed<SigningRecordFilterOption[]>(() => {
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<SigningRecordFilterOption[]>(() => {
const categoryOptions = computed<SigningRecordFilterOption[]>(() => {
// 真实模式下后端签署任务接口不支持按类别检索,这里给不出可选项,由筛选条禁用该字段
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('导出失败,请稍后重试')