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

This commit is contained in:
yelan
2026-09-24 21:03:39 +08:00
parent abe7e17582
commit 78402859fd
26 changed files with 3618 additions and 193 deletions
@@ -0,0 +1,165 @@
/**
* 演示数据目录:文书 / 科室 / 院区。
*
* 为什么单独抽一份:签署记录查询与报表分析都要按「文书 → 科室 / 类别」、
* 「科室 → 院区」做归类统计。两处各写一份目录,改一个文书名就会让记录页与
* 报表页的口径对不上 —— 这种漂移不会报错,只会让数字悄悄对不齐。所以目录
* 只在这里维护一份,两边都从这里取。
*
* 记录与报表引用的是「签署当时那一版」文书,可能与当前文库不同(模板停用、
* 改版、归档都会让历史指向一份现在查不到的文书),因此目录自带文书名与类别,
* 不靠当前文库反查。
*/
import type { WorkbenchCampus } from './types'
export interface MockDocumentDef {
id: string
name: string
category: string
department: string
/** 该文书的风险告知条款(演示正文用) */
items: string[]
}
export const mockDocumentCatalog: MockDocumentDef[] = [
{
id: 'mock-doc-cardiac',
name: '心脏介入手术知情同意书',
category: '知情同意书',
department: '心内科',
items: [
'术中、术后可能发生心律失常、心肌缺血甚至心肌梗死。',
'穿刺部位可能出现血肿、假性动脉瘤或动静脉瘘。',
'可能发生造影剂过敏反应,严重者可出现过敏性休克。',
'存在麻醉意外、血管破裂、血栓形成与栓塞等风险。',
'术后需按医嘱卧床、服药并按时复诊,擅自停药可能危及生命。',
],
},
{
id: 'mock-doc-mri',
name: 'MRI 磁共振检查知情同意书',
category: '知情同意书',
department: '医学影像科',
items: [
'体内有心脏起搏器、金属植入物、动脉瘤夹者不得进入检查室。',
'检查中机器噪声较大,请配合佩戴耳塞并保持体位不动。',
'使用对比剂时可能出现恶心、皮疹等不良反应,极少数可发生严重过敏。',
'危重患者、不能配合者可能无法完成检查或需改期。',
'检查结果由诊断医师出具,最终以正式报告为准。',
],
},
{
id: 'mock-doc-endoscopy',
name: '内镜检查知情同意书',
category: '知情同意书',
department: '消化内科',
items: [
'检查过程中可能出现咽喉不适、恶心、腹胀或轻度腹痛。',
'极少数情况下可能发生出血、穿孔等并发症,需进一步处理。',
'麻醉或镇静下检查存在呼吸抑制、误吸等风险。',
'检查中可能根据所见情况取活检或行息肉切除。',
'若检查前准备不充分,可能影响观察效果或需要重新检查。',
],
},
{
id: 'mock-doc-narcotic',
name: '麻醉药品、精神药品知情同意书',
category: '知情同意书',
department: '麻醉科·精神科',
items: [
'麻精药品具有成瘾性(依赖性)风险,须严格按照医嘱使用。',
'不得套开、转借或转售麻精药品,转售涉嫌毒品犯罪。',
'需按医嘱定期复诊,长期使用者至少每 3 个月复诊一次。',
'过期、未使用的药品及废贴、空安瓿应交回医疗机构销毁。',
'可能出现恶心、呕吐、便秘、头晕、嗜睡、呼吸抑制等不良反应。',
],
},
{
id: 'mock-doc-surgery',
name: '手术麻醉风险告知书',
category: '风险告知书',
department: '普外科',
items: [
'麻醉过程中可能出现呼吸循环波动、药物过敏或困难气道。',
'术中可能因病情变化扩大手术范围或改变手术方式。',
'术后可能发生出血、感染、切口愈合不良或深静脉血栓。',
'合并基础疾病者发生并发症的风险相应升高。',
'术后需配合镇痛、早期活动与康复训练。',
],
},
{
id: 'mock-doc-blood',
name: '输血治疗风险告知书',
category: '风险告知书',
department: '血液科',
items: [
'可能出现发热、寒战、皮疹等输血反应。',
'极少数情况下可能发生溶血反应或输血相关急性肺损伤。',
'虽经严格筛查,仍存在窗口期病原体传播的极小概率。',
'大量输血可能引起凝血功能异常、电解质紊乱。',
'输血过程中请勿擅自离开病区,出现不适请立即告知医护人员。',
],
},
{
id: 'mock-doc-admission',
name: '住院须知及告知书',
category: '入院告知',
department: '医务处',
items: [
'住院期间请遵守病区管理要求,不得擅自离院或外宿。',
'请如实告知既往病史、过敏史与用药情况。',
'贵重物品请自行妥善保管,避免带入大量现金。',
'医疗费用按医保政策与医院规定结算,欠费可能影响治疗安排。',
'诊疗方案可能根据病情变化调整,调整前会与患者或家属沟通。',
],
},
{
id: 'mock-doc-emergency',
name: '入院告知书(急诊留观)',
category: '入院告知',
department: '急诊科',
items: [
'留观期间病情可能随时变化,需配合医护人员密切观察。',
'留观不等同于住院,如需住院将另行办理手续。',
'部分检查结果出具需要时间,请耐心等待并保持联系方式畅通。',
'如病情加重需转入抢救室或重症监护病房,将另行告知。',
'离院需经医师评估同意,擅自离院可能延误诊治。',
],
},
]
/** 科室所属院区:演示数据按科室归属派生院区,不必逐条重复写一遍 */
export const mockDepartmentCampus: Record<string, WorkbenchCampus> = {
心内科: '本部院区',
消化内科: '本部院区',
'麻醉科·精神科': '本部院区',
医务处: '本部院区',
医学影像科: '东院区',
急诊科: '东院区',
普外科: '西院区',
血液科: '西院区',
}
export const mockDocumentById = new Map(
mockDocumentCatalog.map((document) => [document.id, document]),
)
/** 演示科室清单(筛选下拉用):只列演示数据里出现过的科室,选了必定有结果 */
export const mockCatalogDepartments: string[] = [
...new Set(mockDocumentCatalog.map((document) => document.department)),
]
/** 演示文档类别清单(筛选下拉用) */
export const mockCatalogCategories: string[] = [
...new Set(mockDocumentCatalog.map((document) => document.category)),
]
/**
* 按科室取院区。
* 演示数据按科室归属派生院区,不必逐条重复写一遍;取不到时回落到本部院区,
* 免得漏配一条映射就让某个科室在报表里变成「无院区」。
*/
export function campusOfDepartment(department: string): WorkbenchCampus {
return mockDepartmentCampus[department] ?? '本部院区'
}
+10 -153
View File
@@ -17,6 +17,12 @@ import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature'
import { mockFlags } from '@/utils/mock-flags'
import { buildFieldSpanHtml } from '@/utils/signing-fields'
import {
campusOfDepartment,
mockDocumentById,
mockDocumentCatalog,
type MockDocumentDef,
} from './mock-catalog'
import { getSigningTaskDetail, getSigningTaskEvents, getSigningTasks } from './signing'
import type {
PatientSex,
@@ -31,7 +37,6 @@ import type {
SigningTaskRecord,
SigningTaskStatus,
VisitType,
WorkbenchCampus,
} from './types'
const useMockData = mockFlags.signRecords
@@ -43,154 +48,6 @@ const MAX_PAGES = 25
/** 导出/批量取回时单页条数 */
const BULK_PAGE_SIZE = 200
/* =========================================================================
演示数据:文书目录
========================================================================= */
/**
* 演示文书目录。
*
* 记录引用的是「签署当时那一版」文书,可能与当前文库不同(模板停用、改版、归档都会
* 让历史记录指向一份现在查不到的文书),所以记录自带文书名与类别,不靠当前文库反查。
*/
interface MockDocumentDef {
id: string
name: string
category: string
department: string
/** 该文书的风险告知条款(演示正文用) */
items: string[]
}
const MOCK_DOCUMENTS: MockDocumentDef[] = [
{
id: 'mock-doc-cardiac',
name: '心脏介入手术知情同意书',
category: '知情同意书',
department: '心内科',
items: [
'术中、术后可能发生心律失常、心肌缺血甚至心肌梗死。',
'穿刺部位可能出现血肿、假性动脉瘤或动静脉瘘。',
'可能发生造影剂过敏反应,严重者可出现过敏性休克。',
'存在麻醉意外、血管破裂、血栓形成与栓塞等风险。',
'术后需按医嘱卧床、服药并按时复诊,擅自停药可能危及生命。',
],
},
{
id: 'mock-doc-mri',
name: 'MRI 磁共振检查知情同意书',
category: '知情同意书',
department: '医学影像科',
items: [
'体内有心脏起搏器、金属植入物、动脉瘤夹者不得进入检查室。',
'检查中机器噪声较大,请配合佩戴耳塞并保持体位不动。',
'使用对比剂时可能出现恶心、皮疹等不良反应,极少数可发生严重过敏。',
'危重患者、不能配合者可能无法完成检查或需改期。',
'检查结果由诊断医师出具,最终以正式报告为准。',
],
},
{
id: 'mock-doc-endoscopy',
name: '内镜检查知情同意书',
category: '知情同意书',
department: '消化内科',
items: [
'检查过程中可能出现咽喉不适、恶心、腹胀或轻度腹痛。',
'极少数情况下可能发生出血、穿孔等并发症,需进一步处理。',
'麻醉或镇静下检查存在呼吸抑制、误吸等风险。',
'检查中可能根据所见情况取活检或行息肉切除。',
'若检查前准备不充分,可能影响观察效果或需要重新检查。',
],
},
{
id: 'mock-doc-narcotic',
name: '麻醉药品、精神药品知情同意书',
category: '知情同意书',
department: '麻醉科·精神科',
items: [
'麻精药品具有成瘾性(依赖性)风险,须严格按照医嘱使用。',
'不得套开、转借或转售麻精药品,转售涉嫌毒品犯罪。',
'需按医嘱定期复诊,长期使用者至少每 3 个月复诊一次。',
'过期、未使用的药品及废贴、空安瓿应交回医疗机构销毁。',
'可能出现恶心、呕吐、便秘、头晕、嗜睡、呼吸抑制等不良反应。',
],
},
{
id: 'mock-doc-surgery',
name: '手术麻醉风险告知书',
category: '风险告知书',
department: '普外科',
items: [
'麻醉过程中可能出现呼吸循环波动、药物过敏或困难气道。',
'术中可能因病情变化扩大手术范围或改变手术方式。',
'术后可能发生出血、感染、切口愈合不良或深静脉血栓。',
'合并基础疾病者发生并发症的风险相应升高。',
'术后需配合镇痛、早期活动与康复训练。',
],
},
{
id: 'mock-doc-blood',
name: '输血治疗风险告知书',
category: '风险告知书',
department: '血液科',
items: [
'可能出现发热、寒战、皮疹等输血反应。',
'极少数情况下可能发生溶血反应或输血相关急性肺损伤。',
'虽经严格筛查,仍存在窗口期病原体传播的极小概率。',
'大量输血可能引起凝血功能异常、电解质紊乱。',
'输血过程中请勿擅自离开病区,出现不适请立即告知医护人员。',
],
},
{
id: 'mock-doc-admission',
name: '住院须知及告知书',
category: '入院告知',
department: '医务处',
items: [
'住院期间请遵守病区管理要求,不得擅自离院或外宿。',
'请如实告知既往病史、过敏史与用药情况。',
'贵重物品请自行妥善保管,避免带入大量现金。',
'医疗费用按医保政策与医院规定结算,欠费可能影响治疗安排。',
'诊疗方案可能根据病情变化调整,调整前会与患者或家属沟通。',
],
},
{
id: 'mock-doc-emergency',
name: '入院告知书(急诊留观)',
category: '入院告知',
department: '急诊科',
items: [
'留观期间病情可能随时变化,需配合医护人员密切观察。',
'留观不等同于住院,如需住院将另行办理手续。',
'部分检查结果出具需要时间,请耐心等待并保持联系方式畅通。',
'如病情加重需转入抢救室或重症监护病房,将另行告知。',
'离院需经医师评估同意,擅自离院可能延误诊治。',
],
},
]
/** 科室所属院区:演示数据按科室归属派生院区,不必逐条重复写一遍 */
const MOCK_DEPARTMENT_CAMPUS: Record<string, WorkbenchCampus> = {
心内科: '本部院区',
消化内科: '本部院区',
'麻醉科·精神科': '本部院区',
医务处: '本部院区',
医学影像科: '东院区',
急诊科: '东院区',
普外科: '西院区',
血液科: '西院区',
}
const MOCK_DOCUMENT_BY_ID = new Map(MOCK_DOCUMENTS.map((document) => [document.id, document]))
/** 演示科室清单(筛选下拉用):只列演示数据里出现过的科室,选了必定有结果 */
export const mockRecordDepartments: string[] = [...new Set(MOCK_DOCUMENTS.map((d) => d.department))]
/** 演示文档类别清单(筛选下拉用) */
export const mockRecordCategories: string[] = [
...new Set(MOCK_DOCUMENTS.map((document) => document.category)),
]
/**
* 演示正文。
*
@@ -230,7 +87,7 @@ function buildMockDocumentHtml(document: MockDocumentDef): string {
function getMockDocumentHtml(documentId: string): string {
mockDocumentHtmlCache ??= new Map(
MOCK_DOCUMENTS.map((document) => [document.id, buildMockDocumentHtml(document)]),
mockDocumentCatalog.map((document) => [document.id, buildMockDocumentHtml(document)]),
)
return mockDocumentHtmlCache.get(documentId) ?? ''
@@ -253,7 +110,7 @@ interface MockRecordSeed {
sex: PatientSex
age: number
visitType: VisitType
/** MOCK_DOCUMENTS 的 id */
/** mockDocumentCatalog 的 id */
doc: string
status: SigningTaskStatus
method: SigningMethod
@@ -311,7 +168,7 @@ const VISIT_NO_PREFIX: Record<VisitType, string> = {
}
function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord {
const document = MOCK_DOCUMENT_BY_ID.get(seed.doc)
const document = mockDocumentById.get(seed.doc)
if (!document) {
throw new Error(`演示签署记录引用了不存在的文书:${seed.doc}`)
@@ -339,7 +196,7 @@ function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord {
return {
id: `SR${dateKey}${pad(index + 1)}`,
templateVersionId: `${seed.doc}-v1`,
campus: MOCK_DEPARTMENT_CAMPUS[document.department] ?? '本部院区',
campus: campusOfDepartment(document.department),
patientId: `P2026${10001 + index}`,
patientName: seed.patient,
sex: seed.sex,
@@ -0,0 +1,294 @@
/**
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)的数据源。
*
* 现状:后端没有任何分析聚合接口,所以这四个菜单目前只有演示数据。
* 这里刻意**返回任务级明细**而不是预先算好的聚合结果,原因是演示阶段聚合逻辑
* 要能被页面与验证脚本反复调用(换筛选条件、下钻、导出都基于同一份明细)。
*
* ⚠️ 真实接入时不要照搬这个形状:一次分析查询不该把全量任务明细拉到浏览器里算。
* 后端应按页面给出四个聚合接口(总览指标 + 趋势、签署量分组、时效指标 + 分组、
* 患者结构分组),届时这个模块退化成「按查询参数取聚合结果」,
* 页面侧的聚合函数随之删掉即可 —— 页面只消费聚合结果,不关心它从哪来。
*
* 演示数据的两条硬约束(都踩过):
* 1. **确定性**:用固定种子的 PRNG,不用 Math.random。否则每次刷新数字都在跳,
* 截图对不上、验证脚本没法断言「筛选后行数应该变少」。
* 2. **锚定「距今天数」**:任务的日期按「N 天前」生成,不写死具体日期。
* 写死日期后过几天再打开,近 7 天 / 近 14 天 / 本月 全是空的,
* 演示时看起来像筛选坏了。
*/
import { mockFlags } from '@/utils/mock-flags'
import { mockDocumentCatalog, campusOfDepartment } from './mock-catalog'
import type {
PatientSex,
SigningMethod,
SigningTaskStatus,
VisitType,
WorkbenchCampus,
} from './types'
const useMockData = mockFlags.reportAnalytics
export const isReportAnalyticsMockEnabled = useMockData
/**
* 演示数据覆盖的天数。
* 比最长的时间筛选(本月按 1 号起算,最多 31 天)略短,够用且不至于让
* 「本月」看起来总是差几天;真要展示整月,把这里调到 31 即可。
*/
const DEMO_SPAN_DAYS = 30
/** 演示任务量级:约 5 条/天,够让趋势图与排行榜有形状,又不会让表格卡顿 */
const DEMO_TASK_COUNT = 152
/** 一条报表任务行:聚合所需的全部维度都在这里,页面不再回查其它数据源 */
export interface ReportTaskRow {
id: string
patientId: string
patientName: string
sex: PatientSex
age: number
visitType: VisitType
visitNo: string
campus: WorkbenchCampus
department: string
documentName: string
category: string
method: SigningMethod
status: SigningTaskStatus
/** 发起时间,`YYYY-MM-DD HH:mm`(与签署记录同一格式,便于直接比较与展示) */
createdAt: string
/** 签署完成时间;未完成的任务没有这一项 */
signedAt?: string
/** 签署用时(分钟);只有已签署的任务有。超 1440 为超 24 小时,超 2880 为超期完成 */
signMinutes?: number
}
export interface ReportAnalyticsDataset {
tasks: ReportTaskRow[]
/**
* 数据是否可用。真实模式下没有后端接口,这里返回 false,
* 页面据此显示「后端尚未提供分析接口」的说明,而不是渲染一张全 0 的假报表。
*/
available: boolean
/** 数据生成时刻(`YYYY-MM-DD HH:mm`),页面用于「数据截至」提示 */
generatedAt: string
}
/* =========================================================================
确定性随机
========================================================================= */
/** mulberry32:小、快、固定种子可复现 */
function createRandom(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function pick<T>(random: () => number, items: readonly T[]): T {
return items[Math.floor(random() * items.length)] as T
}
function intBetween(random: () => number, min: number, max: number): number {
return min + Math.floor(random() * (max - min + 1))
}
/* =========================================================================
演示患者池
========================================================================= */
interface DemoPatient {
name: string
sex: PatientSex
age: number
visitType: VisitType
}
/**
* 患者池。
*
* 含 3 名未成年患者:儿科知情同意同样由监护人签署,报表统计的是患者本人年龄。
* 未成年占比刻意保持在三成以下(3/24),与「综合医院里儿科签署只是小部分」的实际相符。
*
* ⚠️ **不要把「每个年龄段在任意时间窗内都非空」当作验收标准**:未成年任务只有十几条,
* 近 7 天窗口里恰好一条都没有是正常的(实测 2026-09-24 当天就是这样)。
* 年龄段分档的正确性由分档边界(17/18、40/41、60/61)来保证,不是由「每档都非空」。
* 为了让窄窗口好看而硬塞样本,会让年龄段分布失去真实形状。
*/
const DEMO_PATIENTS: DemoPatient[] = [
{ name: '周雅琴', sex: '女', age: 29, visitType: '门诊' },
{ name: '李淑芬', sex: '女', age: 67, visitType: '住院' },
{ name: '赵美英', sex: '女', age: 58, visitType: '住院' },
{ name: '陈志强', sex: '男', age: 61, visitType: '住院' },
{ name: '王建国', sex: '男', age: 71, visitType: '住院' },
{ name: '孙浩然', sex: '男', age: 54, visitType: '门诊' },
{ name: '刘伟', sex: '男', age: 45, visitType: '门诊' },
{ name: '吴桂芳', sex: '女', age: 73, visitType: '住院' },
{ name: '黄志明', sex: '男', age: 66, visitType: '门诊' },
{ name: '郑海燕', sex: '女', age: 38, visitType: '门诊' },
{ name: '徐丽华', sex: '女', age: 50, visitType: '体检' },
{ name: '马晓东', sex: '男', age: 42, visitType: '其他' },
{ name: '林俊杰', sex: '男', age: 34, visitType: '体检' },
{ name: '何秀英', sex: '女', age: 62, visitType: '住院' },
{ name: '邓小燕', sex: '女', age: 27, visitType: '门诊' },
{ name: '曹明辉', sex: '男', age: 57, visitType: '住院' },
{ name: '袁丽', sex: '女', age: 19, visitType: '门诊' },
{ name: '沈国强', sex: '男', age: 78, visitType: '住院' },
{ name: '崔文静', sex: '女', age: 46, visitType: '体检' },
{ name: '侯志远', sex: '男', age: 36, visitType: '门诊' },
{ name: '龚雨萱', sex: '女', age: 6, visitType: '门诊' },
{ name: '范子墨', sex: '男', age: 14, visitType: '住院' },
{ name: '柳思彤', sex: '女', age: 16, visitType: '门诊' },
{ name: '严志刚', sex: '男', age: 52, visitType: '其他' },
]
/* =========================================================================
演示任务生成
========================================================================= */
/** 状态分布:已签署占多数,其余各留一点,六种状态在总览统计块里都看得到 */
const STATUS_WEIGHTS: { status: SigningTaskStatus; weight: number }[] = [
{ status: 'signed', weight: 68 },
{ status: 'pending', weight: 9 },
{ status: 'signing', weight: 5 },
{ status: 'expired', weight: 8 },
{ status: 'void', weight: 5 },
{ status: 'failed', weight: 5 },
]
const STATUS_TOTAL_WEIGHT = STATUS_WEIGHTS.reduce((sum, item) => sum + item.weight, 0)
function pickStatus(random: () => number): SigningTaskStatus {
let roll = random() * STATUS_TOTAL_WEIGHT
for (const item of STATUS_WEIGHTS) {
roll -= item.weight
if (roll <= 0) {
return item.status
}
}
return 'signed'
}
function pad(value: number): string {
return String(value).padStart(2, '0')
}
function formatMoment(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
* 签署用时(分钟)。
*
* 分三段构造,保证「24 小时内完成率」与「超期完成」两张表都拿得到样本:
* 大部分在 3~120 分钟,一部分落在 24 小时附近,少数超过 48 小时有效期。
*/
function buildSignMinutes(random: () => number): number {
const roll = random()
if (roll < 0.82) {
return intBetween(random, 3, 720)
}
if (roll < 0.94) {
return intBetween(random, 720, 2880)
}
return intBetween(random, 2881, 6600)
}
function buildTasks(): ReportTaskRow[] {
const random = createRandom(20260924)
const rows: ReportTaskRow[] = []
const today = new Date()
for (let index = 0; index < DEMO_TASK_COUNT; index += 1) {
const patient = pick(random, DEMO_PATIENTS)
const document = pick(random, mockDocumentCatalog)
const status = pickStatus(random)
const method: SigningMethod = random() < 0.45 ? 'pad' : 'sms'
// 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假
const daysAgo = Math.floor(DEMO_SPAN_DAYS * Math.pow(random(), 1.35))
const createdAt = new Date(today)
createdAt.setDate(createdAt.getDate() - daysAgo)
createdAt.setHours(intBetween(random, 8, 19), intBetween(random, 0, 59), 0, 0)
const isSigned = status === 'signed'
const signMinutes = isSigned ? buildSignMinutes(random) : undefined
const row: ReportTaskRow = {
id: `RT${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`,
patientId: `P${202610000 + (DEMO_PATIENTS.indexOf(patient) + 1)}`,
patientName: patient.name,
sex: patient.sex,
age: patient.age,
visitType: patient.visitType,
visitNo: `${patient.visitType === '住院' ? 'ZY' : patient.visitType === '体检' ? 'TJ' : 'MZ'}${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`,
campus: campusOfDepartment(document.department),
department: document.department,
documentName: document.name,
category: document.category,
method,
status,
createdAt: formatMoment(createdAt),
}
if (signMinutes !== undefined) {
const signedAt = new Date(createdAt.getTime() + signMinutes * 60_000)
row.signMinutes = signMinutes
row.signedAt = formatMoment(signedAt)
}
rows.push(row)
}
// 按发起时间倒序:页面与导出的默认顺序都跟这里一致,不必各自再排一次
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
}
/* =========================================================================
对外接口
========================================================================= */
let cachedTasks: ReportTaskRow[] | null = null
/** 演示数据只生成一次:确定性生成也要避免每次进页面都重跑一遍 */
function readTasks(): ReportTaskRow[] {
cachedTasks ??= buildTasks()
return cachedTasks
}
/**
* 取报表分析数据集。
*
* 真实模式下后端没有对应接口,返回 `available: false` 而不抛错:
* 报表页拿到空数据时的正确表现是「说明原因」,不是弹一个红色报错 ——
* 后端没上线不是用户的错,也不该看起来像页面坏了。
*/
export async function getReportAnalyticsDataset(): Promise<ReportAnalyticsDataset> {
if (!useMockData) {
return { tasks: [], available: false, generatedAt: formatMoment(new Date()) }
}
return {
tasks: readTasks(),
available: true,
generatedAt: formatMoment(new Date()),
}
}