feat(reports): 接入真实报表分析及分组明细与异步导出
This commit is contained in:
@@ -20,9 +20,6 @@ VITE_API_BASE_URL=/api
|
||||
# VITE_MOCK_SIGN_RECORDS=true
|
||||
# VITE_MOCK_DOCUMENTS=false
|
||||
# VITE_MOCK_DOCUMENT_PERMISSIONS=false
|
||||
# 报表分析目录下的四个菜单(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。
|
||||
# 后端目前没有任何分析聚合接口,缺省开启演示数据;关掉只会得到「后端尚未提供接口」的空态。
|
||||
# VITE_MOCK_REPORT_ANALYTICS=true
|
||||
# VITE_MOCK_USERS=false
|
||||
# VITE_MOCK_ORGANIZATION=false
|
||||
# VITE_MOCK_SETTINGS=false
|
||||
|
||||
+12
-14
@@ -118,9 +118,9 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
|
||||
└─ /system/setting 系统设置 / 菜单设置
|
||||
```
|
||||
|
||||
「系统管理 → 报表分析」(`/report/index`,签署报表与导出任务,走 `reports/signing/*` 接口)已整体下线:页面、接口模块、`VITE_MOCK_REPORTS` 开关与路由别名一并移除。后端菜单资源树暂时仍会下发该菜单,前端在 `stores/login.ts` 的 `RETIRED_MENU_PATHS` 里统一屏蔽,侧边栏不展示、路由守卫也会重定向走,不会出现「前端暂未接入组件」的死链;后端下架该菜单资源后可以删掉这个集合。
|
||||
「系统管理 → 报表分析」(旧 `/report/index` 菜单)仍由 `stores/login.ts` 的 `RETIRED_MENU_PATHS` 屏蔽,避免旧菜单资源形成死链。它与下面四个独立分析菜单使用不同的页面路由。
|
||||
|
||||
`/report/sign/overview` 等四个是**另一套**:「报表分析」目录下的四个独立菜单,后端目前没有任何分析聚合接口,因此只有演示数据(`VITE_MOCK_REPORT_ANALYTICS`)。路由与 `componentPath` 都以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。
|
||||
`/report/sign/overview` 等四个页面是「报表分析」目录下的独立菜单。后端 V34 迁移创建菜单资源并授权给系统管理员、医务管理和审计员;路由与 `componentPath` 仍以后端菜单资源树为准,不要照抄原型里的 `MENU_TREE`(原型写的是 `/report/overview` 等,与后端不一致)。
|
||||
|
||||
布局拆分为 `Menu`、`Header` 和 `Container` 三个公共组件,页面内容由各自的 View 负责。
|
||||
|
||||
@@ -257,31 +257,29 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
|
||||
## 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)
|
||||
|
||||
四个菜单共用一套数据源与筛选条件:
|
||||
四个菜单共用一套后端数据源与筛选条件:
|
||||
|
||||
- `api/workbench/report-analytics.ts`:数据集。**后端没有任何分析聚合接口**,真实模式返回 `{ tasks: [], available: false }` 而不抛错,页面据此显示「后端尚未提供报表分析接口」的说明,不渲染一张全 0 的假报表。
|
||||
- `views/reports/aggregate.ts`:全部聚合逻辑,纯函数(不依赖 DOM 与 Vue),可用 Node 直跑断言。
|
||||
- `api/workbench/report-analytics.ts`:报表查询、下钻分页和导出任务客户端。
|
||||
- 后端统一使用 `/api/v1/reports/signing`:`GET /options` 提供筛选项,`GET /analytics` 一次返回状态、趋势、分组、时效与人口统计聚合,`GET /analytics/tasks` 分页返回分组下钻明细;接口沿用 `report:sign:view` 权限及当前用户的数据范围。
|
||||
- `views/reports/aggregate.ts`:日期窗口、展示标签与时长格式化等前端辅助逻辑;聚合指标由数据库计算,浏览器不再拉取全量任务后自行统计。
|
||||
- `views/reports/filter-store.ts`:**四个菜单共用一份筛选条件**(模块级 `reactive`)。原型里它们是同一筛选条下的四个页签,各存一份会导致切页时筛选被重置、四个页面的数字看起来互相矛盾。
|
||||
- `views/reports/components/ReportPageShell.vue`:标题 + 筛选条 + 数据可用性闸门 + 导出弹窗,四个页面共用;页面只填自己的图表内容。
|
||||
- `views/reports/components/ReportPageShell.vue`:读取后端筛选项、共用筛选条、数据加载状态与导出弹窗;页面只填自己的图表内容。
|
||||
- 图表是手写内联 SVG(`ReportBarChart` / `ReportBarList`),不引图表库:只需要柱状图与横向条形榜两种形态,引库要多几百 KB 且读不到主题 CSS 变量。渐变 id 用 `useId()` 取每实例唯一值。
|
||||
- 下钻和超期清单只返回脱敏患者姓名与患者标识;人口结构接口只返回数据库聚合计数,不向浏览器传输患者姓名、年龄或性别明细。
|
||||
- 导出通过 `POST /export-jobs` 创建异步任务,`GET /export-jobs/{jobId}` 轮询状态,完成后调用响应中的下载路径。后端按白名单导出脱敏字段并记录审计。部署需设置 `medisign.sign.report.export.worker.enabled=true` 启动导出 worker;默认关闭。
|
||||
|
||||
**演示数据的两条硬约束**:① 用固定种子的 PRNG(mulberry32),不用 `Math.random` —— 否则每次刷新数字都在跳,截图对不上、断言写不了;② 时间锚定「距今天数」,不写死日期 —— 写死之后过几天再打开,近 7 天 / 近 14 天 / 本月全是空的,看起来像筛选坏了。
|
||||
|
||||
**边界**:① 时效指标只统计**已签署**任务(未完成任务不是「签得快」而是「还没签完」,计入会稀释均值),样本为 0 时显示「—」而不是 0;② 患者结构统计的是**人次**而非人数,同一患者多次签署会重复计数,页面上已写明;③ 导出是前端按条件生成 CSV(UTF-8 带 BOM、CRLF),与签署记录查询共用 `utils/csv-export.ts`。**后端提供导出任务后应改为服务端导出**:导出范围与导出人必须进审计日志,浏览器里做不到。真实接入聚合接口时,后端应按页面出四个聚合接口,**不要**照搬现在的形状把全量任务明细拉到浏览器里算。
|
||||
|
||||
演示数据缺省开启(`VITE_MOCK_REPORT_ANALYTICS`)。
|
||||
**统计口径**:时效指标只统计已签署任务;样本为 0 时显示「—」。患者结构统计的是任务人次而非去重人数,同一患者的多次签署会重复计数。筛选支持后端任务实际使用的 PAD 与 SMS 两种签署方式。
|
||||
|
||||
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
|
||||
|
||||
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。联调本地后端时可用 `VITE_API_PROXY_TARGET=http://127.0.0.1:18080 npm run dev` 覆盖目标。修改代理或环境变量后需要重启 Vite 开发服务。
|
||||
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到本机 `http://127.0.0.1:18080`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径;启动日志会显示实际代理目标。要联调测试服务器,可用 `VITE_API_PROXY_TARGET=https://ipad.shenynet.com npm run dev` 覆盖目标。修改代理或环境变量后需要重启 Vite 开发服务。
|
||||
|
||||
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省使用演示数据的只有签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)与「报表分析」目录下的四个菜单(`VITE_MOCK_REPORT_ANALYTICS`,后端没有分析聚合接口),其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
|
||||
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省使用演示数据的只有签署工作台(`VITE_MOCK_SIGNING`)和签署记录查询(`VITE_MOCK_SIGN_RECORDS`);报表分析使用真实后端接口。各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
|
||||
|
||||
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。
|
||||
|
||||
文档库正文编辑器:选中「患者签名」占位符后,可以自动识别 PDF 位置,或生成 PDF 预览后输入坐标。阴影框支持拖动定位,宽高输入即时更新;点击「更新该项」或保存正文写入位置。正文变化后需重新生成预览。预览使用示例患者,实际患者文字长度可能影响排版,建议为填写项预留固定空间。重复的患者签名占位符不会自动选取。后端在保存及发布时检查页码与页面边界。
|
||||
|
||||
|
||||
### 工作台患者填写项
|
||||
|
||||
真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。
|
||||
|
||||
@@ -1,328 +1,427 @@
|
||||
/**
|
||||
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)的数据源。
|
||||
*
|
||||
* 现状:后端没有任何分析聚合接口,所以这四个菜单目前只有演示数据。
|
||||
* 这里刻意**返回任务级明细**而不是预先算好的聚合结果,原因是演示阶段聚合逻辑
|
||||
* 要能被页面与验证脚本反复调用(换筛选条件、下钻、导出都基于同一份明细)。
|
||||
*
|
||||
* ⚠️ 真实接入时不要照搬这个形状:一次分析查询不该把全量任务明细拉到浏览器里算。
|
||||
* 后端应按页面给出四个聚合接口(总览指标 + 趋势、签署量分组、时效指标 + 分组、
|
||||
* 患者结构分组),届时这个模块退化成「按查询参数取聚合结果」,
|
||||
* 页面侧的聚合函数随之删掉即可 —— 页面只消费聚合结果,不关心它从哪来。
|
||||
*
|
||||
* 演示数据的两条硬约束(都踩过):
|
||||
* 1. **确定性**:用固定种子的 PRNG,不用 Math.random。否则每次刷新数字都在跳,
|
||||
* 截图对不上、验证脚本没法断言「筛选后行数应该变少」。
|
||||
* 2. **锚定「距今天数」**:任务的日期按「N 天前」生成,不写死具体日期。
|
||||
* 写死日期后过几天再打开,近 7 天 / 近 14 天 / 本月 全是空的,
|
||||
* 演示时看起来像筛选坏了。
|
||||
*/
|
||||
import type { PageResult, ApiResponse } from '@/types/common'
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import type { SigningMethod, SigningTaskStatus, WorkbenchCampus } from './types'
|
||||
|
||||
import { mockDocumentCatalog, campusOfDepartment } from './mock-catalog'
|
||||
import type {
|
||||
PatientSex,
|
||||
SigningMethod,
|
||||
SigningTaskStatus,
|
||||
VisitType,
|
||||
WorkbenchCampus,
|
||||
} from './types'
|
||||
|
||||
const useMockData = mockFlags.reportAnalytics
|
||||
|
||||
export const isReportAnalyticsMockEnabled = useMockData
|
||||
|
||||
/**
|
||||
* 演示数据覆盖的天数。
|
||||
* 比最长的时间筛选(本月按 1 号起算,最多 31 天)略短,够用且不至于让
|
||||
* 「本月」看起来总是差几天;真要展示整月,把这里调到 31 即可。
|
||||
*/
|
||||
const DEMO_SPAN_DAYS = 30
|
||||
|
||||
/** 演示任务量级:约 5 条/天,够让趋势图与排行榜有形状,又不会让表格卡顿 */
|
||||
const DEMO_TASK_COUNT = 152
|
||||
|
||||
/** 一条报表任务行:聚合所需的全部维度都在这里,页面不再回查其它数据源 */
|
||||
export interface ReportTaskRow {
|
||||
id: string
|
||||
patientId: string
|
||||
patientName: string
|
||||
sex: PatientSex
|
||||
age: number
|
||||
visitType: VisitType
|
||||
visitNo: string
|
||||
campus: WorkbenchCampus
|
||||
department: string
|
||||
documentName: string
|
||||
category: string
|
||||
method: SigningMethod
|
||||
status: SigningTaskStatus
|
||||
/** 发起时间,`YYYY-MM-DD HH:mm`(与签署记录同一格式,便于直接比较与展示) */
|
||||
createdAt: string
|
||||
/** 签署完成时间;未完成的任务没有这一项 */
|
||||
signedAt?: string
|
||||
/** 签署用时(分钟);只有已签署的任务有。超 1440 为超 24 小时,超 2880 为超期完成 */
|
||||
signMinutes?: number
|
||||
}
|
||||
|
||||
export interface ReportGroupRow {
|
||||
key: string
|
||||
total: number
|
||||
signed: number
|
||||
confirming: number
|
||||
pending: number
|
||||
signing: number
|
||||
expired: number
|
||||
void: number
|
||||
failed: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
export interface ReportBarItem {
|
||||
label: string
|
||||
value: number
|
||||
display?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface ReportAnalyticsDataset {
|
||||
tasks: ReportTaskRow[]
|
||||
/**
|
||||
* 数据是否可用。真实模式下没有后端接口,这里返回 false,
|
||||
* 页面据此显示「后端尚未提供分析接口」的说明,而不是渲染一张全 0 的假报表。
|
||||
*/
|
||||
available: boolean
|
||||
/** 数据生成时刻(`YYYY-MM-DD HH:mm`),页面用于「数据截至」提示 */
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
确定性随机
|
||||
========================================================================= */
|
||||
|
||||
/** mulberry32:小、快、固定种子可复现 */
|
||||
function createRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
fromDate: string
|
||||
toDate: string
|
||||
statusCounts: Omit<ReportGroupRow, 'key' | 'completionRate'>
|
||||
trend: { key: string; label: string; created: number; signed: number }[]
|
||||
departments: ReportGroupRow[]
|
||||
categories: ReportGroupRow[]
|
||||
methods: ReportGroupRow[]
|
||||
campuses: ReportGroupRow[]
|
||||
documents: ReportGroupRow[]
|
||||
timeliness: {
|
||||
averageMinutes: number | null
|
||||
within24hRate: number | null
|
||||
overdueRate: number | null
|
||||
expiredTaskCount: number
|
||||
signedSampleCount: number
|
||||
departmentAverages: ReportBarItem[]
|
||||
dailyAverages: { date: string; averageMinutes: number; sampleCount: number }[]
|
||||
overdueTaskCount: number
|
||||
overdueTasks: {
|
||||
id: string
|
||||
patientId: string
|
||||
patientName: string
|
||||
department: string
|
||||
documentName: string
|
||||
createdAt: string
|
||||
signedAt: string
|
||||
signMinutes: number
|
||||
}[]
|
||||
}
|
||||
patientStructure: {
|
||||
age: ReportBarItem[]
|
||||
sex: ReportBarItem[]
|
||||
visitType: ReportBarItem[]
|
||||
campus: ReportBarItem[]
|
||||
}
|
||||
}
|
||||
|
||||
function pick<T>(random: () => number, items: readonly T[]): T {
|
||||
return items[Math.floor(random() * items.length)] as T
|
||||
export interface ReportOptions {
|
||||
campuses: { id: string; name: string }[]
|
||||
departments: { id: string; campusId: string; name: string }[]
|
||||
templateCategories: string[]
|
||||
}
|
||||
|
||||
function intBetween(random: () => number, min: number, max: number): number {
|
||||
return min + Math.floor(random() * (max - min + 1))
|
||||
export interface ReportAnalyticsQuery {
|
||||
createdFrom: string
|
||||
createdTo: string
|
||||
timezone: string
|
||||
campusId?: string
|
||||
departmentId?: string
|
||||
templateCategory?: string
|
||||
signMethod?: 'PAD' | 'SMS'
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
演示患者池
|
||||
========================================================================= */
|
||||
|
||||
interface DemoPatient {
|
||||
name: string
|
||||
sex: PatientSex
|
||||
age: number
|
||||
visitType: VisitType
|
||||
export interface ReportFilterInput {
|
||||
dateRange: '7d' | '14d' | 'month' | 'custom'
|
||||
customFrom: string
|
||||
customTo: string
|
||||
campus: string
|
||||
department: string
|
||||
category: string
|
||||
method: SigningMethod | 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者池。
|
||||
*
|
||||
* 含 3 名未成年患者:儿科知情同意同样由监护人签署,报表统计的是患者本人年龄。
|
||||
* 未成年占比刻意保持在三成以下(3/24),与「综合医院里儿科签署只是小部分」的实际相符。
|
||||
*
|
||||
* ⚠️ **不要把「每个年龄段在任意时间窗内都非空」当作验收标准**:未成年任务只有十几条,
|
||||
* 近 7 天窗口里恰好一条都没有是正常的(实测 2026-09-24 当天就是这样)。
|
||||
* 年龄段分档的正确性由分档边界(17/18、40/41、60/61)来保证,不是由「每档都非空」。
|
||||
* 为了让窄窗口好看而硬塞样本,会让年龄段分布失去真实形状。
|
||||
*/
|
||||
const DEMO_PATIENTS: DemoPatient[] = [
|
||||
{ name: '周雅琴', sex: '女', age: 29, visitType: '门诊' },
|
||||
{ name: '李淑芬', sex: '女', age: 67, visitType: '住院' },
|
||||
{ name: '赵美英', sex: '女', age: 58, visitType: '住院' },
|
||||
{ name: '陈志强', sex: '男', age: 61, visitType: '住院' },
|
||||
{ name: '王建国', sex: '男', age: 71, visitType: '住院' },
|
||||
{ name: '孙浩然', sex: '男', age: 54, visitType: '门诊' },
|
||||
{ name: '刘伟', sex: '男', age: 45, visitType: '门诊' },
|
||||
{ name: '吴桂芳', sex: '女', age: 73, visitType: '住院' },
|
||||
{ name: '黄志明', sex: '男', age: 66, visitType: '门诊' },
|
||||
{ name: '郑海燕', sex: '女', age: 38, visitType: '门诊' },
|
||||
{ name: '徐丽华', sex: '女', age: 50, visitType: '体检' },
|
||||
{ name: '马晓东', sex: '男', age: 42, visitType: '其他' },
|
||||
{ name: '林俊杰', sex: '男', age: 34, visitType: '体检' },
|
||||
{ name: '何秀英', sex: '女', age: 62, visitType: '住院' },
|
||||
{ name: '邓小燕', sex: '女', age: 27, visitType: '门诊' },
|
||||
{ name: '曹明辉', sex: '男', age: 57, visitType: '住院' },
|
||||
{ name: '袁丽', sex: '女', age: 19, visitType: '门诊' },
|
||||
{ name: '沈国强', sex: '男', age: 78, visitType: '住院' },
|
||||
{ name: '崔文静', sex: '女', age: 46, visitType: '体检' },
|
||||
{ name: '侯志远', sex: '男', age: 36, visitType: '门诊' },
|
||||
{ name: '龚雨萱', sex: '女', age: 6, visitType: '门诊' },
|
||||
{ name: '范子墨', sex: '男', age: 14, visitType: '住院' },
|
||||
{ name: '柳思彤', sex: '女', age: 16, visitType: '门诊' },
|
||||
{ name: '严志刚', sex: '男', age: 52, visitType: '其他' },
|
||||
]
|
||||
interface ReportGroupDto {
|
||||
key: string
|
||||
total: number
|
||||
signed: number
|
||||
confirming: number
|
||||
pending: number
|
||||
signing: number
|
||||
expired: number
|
||||
voided: number
|
||||
failed: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
演示任务生成
|
||||
========================================================================= */
|
||||
|
||||
/** 状态分布:已签署占多数,其余各留一点,七种状态在总览统计块里都看得到 */
|
||||
const STATUS_WEIGHTS: { status: SigningTaskStatus; weight: number }[] = [
|
||||
{ status: 'signed', weight: 62 },
|
||||
{ status: 'confirming', weight: 6 },
|
||||
{ status: 'pending', weight: 9 },
|
||||
{ status: 'signing', weight: 5 },
|
||||
{ status: 'expired', weight: 8 },
|
||||
{ status: 'void', weight: 5 },
|
||||
{ status: 'failed', weight: 5 },
|
||||
]
|
||||
|
||||
const STATUS_TOTAL_WEIGHT = STATUS_WEIGHTS.reduce((sum, item) => sum + item.weight, 0)
|
||||
|
||||
function pickStatus(random: () => number): SigningTaskStatus {
|
||||
let roll = random() * STATUS_TOTAL_WEIGHT
|
||||
|
||||
for (const item of STATUS_WEIGHTS) {
|
||||
roll -= item.weight
|
||||
|
||||
if (roll <= 0) {
|
||||
return item.status
|
||||
}
|
||||
interface ReportAnalyticsDto {
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
fromDate: string
|
||||
toDate: string
|
||||
statusCounts: {
|
||||
total: number
|
||||
signed: number
|
||||
confirming: number
|
||||
pending: number
|
||||
signing: number
|
||||
expired: number
|
||||
voided: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
return 'signed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 四种签署方式的抽样权重。
|
||||
*
|
||||
* 现场两种(手写板 / Pad)占比高,线上两种(短信 / 公众号)略低 —— 四种都要出现,
|
||||
* 否则「签署方式分布」这个报表维度会缺行,演示时看不到新方式。
|
||||
* 权重和不必是 100,`pickMethod` 会自己算总和。
|
||||
*/
|
||||
const METHOD_WEIGHTS: readonly { method: SigningMethod; weight: number }[] = [
|
||||
{ method: 'pad', weight: 38 },
|
||||
{ method: 'ipad', weight: 24 },
|
||||
{ method: 'sms', weight: 26 },
|
||||
{ method: 'wx', weight: 12 },
|
||||
]
|
||||
|
||||
const METHOD_TOTAL_WEIGHT = METHOD_WEIGHTS.reduce((sum, item) => sum + item.weight, 0)
|
||||
|
||||
function pickMethod(random: () => number): SigningMethod {
|
||||
let roll = random() * METHOD_TOTAL_WEIGHT
|
||||
|
||||
for (const item of METHOD_WEIGHTS) {
|
||||
roll -= item.weight
|
||||
|
||||
if (roll <= 0) {
|
||||
return item.method
|
||||
}
|
||||
trend: { date: string; created: number; signed: number }[]
|
||||
departments: ReportGroupDto[]
|
||||
categories: ReportGroupDto[]
|
||||
methods: ReportGroupDto[]
|
||||
campuses: ReportGroupDto[]
|
||||
documents: ReportGroupDto[]
|
||||
timeliness: {
|
||||
averageMinutes: number | null
|
||||
within24hRate: number | null
|
||||
overdueRate: number | null
|
||||
expiredTaskCount: number
|
||||
signedSampleCount: number
|
||||
departmentAverages: { label: string; averageMinutes: number; sampleCount: number }[]
|
||||
dailyAverages: { label: string; averageMinutes: number; sampleCount: number }[]
|
||||
overdueTaskCount: number
|
||||
overdueTasks: {
|
||||
taskId: string
|
||||
patientDisplayId: string
|
||||
patientNameMasked: string
|
||||
department: string
|
||||
documentName: string
|
||||
createdAt: string
|
||||
signedAt: string
|
||||
elapsedMinutes: number
|
||||
}[]
|
||||
}
|
||||
patientStructure: {
|
||||
age: { label: string; value: number }[]
|
||||
sex: { label: string; value: number }[]
|
||||
visitType: { label: string; value: number }[]
|
||||
campus: { label: string; value: number }[]
|
||||
}
|
||||
|
||||
return 'pad'
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
interface ReportOptionsDto {
|
||||
campuses: { id: string; name: string }[]
|
||||
departments: { id: string; campusId: string; name: string }[]
|
||||
templateCategories: string[]
|
||||
}
|
||||
|
||||
function formatMoment(date: Date): string {
|
||||
interface ReportTaskDto {
|
||||
taskId: string
|
||||
patientDisplayId: string
|
||||
patientNameMasked: string
|
||||
status: string
|
||||
signMethod: string
|
||||
createdAt: string
|
||||
signedAt: string | null
|
||||
templateName: string
|
||||
templateCategory: string
|
||||
campusName: string
|
||||
departmentName: string
|
||||
}
|
||||
|
||||
interface BackendPage<T> {
|
||||
records?: T[]
|
||||
items?: T[]
|
||||
total?: number
|
||||
page?: number
|
||||
size?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface ReportTasksQuery extends ReportAnalyticsQuery {
|
||||
page: number
|
||||
size: number
|
||||
dimension?: 'department' | 'category' | 'method' | 'campus'
|
||||
groupValue?: string
|
||||
}
|
||||
|
||||
export interface ReportExportRequest extends ReportTasksQuery {
|
||||
timeDimension: 'CREATED' | 'SIGNED' | 'EXPIRED'
|
||||
sort: string
|
||||
direction: 'asc' | 'desc'
|
||||
format: 'CSV' | 'XLSX'
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
export interface ReportExportJob {
|
||||
jobId: string
|
||||
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
|
||||
resultCount: number | null
|
||||
errorMessage: string | null
|
||||
downloadPath: string | null
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return value
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, '')
|
||||
.replace(/Z$/, '')
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 签署用时(分钟)。
|
||||
*
|
||||
* 分三段构造,保证「24 小时内完成率」与「超期完成」两张表都拿得到样本:
|
||||
* 大部分在 3~120 分钟,一部分落在 24 小时附近,少数超过 48 小时有效期。
|
||||
*/
|
||||
function buildSignMinutes(random: () => number): number {
|
||||
const roll = random()
|
||||
|
||||
if (roll < 0.82) {
|
||||
return intBetween(random, 3, 720)
|
||||
}
|
||||
|
||||
if (roll < 0.94) {
|
||||
return intBetween(random, 720, 2880)
|
||||
}
|
||||
|
||||
return intBetween(random, 2881, 6600)
|
||||
function mapGroup(group: ReportGroupDto): ReportGroupRow {
|
||||
const { voided, ...counts } = group
|
||||
return { ...counts, void: voided }
|
||||
}
|
||||
|
||||
function buildTasks(): ReportTaskRow[] {
|
||||
const random = createRandom(20260924)
|
||||
const rows: ReportTaskRow[] = []
|
||||
const today = new Date()
|
||||
|
||||
for (let index = 0; index < DEMO_TASK_COUNT; index += 1) {
|
||||
const patient = pick(random, DEMO_PATIENTS)
|
||||
const document = pick(random, mockDocumentCatalog)
|
||||
const status = pickStatus(random)
|
||||
// 四种方式按现场/线上偏重的权重抽,保证「签署方式分布」四个维度都有量
|
||||
const method = pickMethod(random)
|
||||
|
||||
// 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假
|
||||
const daysAgo = Math.floor(DEMO_SPAN_DAYS * Math.pow(random(), 1.35))
|
||||
|
||||
const createdAt = new Date(today)
|
||||
createdAt.setDate(createdAt.getDate() - daysAgo)
|
||||
createdAt.setHours(intBetween(random, 8, 19), intBetween(random, 0, 59), 0, 0)
|
||||
|
||||
// 待科室确认的任务患者也已经签完了,签署时间与用时都要有 ——
|
||||
// 否则「签署时效分析」会把它们当成"还没签",24 小时内完成率被算低
|
||||
const isSigned = status === 'signed' || status === 'confirming'
|
||||
const signMinutes = isSigned ? buildSignMinutes(random) : undefined
|
||||
|
||||
const row: ReportTaskRow = {
|
||||
id: `RT${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`,
|
||||
patientId: `P${202610000 + (DEMO_PATIENTS.indexOf(patient) + 1)}`,
|
||||
patientName: patient.name,
|
||||
sex: patient.sex,
|
||||
age: patient.age,
|
||||
visitType: patient.visitType,
|
||||
visitNo: `${patient.visitType === '住院' ? 'ZY' : patient.visitType === '体检' ? 'TJ' : 'MZ'}${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`,
|
||||
campus: campusOfDepartment(document.department),
|
||||
department: document.department,
|
||||
documentName: document.name,
|
||||
category: document.category,
|
||||
method,
|
||||
status,
|
||||
createdAt: formatMoment(createdAt),
|
||||
}
|
||||
|
||||
if (signMinutes !== undefined) {
|
||||
const signedAt = new Date(createdAt.getTime() + signMinutes * 60_000)
|
||||
row.signMinutes = signMinutes
|
||||
row.signedAt = formatMoment(signedAt)
|
||||
}
|
||||
|
||||
rows.push(row)
|
||||
function mapStatus(value: string): SigningTaskStatus {
|
||||
switch (value) {
|
||||
case 'WAITING_SIGN':
|
||||
return 'pending'
|
||||
case 'WAITING_CONFIRM':
|
||||
return 'confirming'
|
||||
case 'CREATED':
|
||||
case 'GENERATING':
|
||||
return 'signing'
|
||||
case 'SIGNED':
|
||||
return 'signed'
|
||||
case 'EXPIRED':
|
||||
return 'expired'
|
||||
case 'VOIDED':
|
||||
return 'void'
|
||||
default:
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
// 按发起时间倒序:页面与导出的默认顺序都跟这里一致,不必各自再排一次
|
||||
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
对外接口
|
||||
========================================================================= */
|
||||
|
||||
let cachedTasks: ReportTaskRow[] | null = null
|
||||
|
||||
/** 演示数据只生成一次:确定性生成也要避免每次进页面都重跑一遍 */
|
||||
function readTasks(): ReportTaskRow[] {
|
||||
cachedTasks ??= buildTasks()
|
||||
|
||||
return cachedTasks
|
||||
function mapMethod(value: string): SigningMethod {
|
||||
return value === 'SMS' ? 'sms' : 'pad'
|
||||
}
|
||||
|
||||
/**
|
||||
* 取报表分析数据集。
|
||||
*
|
||||
* 真实模式下后端没有对应接口,返回 `available: false` 而不抛错:
|
||||
* 报表页拿到空数据时的正确表现是「说明原因」,不是弹一个红色报错 ——
|
||||
* 后端没上线不是用户的错,也不该看起来像页面坏了。
|
||||
*/
|
||||
export async function getReportAnalyticsDataset(): Promise<ReportAnalyticsDataset> {
|
||||
if (!useMockData) {
|
||||
return { tasks: [], available: false, generatedAt: formatMoment(new Date()) }
|
||||
}
|
||||
|
||||
function mapTask(task: ReportTaskDto): ReportTaskRow {
|
||||
const signedAt = task.signedAt ? formatDateTime(task.signedAt) : undefined
|
||||
const createdAt = formatDateTime(task.createdAt)
|
||||
const elapsed = task.signedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(task.signedAt).getTime() - new Date(task.createdAt).getTime()) / 60_000,
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
return {
|
||||
tasks: readTasks(),
|
||||
available: true,
|
||||
generatedAt: formatMoment(new Date()),
|
||||
id: task.taskId,
|
||||
patientId: task.patientDisplayId,
|
||||
patientName: task.patientNameMasked,
|
||||
campus: task.campusName,
|
||||
department: task.departmentName,
|
||||
documentName: task.templateName,
|
||||
category: task.templateCategory,
|
||||
method: mapMethod(task.signMethod),
|
||||
status: mapStatus(task.status),
|
||||
createdAt,
|
||||
signedAt,
|
||||
signMinutes: elapsed,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReportAnalyticsQuery(
|
||||
filter: ReportFilterInput,
|
||||
window: { from: string; to: string },
|
||||
): ReportAnalyticsQuery {
|
||||
const start = new Date(`${window.from}T00:00:00`)
|
||||
const end = new Date(`${window.to}T00:00:00`)
|
||||
end.setDate(end.getDate() + 1)
|
||||
const query: ReportAnalyticsQuery = {
|
||||
createdFrom: start.toISOString(),
|
||||
createdTo: end.toISOString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
||||
}
|
||||
if (filter.campus && filter.campus !== 'all') query.campusId = filter.campus
|
||||
if (filter.department) query.departmentId = filter.department
|
||||
if (filter.category) query.templateCategory = filter.category
|
||||
if (filter.method !== 'all') query.signMethod = filter.method.toUpperCase() as 'PAD' | 'SMS'
|
||||
return query
|
||||
}
|
||||
|
||||
export async function getReportOptions(): Promise<ReportOptions> {
|
||||
const response = await request.get<ApiResponse<ReportOptionsDto>>('/v1/reports/signing/options')
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getReportAnalytics(
|
||||
query: ReportAnalyticsQuery,
|
||||
): Promise<ReportAnalyticsDataset> {
|
||||
const response = await request.get<ApiResponse<ReportAnalyticsDto>>(
|
||||
'/v1/reports/signing/analytics',
|
||||
{ params: query },
|
||||
)
|
||||
const dto = unwrapApiResponse(response)
|
||||
const { voided, ...statusCounts } = dto.statusCounts
|
||||
return {
|
||||
timezone: dto.timezone,
|
||||
generatedAt: formatDateTime(dto.generatedAt),
|
||||
fromDate: dto.fromDate,
|
||||
toDate: dto.toDate,
|
||||
statusCounts: { ...statusCounts, void: voided },
|
||||
trend: dto.trend.map((point) => ({
|
||||
key: point.date,
|
||||
label: `${Number(point.date.slice(5, 7))}/${Number(point.date.slice(8, 10))}`,
|
||||
created: point.created,
|
||||
signed: point.signed,
|
||||
})),
|
||||
departments: dto.departments.map(mapGroup),
|
||||
categories: dto.categories.map(mapGroup),
|
||||
methods: dto.methods.map(mapGroup),
|
||||
campuses: dto.campuses.map(mapGroup),
|
||||
documents: dto.documents.map(mapGroup),
|
||||
timeliness: {
|
||||
averageMinutes: dto.timeliness.averageMinutes,
|
||||
within24hRate: dto.timeliness.within24hRate,
|
||||
overdueRate: dto.timeliness.overdueRate,
|
||||
expiredTaskCount: dto.timeliness.expiredTaskCount,
|
||||
signedSampleCount: dto.timeliness.signedSampleCount,
|
||||
departmentAverages: dto.timeliness.departmentAverages.map((row) => ({
|
||||
label: row.label,
|
||||
value: row.averageMinutes,
|
||||
})),
|
||||
dailyAverages: dto.timeliness.dailyAverages.map((row) => ({
|
||||
date: row.label,
|
||||
averageMinutes: row.averageMinutes,
|
||||
sampleCount: row.sampleCount,
|
||||
})),
|
||||
overdueTaskCount: dto.timeliness.overdueTaskCount,
|
||||
overdueTasks: dto.timeliness.overdueTasks.map((row) => ({
|
||||
id: row.taskId,
|
||||
patientId: row.patientDisplayId,
|
||||
patientName: row.patientNameMasked,
|
||||
department: row.department,
|
||||
documentName: row.documentName,
|
||||
createdAt: formatDateTime(row.createdAt),
|
||||
signedAt: formatDateTime(row.signedAt),
|
||||
signMinutes: row.elapsedMinutes,
|
||||
})),
|
||||
},
|
||||
patientStructure: {
|
||||
age: dto.patientStructure.age,
|
||||
sex: dto.patientStructure.sex,
|
||||
visitType: dto.patientStructure.visitType,
|
||||
campus: dto.patientStructure.campus,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReportAnalyticsTasks(
|
||||
query: ReportTasksQuery,
|
||||
): Promise<PageResult<ReportTaskRow>> {
|
||||
const response = await request.get<ApiResponse<BackendPage<ReportTaskDto>>>(
|
||||
'/v1/reports/signing/analytics/tasks',
|
||||
{
|
||||
params: query,
|
||||
},
|
||||
)
|
||||
const page = unwrapApiResponse(response)
|
||||
return {
|
||||
records: (page.records ?? page.items ?? []).map(mapTask),
|
||||
total: page.total ?? 0,
|
||||
page: page.page ?? query.page,
|
||||
pageSize: page.pageSize ?? page.size ?? query.size,
|
||||
}
|
||||
}
|
||||
|
||||
export function createReportExportJob(input: ReportExportRequest): Promise<ReportExportJob> {
|
||||
return request
|
||||
.post<ApiResponse<ReportExportJob>>('/v1/reports/signing/export-jobs', {
|
||||
...input,
|
||||
page: 1,
|
||||
size: 200,
|
||||
timeDimension: 'CREATED',
|
||||
sort: 'createdAt',
|
||||
direction: 'desc',
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function getReportExportJob(jobId: string): Promise<ReportExportJob> {
|
||||
return request
|
||||
.get<ApiResponse<ReportExportJob>>(
|
||||
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`,
|
||||
)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export async function downloadReportExport(path: string, filename: string): Promise<void> {
|
||||
if (!/^\/api\/v1\/reports\/signing\/export-jobs\/[0-9a-f-]{36}\/download$/i.test(path)) {
|
||||
throw new Error('后端返回的导出下载地址无效')
|
||||
}
|
||||
const apiPath = path.replace(/^\/api(?=\/)/, '')
|
||||
const blob = await request.get<Blob>(apiPath, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
@@ -43,16 +43,6 @@ export const mockFlags = {
|
||||
documentPermissions: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENT_PERMISSIONS, false),
|
||||
/** 审计日志:已有只读后端查询接口,直接读取数据库记录 */
|
||||
audit: resolveMockFlag(import.meta.env.VITE_MOCK_AUDIT, false),
|
||||
/**
|
||||
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)。
|
||||
*
|
||||
* 「系统管理 → 报表分析」(原 `reports` 开关,走 reports/signing/* 接口)已整体下线,
|
||||
* 开关与页面一并移除;这里是「报表分析」目录下的四个独立菜单,是另一套菜单、
|
||||
* 另一套后端面,不受影响。
|
||||
*
|
||||
* 缺省开着:后端目前没有任何分析聚合接口,真实模式只会得到空态。
|
||||
*/
|
||||
reportAnalytics: resolveMockFlag(import.meta.env.VITE_MOCK_REPORT_ANALYTICS, true),
|
||||
/** 用户与权限:用户、角色、接口权限目录 */
|
||||
users: resolveMockFlag(import.meta.env.VITE_MOCK_USERS, false),
|
||||
/**
|
||||
|
||||
@@ -1,60 +1,17 @@
|
||||
/**
|
||||
* 报表分析的聚合逻辑:全部是纯函数,输入「任务明细 + 筛选条件」,输出页面直接渲染的结构。
|
||||
*
|
||||
* 为什么单独一份而不是写进各页面:同一个「按科室的完成率」在总览的汇总矩阵、
|
||||
* 时效分析的分组条形榜、患者结构分析的院区分布里都要用;各页各写一遍,
|
||||
* 迟早出现「同一个筛选条件,两个页面算出不同的数」——这种不一致在界面上
|
||||
* 没有任何提示,只能靠人盯着两个页面比对才能发现。
|
||||
*
|
||||
* 纯函数还有个直接好处:不依赖 DOM 与 Vue,可以用 Node 直接跑断言(见验证脚本)。
|
||||
*/
|
||||
/** 展示层使用的报表标签、日期窗口和格式化函数。聚合指标由后端计算。 */
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types'
|
||||
import { formatSigningMethodTitle, SIGNING_METHODS } from '@/utils/signing-method'
|
||||
|
||||
import type {
|
||||
ReportBarItem,
|
||||
ReportFilterForm,
|
||||
ReportGroupRow,
|
||||
ReportPatientStructure,
|
||||
ReportStatusCounts,
|
||||
ReportSummaryDimension,
|
||||
ReportTimelinessMetrics,
|
||||
ReportTrendPoint,
|
||||
} from './types'
|
||||
import type { ReportBarItem, ReportFilterForm, ReportSummaryDimension } from './types'
|
||||
|
||||
/** 超 24 小时(分钟):时效分析的「24 小时内完成率」分界 */
|
||||
export const WITHIN_24H_MINUTES = 1440
|
||||
/** 超 48 小时(分钟):超过签署链接有效期,算「超期完成」 */
|
||||
/** 超 48 小时(分钟):超期完成任务的展示阈值。 */
|
||||
export const OVERDUE_MINUTES = 2880
|
||||
|
||||
/** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */
|
||||
const MAX_TREND_DAYS = 60
|
||||
|
||||
/**
|
||||
* 签署方式维度文案。
|
||||
* 口径统一走 utils/signing-method:四种方式各成一行,不能只列 pad / sms——
|
||||
* 报表维度是按任务实际取值聚合的,缺一行会让 Pad / 公众号的任务在图表里显示成空白。
|
||||
* 文案取原型的 methodLabel(`✍️ 手写板签署` 这种带图标的完整写法),
|
||||
* 与报表明细表的「方式」列一致。
|
||||
*
|
||||
* ⚠️ 这是**查表**,没有 `readSigningMethod` 那样的未知取值兜底:`METHOD_LABELS[未知值]`
|
||||
* 会得到 undefined,汇总矩阵会多出一行没有名字的分组。今天不会触发(`SigningMethod`
|
||||
* 是封闭联合,取值只来自本仓库的演示数据),但接真实接口时必须在边界上先把后端取值
|
||||
* 归一到这四个(见 api/workbench/signing.ts 的 normalizeSigningMethod),不要指望这里兜底。
|
||||
*/
|
||||
export const METHOD_LABELS: Record<SigningMethod, string> = Object.fromEntries(
|
||||
SIGNING_METHODS.map((option) => [option.key, formatSigningMethodTitle(option.key)]),
|
||||
) as Record<SigningMethod, string>
|
||||
|
||||
/**
|
||||
* 状态文案。七种状态与后端枚举一一对应。
|
||||
*
|
||||
* `confirming`(待科室确认)是**节点状态**:患者已经签完、只差科室复核签章。
|
||||
* 它在报表里单列一栏而不是并进「已签署」—— 并进去会让「已签署」虚高,
|
||||
* 而科室复核本来就是这类文书的关键卡点,混掉就看不见卡在哪了。
|
||||
*/
|
||||
export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
|
||||
pending: '待签署',
|
||||
signing: '签署中',
|
||||
@@ -65,7 +22,6 @@ export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */
|
||||
export const STATUS_COLUMNS: SigningTaskStatus[] = [
|
||||
'signed',
|
||||
'confirming',
|
||||
@@ -83,65 +39,15 @@ export const SUMMARY_DIMENSION_LABELS: Record<ReportSummaryDimension, string> =
|
||||
campus: '院区',
|
||||
}
|
||||
|
||||
/** 年龄段分档:顺序固定,不按数量排 —— 年龄天然有序,重排反而看不出分布形状 */
|
||||
const AGE_BUCKETS: { label: string; match: (age: number) => boolean }[] = [
|
||||
{ label: '0-17 岁', match: (age) => age < 18 },
|
||||
{ label: '18-40 岁', match: (age) => age >= 18 && age <= 40 },
|
||||
{ label: '41-60 岁', match: (age) => age >= 41 && age <= 60 },
|
||||
{ label: '61 岁以上', match: (age) => age > 60 },
|
||||
]
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** 把 `YYYY-MM-DD HH:mm` 截成日期键 */
|
||||
export function dateKeyOf(moment: string): string {
|
||||
return moment.slice(0, 10)
|
||||
}
|
||||
|
||||
function dateKeyOfDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** 日期键 → 坐标轴标签 `M/D` */
|
||||
export function axisLabelOf(dateKey: string): string {
|
||||
const [, month, day] = dateKey.split('-')
|
||||
|
||||
return `${Number(month)}/${Number(day)}`
|
||||
}
|
||||
|
||||
/** 任务状态 → 计数里的字段名:状态枚举与计数字段一一对应,直接索引即可 */
|
||||
export function emptyCounts(): ReportStatusCounts {
|
||||
return {
|
||||
total: 0,
|
||||
signed: 0,
|
||||
confirming: 0,
|
||||
pending: 0,
|
||||
signing: 0,
|
||||
expired: 0,
|
||||
void: 0,
|
||||
failed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function addToCounts(counts: ReportStatusCounts, status: ReportTaskRow['status']): void {
|
||||
counts.total += 1
|
||||
counts[status] += 1
|
||||
}
|
||||
|
||||
/** 完成率:已签署 ÷ 发起。发起为 0 时按 0 计,不返回 NaN */
|
||||
export function completionRateOf(counts: ReportStatusCounts): number {
|
||||
return counts.total ? Math.round((counts.signed / counts.total) * 100) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析筛选的时间窗口,返回闭区间 `{ from, to }`(都是 `YYYY-MM-DD`)。
|
||||
*
|
||||
* 返回 null 表示「不做时间过滤」:自定义区间只填了一头时属于「条件还没填完」,
|
||||
* 这时既不该按半截条件筛,也不该悄悄套一个默认窗口 —— 由页面禁用查询按钮,
|
||||
* 这里只是给出一个确定的行为。
|
||||
*/
|
||||
/** 解析报表筛选的闭区间日期;时间戳边界在 API 层转换为左闭右开。 */
|
||||
export function resolveDateWindow(
|
||||
filter: ReportFilterForm,
|
||||
today = new Date(),
|
||||
@@ -149,361 +55,45 @@ export function resolveDateWindow(
|
||||
const to = dateKeyOfDate(today)
|
||||
|
||||
if (filter.dateRange === 'custom') {
|
||||
if (!filter.customFrom || !filter.customTo) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 起点晚于终点时按用户填的两个端点反着理解,比返回空结果友好
|
||||
if (!filter.customFrom || !filter.customTo) return null
|
||||
return filter.customFrom <= filter.customTo
|
||||
? { from: filter.customFrom, to: filter.customTo }
|
||||
: { from: filter.customTo, to: filter.customFrom }
|
||||
}
|
||||
|
||||
const from = new Date(today)
|
||||
|
||||
if (filter.dateRange === 'month') {
|
||||
from.setDate(1)
|
||||
} else {
|
||||
const days = filter.dateRange === '7d' ? 7 : 14
|
||||
from.setDate(from.getDate() - (days - 1))
|
||||
from.setDate(from.getDate() - (filter.dateRange === '7d' ? 6 : 13))
|
||||
}
|
||||
|
||||
from.setHours(0, 0, 0, 0)
|
||||
|
||||
return { from: dateKeyOfDate(from), to }
|
||||
}
|
||||
|
||||
/** 按筛选条件过滤任务明细 */
|
||||
export function filterReportTasks(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
filter: ReportFilterForm,
|
||||
today = new Date(),
|
||||
): ReportTaskRow[] {
|
||||
const window = resolveDateWindow(filter, today)
|
||||
|
||||
return tasks.filter((task) => {
|
||||
const day = dateKeyOf(task.createdAt)
|
||||
|
||||
if (window && (day < window.from || day > window.to)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.campus !== 'all' && task.campus !== filter.campus) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.department && task.department !== filter.department) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.category && task.category !== filter.category) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.method !== 'all' && task.method !== filter.method) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
/** 日期键 → 坐标轴标签 `M/D`。 */
|
||||
export function axisLabelOf(dateKey: string): string {
|
||||
const [, month, day] = dateKey.split('-')
|
||||
return `${Number(month)}/${Number(day)}`
|
||||
}
|
||||
|
||||
export function countByStatus(tasks: readonly ReportTaskRow[]): ReportStatusCounts {
|
||||
const counts = emptyCounts()
|
||||
|
||||
tasks.forEach((task) => addToCounts(counts, task.status))
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日趋势:同一天分别统计发起量与完成量。
|
||||
*
|
||||
* 窗口天数由 `window` 决定,而不是「有数据的那几天」—— 中间没有任务的日期
|
||||
* 也要占一根空柱,否则横轴会被压缩,趋势的疏密就看不出真实节奏了。
|
||||
*/
|
||||
export function buildTrend(
|
||||
tasks: readonly ReportTaskRow[],
|
||||
window: { from: string; to: string } | null,
|
||||
): ReportTrendPoint[] {
|
||||
const createdMap = new Map<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} 分钟`
|
||||
}
|
||||
|
||||
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return '—'
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
const hours = minutes / 60
|
||||
|
||||
return `${hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours)} 小时`
|
||||
}
|
||||
|
||||
/** 时长分级配色:24 小时内绿、48 小时内橙、超过红 */
|
||||
export function durationColor(minutes: number): string {
|
||||
if (minutes <= WITHIN_24H_MINUTES) {
|
||||
return '#0f9d6c'
|
||||
}
|
||||
|
||||
return minutes <= OVERDUE_MINUTES ? '#d9821f' : '#d0534f'
|
||||
}
|
||||
|
||||
/** 完成率配色:≥90% 绿、≥80% 橙、其余红(原型口径) */
|
||||
/** 完成率配色:≥90% 绿、≥80% 橙、其余红。 */
|
||||
export function completionRateColor(rate: number): string {
|
||||
if (rate >= 90) {
|
||||
return '#0f9d6c'
|
||||
}
|
||||
|
||||
if (rate >= 90) return '#0f9d6c'
|
||||
return rate >= 80 ? '#d9821f' : '#d0534f'
|
||||
}
|
||||
|
||||
/** 条形榜调色板(与原型一致) */
|
||||
export const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f']
|
||||
const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f']
|
||||
|
||||
/** 取条形颜色:显式给了就用,否则按调色板轮换 */
|
||||
/** 横向条形图优先使用数据指定颜色,否则循环使用主题调色板。 */
|
||||
export function barColorOf(item: ReportBarItem, index: number): string {
|
||||
return item.color ?? BAR_PALETTE[index % BAR_PALETTE.length] ?? '#0e6e8c'
|
||||
}
|
||||
|
||||
@@ -2,33 +2,34 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { downloadCsv, fileStamp } from '@/utils/csv-export'
|
||||
import { REPORT_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
import {
|
||||
buildReportAnalyticsQuery,
|
||||
createReportExportJob,
|
||||
downloadReportExport,
|
||||
getReportExportJob,
|
||||
type ReportExportJob,
|
||||
} from '@/api/workbench/report-analytics'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
|
||||
import { filterReportTasks, formatDuration, METHOD_LABELS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportDateRange, ReportExportColumn, ReportFilterForm, ReportFilterOption } from '../types'
|
||||
import { resolveDateWindow } from '../aggregate'
|
||||
import type { ReportDateRange, ReportFilterForm, ReportFilterOption } from '../types'
|
||||
|
||||
/**
|
||||
* 导出弹窗。
|
||||
* 报表导出通过服务端异步任务完成,任务创建、下载都会由后端记录审计。
|
||||
*
|
||||
* 「导出范围」在弹窗里可以独立调整,而不是固定沿用页面上的筛选:导出通常是
|
||||
* 「把范围放宽一点拿全量数据」,如果只能导出当前所见,用户得先改页面筛选、
|
||||
* 导出、再改回来。
|
||||
*
|
||||
* ⚠️ 前端导出只是权宜之计:导出行为、导出人与导出范围必须写入审计日志,
|
||||
* 浏览器里做不到。后端提供导出任务接口后应整体替换(参考签署记录查询的同类说明)。
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** 未筛选的全量明细:弹窗自己按导出范围过滤 */
|
||||
tasks: ReportTaskRow[]
|
||||
/** 弹窗打开时用于初始化导出范围 */
|
||||
filter: ReportFilterForm
|
||||
campuses: ReportFilterOption<WorkbenchCampus | 'all'>[]
|
||||
departments: ReportFilterOption[]
|
||||
departments: (ReportFilterOption & { campusId?: string })[]
|
||||
categories: ReportFilterOption[]
|
||||
}>()
|
||||
|
||||
@@ -43,9 +44,10 @@ const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
]
|
||||
|
||||
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
const methodOptions: ReportFilterOption<ReportFilterForm['method']>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -54,28 +56,19 @@ const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
* 前九项与原型一致,默认勾选;其余是台账里常要的补充字段,默认不勾 ——
|
||||
* 默认全勾会让导出的表宽到没法看,而默认只给最少字段又要用户每次手动加。
|
||||
*/
|
||||
const EXPORT_COLUMNS: ReportExportColumn[] = [
|
||||
{ key: 'id', label: '任务号', value: (row) => row.id },
|
||||
{ key: 'patientName', label: '患者姓名', value: (row) => row.patientName },
|
||||
{ key: 'documentName', label: '文档名称', value: (row) => row.documentName },
|
||||
{ key: 'department', label: '科室', value: (row) => row.department },
|
||||
{ key: 'method', label: '签署方式', value: (row) => METHOD_LABELS[row.method] },
|
||||
{ key: 'status', label: '状态', value: (row) => STATUS_LABELS[row.status] },
|
||||
{ key: 'createdAt', label: '发起时间', value: (row) => row.createdAt },
|
||||
{ key: 'signedAt', label: '完成时间', value: (row) => row.signedAt ?? '' },
|
||||
{
|
||||
key: 'signMinutes',
|
||||
label: '签署用时',
|
||||
// 表格里空值显示「—」,但 CSV 给空单元格:表格里的破折号进到 Excel 会变成文本
|
||||
value: (row) => (row.signMinutes === undefined ? '' : formatDuration(row.signMinutes)),
|
||||
},
|
||||
{ key: 'patientId', label: '患者ID', value: (row) => row.patientId },
|
||||
{ key: 'sex', label: '性别', value: (row) => row.sex },
|
||||
{ key: 'age', label: '年龄', value: (row) => String(row.age) },
|
||||
{ key: 'visitNo', label: '就诊号', value: (row) => row.visitNo },
|
||||
{ key: 'visitType', label: '就诊类型', value: (row) => row.visitType },
|
||||
{ key: 'campus', label: '院区', value: (row) => row.campus },
|
||||
{ key: 'category', label: '文档类别', value: (row) => row.category },
|
||||
const EXPORT_COLUMNS = [
|
||||
{ key: 'taskId', label: '任务号' },
|
||||
{ key: 'patientDisplayId', label: '患者脱敏标识' },
|
||||
{ key: 'patientNameMasked', label: '患者姓名(脱敏)' },
|
||||
{ key: 'templateName', label: '文档名称' },
|
||||
{ key: 'departmentName', label: '科室' },
|
||||
{ key: 'signMethod', label: '签署方式' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'createdAt', label: '发起时间' },
|
||||
{ key: 'signedAt', label: '完成时间' },
|
||||
{ key: 'signDurationSeconds', label: '签署用时(秒)' },
|
||||
{ key: 'campusName', label: '院区' },
|
||||
{ key: 'templateCategory', label: '文档类别' },
|
||||
]
|
||||
|
||||
const DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key)
|
||||
@@ -83,6 +76,17 @@ const DEFAULT_CHECKED = EXPORT_COLUMNS.slice(0, 9).map((column) => column.key)
|
||||
const draft = ref<ReportFilterForm>({ ...props.filter })
|
||||
const checkedKeys = ref<string[]>([...DEFAULT_CHECKED])
|
||||
const exporting = ref(false)
|
||||
const activeJobId = ref('')
|
||||
const activeJob = ref<ReportExportJob | null>(null)
|
||||
|
||||
const draftDepartmentOptions = computed(() =>
|
||||
props.departments.filter(
|
||||
(department) =>
|
||||
!department.value ||
|
||||
draft.value.campus === 'all' ||
|
||||
department.campusId === draft.value.campus,
|
||||
),
|
||||
)
|
||||
|
||||
// 每次打开都按「当前页面筛选」重置:上次调过的导出范围留到下一次会让人导错数据
|
||||
watch(
|
||||
@@ -95,7 +99,17 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const scopedTasks = computed(() => filterReportTasks(props.tasks, draft.value))
|
||||
watch(
|
||||
() => draft.value.campus,
|
||||
() => {
|
||||
if (
|
||||
draft.value.department &&
|
||||
!draftDepartmentOptions.value.some((option) => option.value === draft.value.department)
|
||||
) {
|
||||
draft.value.department = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const customRangeIncomplete = computed(
|
||||
() => draft.value.dateRange === 'custom' && (!draft.value.customFrom || !draft.value.customTo),
|
||||
@@ -104,43 +118,86 @@ const customRangeIncomplete = computed(
|
||||
const selectedColumns = computed(() =>
|
||||
EXPORT_COLUMNS.filter((column) => checkedKeys.value.includes(column.key)),
|
||||
)
|
||||
|
||||
const canExport = computed(
|
||||
() => !customRangeIncomplete.value && scopedTasks.value.length > 0 && selectedColumns.value.length > 0,
|
||||
() =>
|
||||
!exporting.value &&
|
||||
(Boolean(activeJobId.value) ||
|
||||
(!customRangeIncomplete.value && selectedColumns.value.length > 0)),
|
||||
)
|
||||
|
||||
function wait(milliseconds: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
async function pollExportJob(jobId: string): Promise<ReportExportJob> {
|
||||
let latest = activeJob.value
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
latest = await getReportExportJob(jobId)
|
||||
activeJob.value = latest
|
||||
if (latest.status === 'SUCCEEDED' || latest.status === 'FAILED' || latest.status === 'EXPIRED')
|
||||
return latest
|
||||
if (attempt < 44) await wait(2000)
|
||||
}
|
||||
return latest!
|
||||
}
|
||||
|
||||
async function finishExport(job: ReportExportJob) {
|
||||
if (job.status === 'SUCCEEDED' && job.downloadPath) {
|
||||
await downloadReportExport(job.downloadPath, `签署报表_${job.jobId}.csv`)
|
||||
ElMessage.success(`已导出 ${job.resultCount ?? 0} 条签署任务`)
|
||||
activeJobId.value = ''
|
||||
activeJob.value = null
|
||||
emit('close')
|
||||
} else if (job.status === 'FAILED' || job.status === 'EXPIRED') {
|
||||
activeJobId.value = ''
|
||||
activeJob.value = null
|
||||
ElMessage.error(job.errorMessage || '导出失败,请稍后重试')
|
||||
} else {
|
||||
ElMessage.info('导出任务仍在后台处理,可稍后再次点击继续查看')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (customRangeIncomplete.value) {
|
||||
async function handleExport() {
|
||||
if (!activeJobId.value && customRangeIncomplete.value) {
|
||||
ElMessage.info('自定义时间范围需要同时填写起止日期')
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedColumns.value.length) {
|
||||
if (!activeJobId.value && !selectedColumns.value.length) {
|
||||
ElMessage.info('请至少选择一个导出字段')
|
||||
return
|
||||
}
|
||||
|
||||
if (!scopedTasks.value.length) {
|
||||
ElMessage.info('当前导出范围内没有可导出的签署任务')
|
||||
return
|
||||
}
|
||||
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
exporting.value = true
|
||||
|
||||
try {
|
||||
downloadCsv(selectedColumns.value, scopedTasks.value, `签署报表_${fileStamp()}.csv`)
|
||||
ElMessage.success(`已导出 ${scopedTasks.value.length} 条签署任务`)
|
||||
emit('close')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请稍后重试')
|
||||
if (!activeJobId.value) {
|
||||
const dateWindow = resolveDateWindow(draft.value)
|
||||
if (!dateWindow) throw new Error('自定义时间范围需要同时填写起止日期')
|
||||
const query = buildReportAnalyticsQuery(draft.value, dateWindow)
|
||||
const job = await createReportExportJob({
|
||||
...query,
|
||||
page: 1,
|
||||
size: 200,
|
||||
timeDimension: 'CREATED',
|
||||
sort: 'createdAt',
|
||||
direction: 'desc',
|
||||
format: 'CSV',
|
||||
columns: selectedColumns.value.map((column) => column.key),
|
||||
})
|
||||
activeJobId.value = job.jobId
|
||||
activeJob.value = job
|
||||
}
|
||||
await finishExport(await pollExportJob(activeJobId.value))
|
||||
} catch (error) {
|
||||
ElMessage.error(toUserMessage(error, '导出任务创建或下载失败'))
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
@@ -188,7 +245,11 @@ function handleExport() {
|
||||
<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
|
||||
v-for="option in draftDepartmentOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -226,7 +287,10 @@ function handleExport() {
|
||||
|
||||
<p class="hint" :class="{ 'is-warn': customRangeIncomplete }">
|
||||
<template v-if="customRangeIncomplete">自定义时间范围需要同时填写起止日期。</template>
|
||||
<template v-else>本次将导出 <b>{{ scopedTasks.length }}</b> 条签署任务。</template>
|
||||
<template v-else-if="activeJobId">
|
||||
导出任务 {{ activeJobId }} · {{ activeJob?.status ?? '处理中' }}
|
||||
</template>
|
||||
<template v-else>由后端按当前筛选生成 CSV,导出过程会写入审计记录。</template>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -241,13 +305,11 @@ function handleExport() {
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
文件格式:CSV(UTF-8,带 BOM,Excel 可直接打开)。Excel / PDF 需后端导出任务支持,当前未接入。
|
||||
文件格式:CSV(UTF-8)。患者姓名和标识按后端安全字段脱敏;人口统计原始字段不提供导出。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="note">
|
||||
导出行为、导出人与导出范围均需写入审计日志,可在「审计日志」模块追溯;当前为前端导出,尚未留痕。
|
||||
</p>
|
||||
<p class="note">后端异步生成文件。任务仍在处理中时可再次打开此弹窗继续查看。</p>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -260,7 +322,7 @@ function handleExport() {
|
||||
:disabled="!canExport || exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
{{ exporting ? '导出中…' : '开始导出' }}
|
||||
{{ exporting ? '正在处理…' : activeJobId ? '继续查看导出' : '开始导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { REPORT_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
|
||||
import { reportFilter, resetReportFilter } from '../filter-store'
|
||||
import type { ReportDateRange, ReportFilterOption } from '../types'
|
||||
@@ -20,6 +19,7 @@ defineProps<{
|
||||
campuses: ReportFilterOption<WorkbenchCampus | 'all'>[]
|
||||
departments: ReportFilterOption[]
|
||||
categories: ReportFilterOption[]
|
||||
canExport: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,10 +33,11 @@ const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
]
|
||||
|
||||
/** 原型 #repMethod 是 4 项(手写板 / Pad / 短信 / 公众号),与记录查询的 3 项不同 */
|
||||
/** 后端任务目前只支持 PAD 与 SMS 两种签署方式。 */
|
||||
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -111,7 +112,12 @@ function handleReset() {
|
||||
<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
|
||||
v-if="canExport"
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('export')"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { mockCatalogCategories, mockCatalogDepartments } from '@/api/workbench/mock-catalog'
|
||||
import type { WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
|
||||
import { reportFilter } from '../filter-store'
|
||||
import type { ReportFilterOption } from '../types'
|
||||
import { loadReportDataset, useReportDataset } from '../use-report-dataset'
|
||||
import { loadReportDataset, loadReportOptions, useReportDataset } from '../use-report-dataset'
|
||||
import ReportExportDialog from './ReportExportDialog.vue'
|
||||
import ReportFilterBar from './ReportFilterBar.vue'
|
||||
|
||||
/**
|
||||
* 报表页面外壳:标题 + 筛选条 + 数据可用性闸门 + 导出弹窗,四个菜单共用。
|
||||
*
|
||||
* 为什么把闸门和导出也放进来:这两件事四个页面一字不差。各写一遍的话,
|
||||
* 「后端没有接口」的说明文案会在四个页面里慢慢分叉,导出列也会各自漂移 ——
|
||||
* 到时候同一个任务在总览导出和时效导出里字段都不一样。
|
||||
* 为什么把数据加载和导出也放进来:四个页面共用同一套筛选项、请求状态和受审计的
|
||||
* 服务端导出入口,避免各页的筛选与权限行为出现差异。
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,31 +22,51 @@ const props = defineProps<{
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { allTasks, tasks, available, generatedAt, loading, errorMessage, customRangeIncomplete } =
|
||||
useReportDataset()
|
||||
const loginStore = useLoginStore()
|
||||
const canExport = computed(() => loginStore.hasPermission('report:sign:export'))
|
||||
const {
|
||||
dataset,
|
||||
options,
|
||||
available,
|
||||
generatedAt,
|
||||
loading,
|
||||
errorMessage,
|
||||
optionsError,
|
||||
customRangeIncomplete,
|
||||
} = useReportDataset()
|
||||
|
||||
const exportVisible = ref(false)
|
||||
|
||||
const campusOptions = computed<ReportFilterOption<WorkbenchCampus | 'all'>[]>(() => [
|
||||
{ label: '全部院区', value: 'all' },
|
||||
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
|
||||
...(options.value?.campuses ?? []).map((campus) => ({ label: campus.name, value: campus.id })),
|
||||
])
|
||||
|
||||
/**
|
||||
* 科室与类别取演示词表:报表的演示数据本身就来自这份词表,
|
||||
* 用组织接口的科室清单会出现「筛了某个科室但一条数据都没有」的假空态。
|
||||
* 真实模式下面板不渲染(没有数据可筛),这里无需再区分口径。
|
||||
*/
|
||||
const departmentOptions: ReportFilterOption[] = [
|
||||
{ label: '全部科室', value: '' },
|
||||
...mockCatalogDepartments.map((name) => ({ label: name, value: name })),
|
||||
]
|
||||
const allDepartmentOptions = computed(() => [
|
||||
{ label: '全部科室', value: '', campusId: '' },
|
||||
...(options.value?.departments ?? []).map((department) => ({
|
||||
label: department.name,
|
||||
value: department.id,
|
||||
campusId: department.campusId,
|
||||
})),
|
||||
])
|
||||
|
||||
const categoryOptions: ReportFilterOption[] = [
|
||||
const departmentOptions = computed<ReportFilterOption[]>(() =>
|
||||
allDepartmentOptions.value.filter(
|
||||
(department) =>
|
||||
!department.value ||
|
||||
reportFilter.campus === 'all' ||
|
||||
department.campusId === reportFilter.campus,
|
||||
),
|
||||
)
|
||||
|
||||
const categoryOptions = computed<ReportFilterOption[]>(() => [
|
||||
{ label: '全部类别', value: '' },
|
||||
...mockCatalogCategories.map((name) => ({ label: name, value: name })),
|
||||
]
|
||||
...(options.value?.templateCategories ?? []).map((category) => ({
|
||||
label: category,
|
||||
value: category,
|
||||
})),
|
||||
])
|
||||
|
||||
const metaText = computed(() =>
|
||||
generatedAt.value ? `数据截至 ${generatedAt.value}` : '正在读取数据…',
|
||||
@@ -61,9 +79,21 @@ const metaText = computed(() =>
|
||||
const showPageIntro = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.loadCampuses()
|
||||
await loadReportOptions()
|
||||
await loadReportDataset()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => reportFilter.campus,
|
||||
() => {
|
||||
if (
|
||||
reportFilter.department &&
|
||||
!departmentOptions.value.some((option) => option.value === reportFilter.department)
|
||||
) {
|
||||
reportFilter.department = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,7 +104,7 @@ onMounted(async () => {
|
||||
<p>{{ props.description }}</p>
|
||||
</div>
|
||||
<div class="page-intro__meta">
|
||||
<span v-if="available">当前筛选 {{ tasks.length }} 条任务</span>
|
||||
<span v-if="available">当前筛选 {{ dataset?.statusCounts.total ?? 0 }} 条任务</span>
|
||||
<span>{{ metaText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,20 +113,15 @@ onMounted(async () => {
|
||||
<div class="report-state">正在读取报表数据…</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="errorMessage">
|
||||
<div class="report-state report-state--error">{{ errorMessage }}</div>
|
||||
<template v-else-if="errorMessage || optionsError">
|
||||
<div class="report-state report-state--error">{{ errorMessage || optionsError }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!available">
|
||||
<template v-else-if="!available || !dataset">
|
||||
<div class="report-state">
|
||||
<strong>后端尚未提供报表分析接口</strong>
|
||||
<p>
|
||||
报表分析目前只有演示数据。真实模式(关闭演示数据)下这里没有可用的聚合结果,
|
||||
因此不渲染任何统计数字 —— 显示一张全 0 的报表会让人以为「今天真的没人签」。
|
||||
</p>
|
||||
<p class="report-state__hint">
|
||||
需要查看演示数据时,把 <code>VITE_MOCK_REPORT_ANALYTICS</code> 设为 <code>true</code> 后重启开发服务。
|
||||
</p>
|
||||
<strong>暂无报表数据</strong>
|
||||
<p v-if="customRangeIncomplete">请补齐自定义时间范围的起止日期后查看统计。</p>
|
||||
<p v-else>当前筛选范围内没有签署任务。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,6 +130,7 @@ onMounted(async () => {
|
||||
:campuses="campusOptions"
|
||||
:departments="departmentOptions"
|
||||
:categories="categoryOptions"
|
||||
:can-export="canExport"
|
||||
@export="exportVisible = true"
|
||||
/>
|
||||
|
||||
@@ -115,11 +141,11 @@ onMounted(async () => {
|
||||
<slot />
|
||||
|
||||
<ReportExportDialog
|
||||
v-if="canExport"
|
||||
:visible="exportVisible"
|
||||
:tasks="allTasks"
|
||||
:filter="reportFilter"
|
||||
:campuses="campusOptions"
|
||||
:departments="departmentOptions"
|
||||
:departments="allDepartmentOptions"
|
||||
:categories="categoryOptions"
|
||||
@close="exportVisible = false"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { ReportGroupRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
import { completionRateColor, groupTasks, STATUS_COLUMNS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportGroupRow, ReportStatusCounts, ReportSummaryDimension } from '../types'
|
||||
import { completionRateColor, METHOD_LABELS, STATUS_COLUMNS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportStatusCounts, ReportSummaryDimension } from '../types'
|
||||
|
||||
/**
|
||||
* 签署汇总矩阵:把当前筛选下的任务按一个维度汇总,并支持下钻。
|
||||
@@ -15,7 +16,7 @@ import type { ReportGroupRow, ReportStatusCounts, ReportSummaryDimension } from
|
||||
*/
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: ReportTaskRow[]
|
||||
rows: ReportGroupRow[]
|
||||
/** 当前选中的分组维度(由页面持有,便于切换维度时重置下钻) */
|
||||
dimension: ReportSummaryDimension
|
||||
}>()
|
||||
@@ -33,7 +34,7 @@ const dimensionOptions: { label: string; value: ReportSummaryDimension }[] = [
|
||||
{ label: '按院区', value: 'campus' },
|
||||
]
|
||||
|
||||
const rows = computed(() => groupTasks(props.tasks, props.dimension))
|
||||
const rows = computed(() => props.rows)
|
||||
|
||||
const totals = computed<ReportStatusCounts>(() =>
|
||||
rows.value.reduce<ReportStatusCounts>(
|
||||
@@ -70,6 +71,10 @@ const hoveredKey = ref<string | null>(null)
|
||||
function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]): number {
|
||||
return row[status]
|
||||
}
|
||||
|
||||
function keyLabel(key: string) {
|
||||
return props.dimension === 'method' ? (METHOD_LABELS[key as SigningMethod] ?? key) : key
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,7 +87,12 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
<select
|
||||
:value="dimension"
|
||||
aria-label="选择汇总口径"
|
||||
@change="emit('update:dimension', ($event.target as HTMLSelectElement).value as ReportSummaryDimension)"
|
||||
@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 }}
|
||||
@@ -103,9 +113,7 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!rows.length">
|
||||
<td :colspan="STATUS_COLUMNS.length + 3" class="matrix__empty">
|
||||
当前条件下暂无数据
|
||||
</td>
|
||||
<td :colspan="STATUS_COLUMNS.length + 3" class="matrix__empty">当前条件下暂无数据</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
@@ -117,7 +125,9 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
@click="emit('drill', null)"
|
||||
>
|
||||
<td><b>合计</b></td>
|
||||
<td><b>{{ totals.total }}</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>
|
||||
@@ -129,12 +139,14 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
:key="row.key"
|
||||
class="matrix__row"
|
||||
:class="{ 'is-hover': hoveredKey === row.key }"
|
||||
:title="`点击查看「${row.key}」的明细`"
|
||||
:title="`点击查看「${keyLabel(row.key)}」的明细`"
|
||||
@mouseenter="hoveredKey = row.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
@click="emit('drill', row.key)"
|
||||
>
|
||||
<td><b>{{ row.key }}</b></td>
|
||||
<td>
|
||||
<b>{{ keyLabel(row.key) }}</b>
|
||||
</td>
|
||||
<td>{{ row.total }}</td>
|
||||
<td v-for="status in STATUS_COLUMNS" :key="status">{{ valueOf(row, status) }}</td>
|
||||
<td>
|
||||
@@ -148,7 +160,8 @@ function valueOf(row: ReportGroupRow, status: (typeof STATUS_COLUMNS)[number]):
|
||||
</div>
|
||||
|
||||
<p class="matrix__note">
|
||||
完成率 = 已签署 ÷ 发起;低于 80% 标红提示。下钻明细只读,需要查看文书原文请到「签署记录查询」。
|
||||
完成率 = 已签署 ÷ 发起;低于 80%
|
||||
标红提示。下钻明细只读,需要查看文书原文请到「签署记录查询」。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
buildReportAnalyticsQuery,
|
||||
getReportAnalyticsTasks,
|
||||
type ReportTaskRow,
|
||||
} from '@/api/workbench/report-analytics'
|
||||
|
||||
import {
|
||||
buildTrend,
|
||||
countByStatus,
|
||||
formatDuration,
|
||||
groupKeyOf,
|
||||
METHOD_LABELS,
|
||||
STATUS_LABELS,
|
||||
SUMMARY_DIMENSION_LABELS,
|
||||
@@ -18,6 +19,7 @@ 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 { reportFilter } from '../filter-store'
|
||||
import { useReportDataset } from '../use-report-dataset'
|
||||
|
||||
/**
|
||||
@@ -27,29 +29,28 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 汇总矩阵回答「问题出在谁身上」。挤在一张图里就哪个问题都回答不清楚。
|
||||
*/
|
||||
|
||||
const { tasks, window: dateWindow } = useReportDataset()
|
||||
|
||||
const counts = computed(() => countByStatus(tasks.value))
|
||||
const { dataset, window: dateWindow } = useReportDataset()
|
||||
const counts = computed(() => dataset.value?.statusCounts)
|
||||
|
||||
/**
|
||||
* 六个状态全列出(含处理失败)。
|
||||
* 只挑几个显示会让各块加起来对不上「累计发起」,看到的人会先怀疑统计错了。
|
||||
*/
|
||||
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?.total ?? 0, color: '#0e6e8c' },
|
||||
{ label: '已签署', value: counts.value?.signed ?? 0, color: '#0f9d6c' },
|
||||
{ label: '待签署', value: counts.value?.pending ?? 0, color: '#3a7bd5' },
|
||||
// 待科室确认:患者已签、等科室复核签章。取色与任务徽标(原型 .tg-cfm)一致,
|
||||
// 不沿用原型的 #7a4dbb —— 那个色在本仓库已经给了「签署中」,同色两块会看串
|
||||
{ label: '待科室确认', value: counts.value.confirming, color: '#c07f00' },
|
||||
{ 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' },
|
||||
{ label: '待科室确认', value: counts.value?.confirming ?? 0, color: '#c07f00' },
|
||||
{ label: '签署中', value: counts.value?.signing ?? 0, color: '#7a4dbb' },
|
||||
{ label: '已超时', value: counts.value?.expired ?? 0, color: '#d9821f' },
|
||||
{ label: '已作废', value: counts.value?.void ?? 0, color: '#d0534f' },
|
||||
{ label: '处理失败', value: counts.value?.failed ?? 0, color: '#b4475f' },
|
||||
])
|
||||
|
||||
const trendPoints = computed<ReportChartPoint[]>(() =>
|
||||
buildTrend(tasks.value, dateWindow.value).map((point) => ({
|
||||
(dataset.value?.trend ?? []).map((point) => ({
|
||||
label: point.label,
|
||||
values: [point.created, point.signed],
|
||||
})),
|
||||
@@ -61,6 +62,20 @@ const trendSeries = [
|
||||
]
|
||||
|
||||
const dimension = ref<ReportSummaryDimension>('department')
|
||||
const groupRows = computed(() => {
|
||||
const data = dataset.value
|
||||
if (!data) return []
|
||||
switch (dimension.value) {
|
||||
case 'category':
|
||||
return data.categories
|
||||
case 'method':
|
||||
return data.methods
|
||||
case 'campus':
|
||||
return data.campuses
|
||||
default:
|
||||
return data.departments
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 下钻状态拆成「是否展开」+「哪一组」两个值。
|
||||
@@ -70,37 +85,67 @@ const dimension = ref<ReportSummaryDimension>('department')
|
||||
*/
|
||||
const drillVisible = ref(false)
|
||||
const drillKey = ref<string | null>(null)
|
||||
const drillRows = ref<ReportTaskRow[]>([])
|
||||
const drillTotal = ref(0)
|
||||
const drillPage = ref(1)
|
||||
const drillLoading = ref(false)
|
||||
const drillError = ref('')
|
||||
const drillPageSize = 25
|
||||
|
||||
function handleDimensionChange(next: ReportSummaryDimension) {
|
||||
dimension.value = next
|
||||
drillVisible.value = false
|
||||
drillKey.value = null
|
||||
drillPage.value = 1
|
||||
}
|
||||
|
||||
async function loadDrill() {
|
||||
const window = dateWindow.value
|
||||
if (!window || !drillVisible.value) return
|
||||
drillLoading.value = true
|
||||
drillError.value = ''
|
||||
try {
|
||||
const query = buildReportAnalyticsQuery(reportFilter, window)
|
||||
const result = await getReportAnalyticsTasks({
|
||||
...query,
|
||||
page: drillPage.value,
|
||||
size: drillPageSize,
|
||||
...(drillKey.value === null
|
||||
? {}
|
||||
: { dimension: dimension.value, groupValue: drillKey.value }),
|
||||
})
|
||||
drillRows.value = result.records
|
||||
drillTotal.value = result.total
|
||||
} catch {
|
||||
drillError.value = '下钻明细加载失败,请重试'
|
||||
} finally {
|
||||
drillLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrill(key: string | null) {
|
||||
drillKey.value = key
|
||||
drillVisible.value = true
|
||||
drillPage.value = 1
|
||||
void loadDrill()
|
||||
}
|
||||
|
||||
const drillRows = computed<ReportTaskRow[]>(() => {
|
||||
if (drillKey.value === null) {
|
||||
return tasks.value
|
||||
}
|
||||
|
||||
return tasks.value.filter((task) => groupKeyOf(task, dimension.value) === drillKey.value)
|
||||
watch([dimension, drillPage, () => dataset.value?.generatedAt], () => {
|
||||
if (drillVisible.value) void loadDrill()
|
||||
})
|
||||
|
||||
const drillTitle = computed(() =>
|
||||
drillKey.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} 条`,
|
||||
const drillMeta = computed(
|
||||
() =>
|
||||
`${drillKey.value ? `${SUMMARY_DIMENSION_LABELS[dimension.value]} = ${drillKey.value}` : '当前筛选条件'} · 共 ${drillTotal.value} 条`,
|
||||
)
|
||||
|
||||
function durationText(row: ReportTaskRow) {
|
||||
return row.signMinutes === undefined ? '—' : formatDuration(row.signMinutes)
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(drillTotal.value / drillPageSize)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,13 +155,17 @@ function durationText(row: ReportTaskRow) {
|
||||
>
|
||||
<ReportStatTiles :items="tiles" />
|
||||
|
||||
<ReportCard title="每日发起与完成趋势" subtitle="发起按任务创建日统计,完成按签署完成日统计" class="gap-top">
|
||||
<ReportCard
|
||||
title="每日发起与完成趋势"
|
||||
subtitle="发起按任务创建日统计,完成按签署完成日统计"
|
||||
class="gap-top"
|
||||
>
|
||||
<ReportBarChart :points="trendPoints" :series="trendSeries" unit="单" />
|
||||
</ReportCard>
|
||||
|
||||
<ReportCard title="签署汇总矩阵" class="gap-top">
|
||||
<ReportSummaryMatrix
|
||||
:tasks="tasks"
|
||||
:rows="groupRows"
|
||||
:dimension="dimension"
|
||||
@update:dimension="handleDimensionChange"
|
||||
@drill="handleDrill"
|
||||
@@ -130,6 +179,7 @@ function durationText(row: ReportTaskRow) {
|
||||
<button type="button" class="link-button" @click="drillVisible = false">收起</button>
|
||||
</template>
|
||||
|
||||
<p v-if="drillError" class="drill-note">{{ drillError }}</p>
|
||||
<div class="drill-scroll">
|
||||
<table class="drill-table">
|
||||
<thead>
|
||||
@@ -145,12 +195,17 @@ function durationText(row: ReportTaskRow) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!drillRows.length">
|
||||
<tr v-if="drillLoading">
|
||||
<td colspan="8" class="drill-empty">正在读取明细…</td>
|
||||
</tr>
|
||||
<tr v-else-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>
|
||||
<b>{{ row.patientName }}</b>
|
||||
</td>
|
||||
<td>{{ row.documentName }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ METHOD_LABELS[row.method] }}</td>
|
||||
@@ -162,6 +217,20 @@ function durationText(row: ReportTaskRow) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="drillTotal > drillPageSize" class="drill-pagination">
|
||||
<button type="button" :disabled="drillPage <= 1 || drillLoading" @click="drillPage--">
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {{ drillPage }} / {{ totalPages }} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="drillPage >= totalPages || drillLoading"
|
||||
@click="drillPage++"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="drill-note">
|
||||
下钻明细只读,用于核对数字来源;需要查看文书原文请到「签署记录查询」。
|
||||
</p>
|
||||
@@ -237,4 +306,27 @@ function durationText(row: ReportTaskRow) {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.drill-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drill-pagination button {
|
||||
padding: 5px 10px;
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.drill-pagination button:disabled {
|
||||
color: #9aa8ae;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<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'
|
||||
@@ -16,19 +15,49 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 120 个患者,进而拿去做人群占比,结论就偏了。
|
||||
*/
|
||||
|
||||
const { tasks } = useReportDataset()
|
||||
|
||||
const structure = computed(() => buildPatientStructure(tasks.value))
|
||||
const { dataset } = useReportDataset()
|
||||
const structure = computed(() => dataset.value?.patientStructure)
|
||||
|
||||
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']),
|
||||
withColors(
|
||||
(structure.value?.age ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次`,
|
||||
})),
|
||||
['#18a0c0', '#0e6e8c', '#0f9d6c', '#7a4dbb', '#9aa8ae'],
|
||||
),
|
||||
)
|
||||
|
||||
const sexItems = computed(() => withColors(structure.value.sex, ['#3a7bd5', '#d4537e']))
|
||||
const total = computed(() => dataset.value?.statusCounts.total ?? 0)
|
||||
const sexItems = computed(() =>
|
||||
withColors(
|
||||
(structure.value?.sex ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次 · ${total.value ? Math.round((item.value / total.value) * 100) : 0}%`,
|
||||
})),
|
||||
['#3a7bd5', '#d4537e', '#9aa8ae'],
|
||||
),
|
||||
)
|
||||
const visitTypeItems = computed(() =>
|
||||
(structure.value?.visitType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次`,
|
||||
})),
|
||||
)
|
||||
const campusItems = computed(() =>
|
||||
(structure.value?.campus ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
display: `${item.value} 人次`,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,17 +77,17 @@ const sexItems = computed(() => withColors(structure.value.sex, ['#3a7bd5', '#d4
|
||||
|
||||
<div class="pat-row">
|
||||
<ReportCard title="就诊类型分布" subtitle="门诊 / 住院 / 体检 / 其他">
|
||||
<ReportBarList :items="structure.visitType" :label-width="70" />
|
||||
<ReportBarList :items="visitTypeItems" :label-width="70" />
|
||||
</ReportCard>
|
||||
|
||||
<ReportCard title="院区患者分布" subtitle="按患者所属院区统计">
|
||||
<ReportBarList :items="structure.campus" :label-width="100" />
|
||||
<ReportBarList :items="campusItems" :label-width="100" />
|
||||
</ReportCard>
|
||||
</div>
|
||||
|
||||
<p class="pat-note">
|
||||
四个维度统计的都是<b>人次</b>而非人数:同一患者在当前筛选条件下有多次签署任务会重复计数,
|
||||
因此各维度合计等于任务数,不等于去重后的患者数。需要按人去重请由后端提供患者维度聚合。
|
||||
因此各维度合计等于任务数,不等于去重后的患者数。本页仅返回聚合计数,不在浏览器传输患者明细。
|
||||
</p>
|
||||
</ReportPageShell>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
buildDailyAverageDuration,
|
||||
buildDurationByDepartment,
|
||||
buildOverdueTasks,
|
||||
buildTimeliness,
|
||||
formatDuration,
|
||||
OVERDUE_MINUTES,
|
||||
} from '../aggregate'
|
||||
import { axisLabelOf, formatDuration, OVERDUE_MINUTES } from '../aggregate'
|
||||
import ReportBarChart from '../components/ReportBarChart.vue'
|
||||
import ReportBarList from '../components/ReportBarList.vue'
|
||||
import ReportCard from '../components/ReportCard.vue'
|
||||
@@ -25,60 +18,77 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 指标显示「—」而不是 0,0 会被读成「表现极差」。
|
||||
*/
|
||||
|
||||
const { tasks, window: dateWindow } = useReportDataset()
|
||||
const { dataset } = useReportDataset()
|
||||
|
||||
const metrics = computed(() => buildTimeliness(tasks.value))
|
||||
const metrics = computed(() => dataset.value?.timeliness)
|
||||
|
||||
const kpis = computed(() => [
|
||||
{
|
||||
label: '平均签署时长',
|
||||
value:
|
||||
metrics.value.averageMinutes === null ? null : formatDuration(metrics.value.averageMinutes),
|
||||
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}%`,
|
||||
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}%`,
|
||||
value: metrics.value?.overdueRate == null ? null : `${metrics.value.overdueRate}%`,
|
||||
icon: 'alert' as const,
|
||||
color: '#d9821f',
|
||||
softColor: 'var(--warn-l)',
|
||||
},
|
||||
{
|
||||
label: '超时未签任务',
|
||||
value: metrics.value.expiredTaskCount,
|
||||
value: metrics.value?.expiredTaskCount ?? 0,
|
||||
icon: 'warning' as const,
|
||||
color: '#d0534f',
|
||||
softColor: 'var(--err-l)',
|
||||
},
|
||||
])
|
||||
|
||||
const departmentDuration = computed(() => buildDurationByDepartment(tasks.value))
|
||||
const departmentDuration = computed(() =>
|
||||
(metrics.value?.departmentAverages ?? []).map((item) => ({
|
||||
...item,
|
||||
display: formatDuration(item.value),
|
||||
color:
|
||||
item.value > OVERDUE_MINUTES
|
||||
? '#d0534f'
|
||||
: item.value > OVERDUE_MINUTES / 2
|
||||
? '#d9821f'
|
||||
: undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const dailyDurationPoints = computed<ReportChartPoint[]>(() =>
|
||||
buildDailyAverageDuration(tasks.value, dateWindow.value).map((item) => ({
|
||||
label: item.label,
|
||||
values: [item.value],
|
||||
(metrics.value?.dailyAverages ?? []).map((item) => ({
|
||||
label: axisLabelOf(item.date),
|
||||
values: [item.averageMinutes],
|
||||
})),
|
||||
)
|
||||
|
||||
const dailyDurationSeries = [{ label: '平均签署时长', color: '#18a0c0' }]
|
||||
|
||||
const overdueTasks = computed(() => buildOverdueTasks(tasks.value))
|
||||
const overdueTasks = computed(() => metrics.value?.overdueTasks ?? [])
|
||||
|
||||
const sampleNote = computed(() =>
|
||||
metrics.value.signedSampleCount
|
||||
metrics.value?.signedSampleCount
|
||||
? `基于 ${metrics.value.signedSampleCount} 条已签署任务`
|
||||
: '当前条件下没有已签署任务',
|
||||
)
|
||||
|
||||
const overdueNote = computed(() => {
|
||||
const total = metrics.value?.overdueTaskCount ?? 0
|
||||
const shown = overdueTasks.value.length
|
||||
return total > shown ? ` · 展示用时最长的 ${shown} 条,共 ${total} 条` : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,18 +103,17 @@ const sampleNote = computed(() =>
|
||||
<ReportBarList :items="departmentDuration" />
|
||||
</ReportCard>
|
||||
|
||||
<ReportCard title="每日平均签署时长" :subtitle="`近 ${dailyDurationPoints.length} 天完成任务均值(分钟)`">
|
||||
<ReportBarChart
|
||||
:points="dailyDurationPoints"
|
||||
:series="dailyDurationSeries"
|
||||
unit="分钟"
|
||||
/>
|
||||
<ReportCard
|
||||
title="每日平均签署时长"
|
||||
:subtitle="`近 ${dailyDurationPoints.length} 天完成任务均值(分钟)`"
|
||||
>
|
||||
<ReportBarChart :points="dailyDurationPoints" :series="dailyDurationSeries" unit="分钟" />
|
||||
</ReportCard>
|
||||
</div>
|
||||
|
||||
<ReportCard
|
||||
title="超期完成任务"
|
||||
:subtitle="`完成用时超过 48 小时链接有效期(${OVERDUE_MINUTES / 60} 小时)的已签署任务 · ${sampleNote}`"
|
||||
:subtitle="`完成用时超过 48 小时链接有效期(${OVERDUE_MINUTES / 60} 小时)的已签署任务 · ${sampleNote}${overdueNote}`"
|
||||
class="gap-top"
|
||||
>
|
||||
<div class="overdue-scroll">
|
||||
@@ -126,12 +135,16 @@ const sampleNote = computed(() =>
|
||||
</tr>
|
||||
<tr v-for="row in overdueTasks" :key="row.id">
|
||||
<td class="mono">{{ row.id }}</td>
|
||||
<td><b>{{ row.patientName }}</b></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>
|
||||
<td>
|
||||
<span class="overdue-tag">{{ formatDuration(row.signMinutes) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,91 +1,111 @@
|
||||
/**
|
||||
* 报表分析的数据装载与筛选:四个菜单共用同一个数据集与同一份筛选条件。
|
||||
*
|
||||
* 数据集放在模块级而不是每个页面各取一次:四个菜单的数据源完全一样,
|
||||
* 各取一次会生成四份互不相干的演示数据 —— 从「签署总览」切到「签署量分析」,
|
||||
* 累计发起数会变,看起来像统计口径不同,其实只是随机数不同。
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
getReportAnalyticsDataset,
|
||||
buildReportAnalyticsQuery,
|
||||
getReportAnalytics,
|
||||
getReportOptions,
|
||||
type ReportAnalyticsDataset,
|
||||
type ReportTaskRow,
|
||||
type ReportOptions,
|
||||
} from '@/api/workbench/report-analytics'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
|
||||
import { filterReportTasks, resolveDateWindow } from './aggregate'
|
||||
import { resolveDateWindow } from './aggregate'
|
||||
import { reportFilter } from './filter-store'
|
||||
|
||||
const dataset = ref<ReportAnalyticsDataset | null>(null)
|
||||
const options = ref<ReportOptions | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const optionsError = 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 = ''
|
||||
let requestSequence = 0
|
||||
let pendingOptions: Promise<void> | null = null
|
||||
let filterWatcherInstalled = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function loadReportOptions(): Promise<void> {
|
||||
if (options.value) return Promise.resolve()
|
||||
pendingOptions ??= (async () => {
|
||||
optionsError.value = ''
|
||||
try {
|
||||
dataset.value = await getReportAnalyticsDataset()
|
||||
} catch {
|
||||
errorMessage.value = '报表数据加载失败,请稍后重试'
|
||||
options.value = await getReportOptions()
|
||||
} catch (error) {
|
||||
optionsError.value = toUserMessage(error, '报表筛选项加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
pending = null
|
||||
pendingOptions = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pending
|
||||
return pendingOptions
|
||||
}
|
||||
|
||||
export async function loadReportDataset(): Promise<void> {
|
||||
const sequence = ++requestSequence
|
||||
dataset.value = null
|
||||
const dateWindow = resolveDateWindow(reportFilter)
|
||||
if (!dateWindow) {
|
||||
errorMessage.value = ''
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const query = buildReportAnalyticsQuery(reportFilter, dateWindow)
|
||||
const result = await getReportAnalytics(query)
|
||||
if (sequence === requestSequence) dataset.value = result
|
||||
} catch (error) {
|
||||
if (sequence === requestSequence) errorMessage.value = toUserMessage(error, '报表数据加载失败')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function installFilterWatcher() {
|
||||
if (filterWatcherInstalled) return
|
||||
filterWatcherInstalled = true
|
||||
watch(
|
||||
() => [
|
||||
reportFilter.dateRange,
|
||||
reportFilter.customFrom,
|
||||
reportFilter.customTo,
|
||||
reportFilter.campus,
|
||||
reportFilter.department,
|
||||
reportFilter.category,
|
||||
reportFilter.method,
|
||||
],
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => void loadReportDataset(), 250)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 仅供演示 / 验证脚本:清掉缓存后重新装载 */
|
||||
export function resetReportDataset(): void {
|
||||
dataset.value = null
|
||||
pending = null
|
||||
requestSequence++
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
export function useReportDataset() {
|
||||
/** 当前筛选对应的时间窗口;null 表示自定义区间还没填完 */
|
||||
installFilterWatcher()
|
||||
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 available = computed(() => dataset.value !== null)
|
||||
const generatedAt = computed(() => dataset.value?.generatedAt ?? '')
|
||||
|
||||
/**
|
||||
* 自定义区间只填了一头时为 true。
|
||||
* 这种半截条件既不该按半截筛,也不该悄悄套默认窗口,页面据此提示用户补全。
|
||||
*/
|
||||
const customRangeIncomplete = computed(
|
||||
() =>
|
||||
reportFilter.dateRange === 'custom' && (!reportFilter.customFrom || !reportFilter.customTo),
|
||||
)
|
||||
|
||||
return {
|
||||
allTasks,
|
||||
tasks,
|
||||
dataset,
|
||||
options,
|
||||
window,
|
||||
available,
|
||||
generatedAt,
|
||||
loading,
|
||||
errorMessage,
|
||||
optionsError,
|
||||
customRangeIncomplete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { buildRanking, METHOD_LABELS } from '../aggregate'
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
import { METHOD_LABELS } from '../aggregate'
|
||||
import ReportBarList from '../components/ReportBarList.vue'
|
||||
import ReportCard from '../components/ReportCard.vue'
|
||||
import ReportPageShell from '../components/ReportPageShell.vue'
|
||||
@@ -15,9 +17,7 @@ import { useReportDataset } from '../use-report-dataset'
|
||||
* 「签署量」问的是真的签掉了多少,把待签署、已作废一起算进来会让排行失去意义。
|
||||
*/
|
||||
|
||||
const { tasks } = useReportDataset()
|
||||
|
||||
const signedTasks = computed(() => tasks.value.filter((task) => task.status === 'signed'))
|
||||
const { dataset } = useReportDataset()
|
||||
|
||||
/** 按原型的分色口径给榜单配色:同一个榜单里颜色只是区分项,不表达好坏 */
|
||||
function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
|
||||
@@ -25,33 +25,44 @@ function withColors(items: ReportBarItem[], colors: string[]): ReportBarItem[] {
|
||||
}
|
||||
|
||||
const byDepartment = computed(() =>
|
||||
buildRanking(signedTasks.value, (task) => task.department || '其他'),
|
||||
(dataset.value?.departments ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({ label: row.key, value: row.signed })),
|
||||
)
|
||||
|
||||
const byMethod = computed(() =>
|
||||
withColors(
|
||||
buildRanking(signedTasks.value, (task) => METHOD_LABELS[task.method]),
|
||||
(dataset.value?.methods ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({
|
||||
label: METHOD_LABELS[row.key as SigningMethod] ?? row.key,
|
||||
value: row.signed,
|
||||
})),
|
||||
['#0e6e8c', '#18a0c0', '#7a4dbb'],
|
||||
),
|
||||
)
|
||||
|
||||
const byCategory = computed(() =>
|
||||
withColors(
|
||||
buildRanking(signedTasks.value, (task) => task.category || '其他'),
|
||||
(dataset.value?.categories ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({ label: row.key, value: row.signed })),
|
||||
['#0f9d6c', '#d9821f', '#0e6e8c'],
|
||||
),
|
||||
)
|
||||
|
||||
const topDocuments = computed(() =>
|
||||
withColors(
|
||||
buildRanking(signedTasks.value, (task) => task.documentName, 10),
|
||||
(dataset.value?.documents ?? [])
|
||||
.filter((row) => row.signed > 0)
|
||||
.map((row) => ({ label: row.key, value: row.signed })),
|
||||
['#18a0c0', '#0e6e8c', '#0f9d6c'],
|
||||
),
|
||||
)
|
||||
|
||||
const summary = computed(() => {
|
||||
const total = tasks.value.length
|
||||
const signed = signedTasks.value.length
|
||||
const total = dataset.value?.statusCounts.total ?? 0
|
||||
const signed = dataset.value?.statusCounts.signed ?? 0
|
||||
|
||||
return `已签署 ${signed} 单,占当前筛选 ${total} 单的 ${total ? Math.round((signed / total) * 100) : 0}%`
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user