feat(reports): 接入真实报表分析及分组明细与异步导出

This commit is contained in:
xy
2026-09-30 18:27:26 +08:00
parent e5f0a5ec97
commit f155c5154f
14 changed files with 928 additions and 982 deletions
-3
View File
@@ -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
View File
@@ -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 使用同一份答案。
+379 -280
View File
@@ -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)
}
-10
View File
@@ -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),
/**
+17 -427
View File
@@ -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>
+125 -33
View File
@@ -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,
}
}
+21 -10
View File
@@ -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}%`
})