feat(签署记录): 新增签署记录查询页并复用签署任务接口
This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* 签署记录查询(工作台 · 签署记录查询)。
|
||||
*
|
||||
* 这里回答的是「历史签署记录」,与签署工作台那份「当前待办」不是同一个视角:
|
||||
* 工作台按当天操作组织,记录页按条件检索全量历史,因此两边各有一份演示数据 ——
|
||||
* 工作台的种子固定在某一天,直接拿它当历史记录,会让「今日 / 近 3 天」这些筛选
|
||||
* 在演示里长期空着,看不出筛选到底有没有生效。两份种子的字段口径完全一致
|
||||
* (都是 SigningRecord),真实模式下读的也是同一个接口,所以不存在两套业务语义。
|
||||
*
|
||||
* 真实模式复用已有的签署任务列表接口(GET /v1/sign-tasks):签署记录就是签署任务的
|
||||
* 历史视图,后端目前没有单独的记录检索接口,前端不凭空造一个不存在的地址。
|
||||
* 已知差异:后端该接口不支持按「文档类别」筛选,记录页会在真实模式下禁用这一项
|
||||
* (见 SigningRecordFilterBar 的 categoryEnabled),不做静默忽略。
|
||||
*/
|
||||
|
||||
import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature'
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import { buildFieldSpanHtml } from '@/utils/signing-fields'
|
||||
|
||||
import { getSigningTaskDetail, getSigningTaskEvents, getSigningTasks } from './signing'
|
||||
import type {
|
||||
PatientSex,
|
||||
SigningApiOptions,
|
||||
SigningFieldKind,
|
||||
SigningMethod,
|
||||
SigningRecord,
|
||||
SigningRecordListResponse,
|
||||
SigningRecordQuery,
|
||||
SigningTaskEvent,
|
||||
SigningTaskQuery,
|
||||
SigningTaskRecord,
|
||||
SigningTaskStatus,
|
||||
VisitType,
|
||||
WorkbenchCampus,
|
||||
} from './types'
|
||||
|
||||
const useMockData = mockFlags.signRecords
|
||||
|
||||
export const isSigningRecordsMockEnabled = useMockData
|
||||
|
||||
/** 签署记录的演示数据量上限:导出时逐页取回,避免一条筛选条件意外拉回整个库 */
|
||||
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)),
|
||||
]
|
||||
|
||||
/**
|
||||
* 演示正文。
|
||||
*
|
||||
* 记录页的「查看」要能打开当时的文书,所以演示记录必须带正文快照。
|
||||
* 正文由文书定义现场生成(含结构化占位符,签名位与签署日期都能按记录取值回显),
|
||||
* 不是渲染时兜底拼出来的假纸样 —— 那种兜底只在正文真的取不到时才允许出现。
|
||||
*
|
||||
* 惰性构建:buildFieldSpanHtml 依赖 DOM,模块在 Node 侧被 import 时不该直接抛错。
|
||||
*/
|
||||
let mockDocumentHtmlCache: Map<string, string> | null = null
|
||||
|
||||
function buildMockDocumentHtml(document: MockDocumentDef): string {
|
||||
const field = (id: string, label: string, kind: SigningFieldKind = 'text', required = false) =>
|
||||
buildFieldSpanHtml({ id, label, kind, required, options: [] })
|
||||
|
||||
return [
|
||||
`<h2>${document.name}</h2>`,
|
||||
'<h3>一、患者与就诊信息</h3>',
|
||||
'<table><tbody>',
|
||||
`<tr><td>患者姓名</td><td>${field('patient', '患者姓名', 'text', true)}</td>`,
|
||||
`<td>性别</td><td>${field('sex', '性别')}</td></tr>`,
|
||||
`<tr><td>年龄</td><td>${field('age', '年龄')}</td>`,
|
||||
`<td>住院号/门诊号</td><td>${field('visit', '就诊号')}</td></tr>`,
|
||||
'</tbody></table>',
|
||||
'<h3>二、风险告知与注意事项</h3>',
|
||||
`<ol>${document.items.map((item) => `<li>${item}</li>`).join('')}</ol>`,
|
||||
'<p>医务人员已就上述诊疗方案、医疗风险、替代方案及可能出现的不良后果向患者本人(或授权委托人)充分说明,患者已完全理解并表示同意。</p>',
|
||||
'<h3>三、签字确认</h3>',
|
||||
'<table><tbody>',
|
||||
`<tr><td>患者(或授权委托人)签名:${field('signature', '患者签名', 'signature', true)}</td>`,
|
||||
`<td>签署日期:${field('signedDate', '签署日期', 'date', true)}</td></tr>`,
|
||||
`<tr><td>经治医师签名:${field('doctor', '医师签名', 'signature')}</td><td></td></tr>`,
|
||||
'</tbody></table>',
|
||||
'<p>注:本知情同意书采用电子方式签署,签名图片与文书原件一并留存,可随时查验。</p>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
function getMockDocumentHtml(documentId: string): string {
|
||||
mockDocumentHtmlCache ??= new Map(
|
||||
MOCK_DOCUMENTS.map((document) => [document.id, buildMockDocumentHtml(document)]),
|
||||
)
|
||||
|
||||
return mockDocumentHtmlCache.get(documentId) ?? ''
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
演示数据:记录种子
|
||||
========================================================================= */
|
||||
|
||||
/**
|
||||
* 一条演示记录的种子。
|
||||
*
|
||||
* 只写「人 + 事」:患者 ID、就诊号、院区、失效时间这些都能由这几项派生出来
|
||||
* (见 buildMockRecord),逐条手写既冗长又容易写错。
|
||||
* `days` 是距今天数(0 = 今天),演示数据因此始终落在「今日 / 近 3 天 / 近 7 天」窗口内,
|
||||
* 不会像写死日期那样,过几天就全部落到筛选范围之外。
|
||||
*/
|
||||
interface MockRecordSeed {
|
||||
patient: string
|
||||
sex: PatientSex
|
||||
age: number
|
||||
visitType: VisitType
|
||||
/** MOCK_DOCUMENTS 的 id */
|
||||
doc: string
|
||||
status: SigningTaskStatus
|
||||
method: SigningMethod
|
||||
days: number
|
||||
/** 发起时刻 HH:mm */
|
||||
at: string
|
||||
/** 签署用时(分钟):只有已签署的记录才有 */
|
||||
minutes?: number
|
||||
source?: string
|
||||
voidReason?: string
|
||||
}
|
||||
|
||||
const MOCK_RECORD_SEEDS: MockRecordSeed[] = [
|
||||
// 今天
|
||||
{ patient: '赵美英', sex: '女', age: 58, visitType: '住院', doc: 'mock-doc-cardiac', status: 'signed', method: 'pad', days: 0, at: '09:12', minutes: 7 },
|
||||
{ patient: '李淑芬', sex: '女', age: 67, visitType: '门诊', doc: 'mock-doc-mri', status: 'signed', method: 'sms', days: 0, at: '10:05', minutes: 4 },
|
||||
{ patient: '周雅琴', sex: '女', age: 29, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'pending', method: 'sms', days: 0, at: '11:20' },
|
||||
// 昨天
|
||||
{ patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-narcotic', status: 'signed', method: 'pad', days: 1, at: '08:40', minutes: 6, source: '工作台发起' },
|
||||
{ patient: '王建国', sex: '男', age: 71, visitType: '住院', doc: 'mock-doc-blood', status: 'signed', method: 'sms', days: 1, at: '14:15', minutes: 9 },
|
||||
{ patient: '陈志强', sex: '男', age: 61, visitType: '住院', doc: 'mock-doc-admission', status: 'void', method: 'pad', days: 1, at: '16:02', source: '工作台发起', voidReason: '文书选择错误,已重新发起' },
|
||||
// 近 3 天
|
||||
{ patient: '吴桂芳', sex: '女', age: 73, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'pad', days: 2, at: '09:35', minutes: 5 },
|
||||
{ patient: '刘伟', sex: '男', age: 45, visitType: '门诊', doc: 'mock-doc-cardiac', status: 'signed', method: 'sms', days: 2, at: '15:48', minutes: 3, source: '工作台发起' },
|
||||
{ patient: '郑海燕', sex: '女', age: 38, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'expired', method: 'sms', days: 3, at: '10:22' },
|
||||
{ patient: '黄志明', sex: '男', age: 66, visitType: '住院', doc: 'mock-doc-mri', status: 'signed', method: 'pad', days: 3, at: '13:07', minutes: 8 },
|
||||
// 近 7 天
|
||||
{ patient: '徐丽华', sex: '女', age: 50, visitType: '住院', doc: 'mock-doc-blood', status: 'signed', method: 'pad', days: 4, at: '11:40', minutes: 6 },
|
||||
{ patient: '马晓东', sex: '男', age: 33, visitType: '其他', doc: 'mock-doc-emergency', status: 'failed', method: 'pad', days: 5, at: '20:18', source: '工作台发起' },
|
||||
{ patient: '何秀英', sex: '女', age: 79, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'sms', days: 6, at: '09:05', minutes: 12 },
|
||||
{ patient: '林俊杰', sex: '男', age: 41, visitType: '门诊', doc: 'mock-doc-narcotic', status: 'signing', method: 'pad', days: 6, at: '14:52' },
|
||||
// 近 30 天
|
||||
{ patient: '罗美玲', sex: '女', age: 62, visitType: '门诊', doc: 'mock-doc-admission', status: 'expired', method: 'sms', days: 11, at: '17:30' },
|
||||
{ patient: '高文斌', sex: '男', age: 57, visitType: '住院', doc: 'mock-doc-cardiac', status: 'signed', method: 'pad', days: 14, at: '10:10', minutes: 7 },
|
||||
{ patient: '邓小燕', sex: '女', age: 36, visitType: '门诊', doc: 'mock-doc-mri', status: 'void', method: 'sms', days: 19, at: '08:55', voidReason: '患者拒绝签署,任务作废' },
|
||||
{ patient: '苏国华', sex: '男', age: 68, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'signed', method: 'pad', days: 26, at: '15:25', minutes: 6 },
|
||||
]
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||
date.getHours(),
|
||||
)}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 演示数据的就诊号前缀:与医院里门诊 / 住院 / 体检的编号习惯一致 */
|
||||
const VISIT_NO_PREFIX: Record<VisitType, string> = {
|
||||
门诊: 'MZ',
|
||||
住院: 'ZY',
|
||||
体检: 'TJ',
|
||||
其他: 'JZ',
|
||||
}
|
||||
|
||||
function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord {
|
||||
const document = MOCK_DOCUMENT_BY_ID.get(seed.doc)
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`演示签署记录引用了不存在的文书:${seed.doc}`)
|
||||
}
|
||||
|
||||
const createdAtDate = new Date()
|
||||
createdAtDate.setDate(createdAtDate.getDate() - seed.days)
|
||||
const [hour, minute] = seed.at.split(':').map(Number)
|
||||
createdAtDate.setHours(hour, minute, 0, 0)
|
||||
|
||||
const createdAt = formatDateTime(createdAtDate)
|
||||
const signedDate = new Date(createdAtDate)
|
||||
signedDate.setMinutes(signedDate.getMinutes() + (seed.minutes ?? 0))
|
||||
const signedAt = seed.status === 'signed' ? formatDateTime(signedDate) : undefined
|
||||
|
||||
// 已超时的记录按 24 小时失效,其余按 48 小时;失效时间只做展示,不参与筛选
|
||||
const expireHours = seed.status === 'expired' ? 24 : 48
|
||||
const expiresAtDate = new Date(createdAtDate)
|
||||
expiresAtDate.setHours(expiresAtDate.getHours() + expireHours)
|
||||
|
||||
const dateKey = `${createdAtDate.getFullYear()}${pad(createdAtDate.getMonth() + 1)}${pad(
|
||||
createdAtDate.getDate(),
|
||||
)}`
|
||||
|
||||
return {
|
||||
id: `SR${dateKey}${pad(index + 1)}`,
|
||||
templateVersionId: `${seed.doc}-v1`,
|
||||
campus: MOCK_DEPARTMENT_CAMPUS[document.department] ?? '本部院区',
|
||||
patientId: `P2026${10001 + index}`,
|
||||
patientName: seed.patient,
|
||||
sex: seed.sex,
|
||||
age: seed.age,
|
||||
visitNo: `${VISIT_NO_PREFIX[seed.visitType]}${dateKey}${pad(index + 1)}`,
|
||||
visitType: seed.visitType,
|
||||
visitDate: `${createdAtDate.getFullYear()}-${pad(createdAtDate.getMonth() + 1)}-${pad(
|
||||
createdAtDate.getDate(),
|
||||
)}`,
|
||||
documentId: document.id,
|
||||
documentName: document.name,
|
||||
category: document.category,
|
||||
department: document.department,
|
||||
signerName: seed.patient,
|
||||
method: seed.method,
|
||||
status: seed.status,
|
||||
source: seed.source ?? '电子病历',
|
||||
createdAt,
|
||||
updatedAt: signedAt ?? createdAt,
|
||||
signedAt,
|
||||
expiresAt: formatDateTime(expiresAtDate),
|
||||
voidReason: seed.voidReason,
|
||||
// 已签署的记录带上签名图,否则「列表说已签署、打开没有签名」自相矛盾
|
||||
signatureDataUrl: seed.status === 'signed' ? DEMO_SIGNATURE_DATA_URL : undefined,
|
||||
documentContentHtml: getMockDocumentHtml(document.id),
|
||||
}
|
||||
}
|
||||
|
||||
/** 演示记录:种子派生一次,进程内复用(只读,不需要落盘) */
|
||||
let mockRecordsCache: SigningRecord[] | null = null
|
||||
|
||||
function getMockRecords(): SigningRecord[] {
|
||||
mockRecordsCache ??= MOCK_RECORD_SEEDS.map(buildMockRecord)
|
||||
return mockRecordsCache
|
||||
}
|
||||
|
||||
function cloneRecord(record: SigningRecord): SigningRecord {
|
||||
return {
|
||||
...record,
|
||||
selectedItems: record.selectedItems ? [...record.selectedItems] : record.selectedItems,
|
||||
fieldAnswers: record.fieldAnswers ? { ...record.fieldAnswers } : record.fieldAnswers,
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
查询
|
||||
========================================================================= */
|
||||
|
||||
function parseRecordTime(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value.replace(' ', 'T'))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
/** 时间窗口:`to` 为开区间上界(不含),只有「今日 / 昨日」需要 */
|
||||
function resolveDateWindow(range: SigningRecordQuery['dateRange']): {
|
||||
from: Date
|
||||
to: Date
|
||||
} | null {
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const tomorrowStart = new Date(todayStart)
|
||||
tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
||||
|
||||
const daysBack = (days: number) => {
|
||||
const from = new Date(todayStart)
|
||||
from.setDate(from.getDate() - days)
|
||||
return from
|
||||
}
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
return { from: todayStart, to: tomorrowStart }
|
||||
case 'yesterday':
|
||||
return { from: daysBack(1), to: todayStart }
|
||||
case '3d':
|
||||
return { from: daysBack(2), to: tomorrowStart }
|
||||
case '7d':
|
||||
return { from: daysBack(6), to: tomorrowStart }
|
||||
case '30d':
|
||||
return { from: daysBack(29), to: tomorrowStart }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录查询条件 → 签署任务查询条件。
|
||||
* 签署记录就是签署任务的历史视图,真实模式直接复用工作台的列表接口,
|
||||
* 因此这里只做字段映射;后端不支持的条件(文档类别)在页面层已禁用,不会走到这里。
|
||||
*/
|
||||
function toTaskQuery(query: SigningRecordQuery): SigningTaskQuery {
|
||||
return {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
dateRange: query.dateRange,
|
||||
campus: query.campus,
|
||||
method: query.method,
|
||||
department: query.department,
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(task: SigningTaskRecord): SigningRecord {
|
||||
// 任务接口不返回文档类别,这里如实留空,由页面沿用列表里带出的那一份
|
||||
return { ...task, category: '' }
|
||||
}
|
||||
|
||||
export async function getSigningRecords(
|
||||
query: SigningRecordQuery,
|
||||
options: SigningApiOptions = {},
|
||||
): Promise<SigningRecordListResponse> {
|
||||
if (!useMockData) {
|
||||
const page = await getSigningTasks(toTaskQuery(query), options)
|
||||
|
||||
return {
|
||||
records: page.records.map(toRecord),
|
||||
total: page.total,
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
const window = resolveDateWindow(query.dateRange)
|
||||
const keyword = query.keyword?.trim().toLowerCase()
|
||||
const filtered = getMockRecords().filter((record) => {
|
||||
if (window) {
|
||||
const createdAt = parseRecordTime(record.createdAt ?? record.updatedAt)
|
||||
if (!createdAt || createdAt < window.from || createdAt >= window.to) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (query.status && query.status !== 'all' && record.status !== query.status) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (query.campus && query.campus !== 'all' && record.campus !== query.campus) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (query.method && query.method !== 'all' && record.method !== query.method) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (query.department && record.department !== query.department) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (query.category && record.category !== query.category) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
const haystack = [
|
||||
record.id,
|
||||
record.patientName,
|
||||
record.patientId,
|
||||
record.visitNo,
|
||||
record.documentName,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
if (!haystack.includes(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const sorted = filtered.sort(
|
||||
(left, right) =>
|
||||
(parseRecordTime(right.createdAt)?.getTime() ?? 0) -
|
||||
(parseRecordTime(left.createdAt)?.getTime() ?? 0),
|
||||
)
|
||||
const page = Math.max(query.page, 1)
|
||||
const pageSize = Math.max(query.pageSize, 1)
|
||||
const start = (page - 1) * pageSize
|
||||
|
||||
return {
|
||||
records: sorted.slice(start, start + pageSize).map(cloneRecord),
|
||||
total: sorted.length,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐页取回当前筛选条件下的全部记录,供导出使用。
|
||||
* 单次查询上限见 MAX_PAGES:宁可在演示/导出时截断并提示,也不要一条筛选条件
|
||||
* 意外把整个库拉进浏览器内存。
|
||||
*/
|
||||
export async function getAllSigningRecords(
|
||||
query: SigningRecordQuery,
|
||||
options: SigningApiOptions = {},
|
||||
): Promise<SigningRecord[]> {
|
||||
const firstPage = await getSigningRecords(
|
||||
{ ...query, page: 1, pageSize: BULK_PAGE_SIZE },
|
||||
options,
|
||||
)
|
||||
const pageCount = Math.min(
|
||||
Math.max(Math.ceil(firstPage.total / firstPage.pageSize), 1),
|
||||
MAX_PAGES,
|
||||
)
|
||||
|
||||
if (pageCount === 1) {
|
||||
return firstPage.records
|
||||
}
|
||||
|
||||
const rest = await Promise.all(
|
||||
Array.from({ length: pageCount - 1 }, (_, index) =>
|
||||
getSigningRecords({ ...query, page: index + 2, pageSize: BULK_PAGE_SIZE }, options),
|
||||
),
|
||||
)
|
||||
|
||||
return [firstPage, ...rest].flatMap((page) => page.records)
|
||||
}
|
||||
|
||||
/** 记录详情:真实模式按任务接口取回正文快照与固化信息 */
|
||||
export async function getSigningRecordDetail(
|
||||
id: string,
|
||||
options: SigningApiOptions = {},
|
||||
): Promise<SigningRecord | null> {
|
||||
if (!useMockData) {
|
||||
const task = await getSigningTaskDetail(id, options)
|
||||
return task ? toRecord(task) : null
|
||||
}
|
||||
|
||||
const record = getMockRecords().find((item) => item.id === id)
|
||||
return record ? cloneRecord(record) : null
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
操作留痕
|
||||
========================================================================= */
|
||||
|
||||
/**
|
||||
* 演示留痕:按记录状态推导一条时间线。
|
||||
*
|
||||
* 与签署工作台的演示留痕同口径(创建 → 发送 → 签署 / 作废 → 结束),
|
||||
* 但时间点一律取记录自身固化的时间,不掺"现在"—— 历史记录的操作时间
|
||||
* 如果每次刷新都在变,就没法用来核对签署链路了。
|
||||
*/
|
||||
function buildMockEvents(record: SigningRecord): SigningTaskEvent[] {
|
||||
const channel = record.method === 'pad' ? '手写板' : '短信'
|
||||
const createdAt = record.createdAt ?? record.updatedAt
|
||||
const events: SigningTaskEvent[] = [
|
||||
{
|
||||
id: `${record.id}-created`,
|
||||
time: createdAt,
|
||||
text: `张文静创建签署任务(${channel})`,
|
||||
eventType: 'TASK_CREATED',
|
||||
channel: record.method === 'pad' ? 'PAD' : 'SMS',
|
||||
},
|
||||
]
|
||||
|
||||
if (record.method === 'sms') {
|
||||
events.push({
|
||||
id: `${record.id}-sent`,
|
||||
time: createdAt,
|
||||
text: `已向 ${record.patientName} 预留手机号发送签署短信`,
|
||||
eventType: 'SEND_PREPARED',
|
||||
channel: 'SMS',
|
||||
attemptNo: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (record.status === 'signing') {
|
||||
events.push({
|
||||
id: `${record.id}-signing`,
|
||||
time: record.updatedAt,
|
||||
text: '已打开签名采集,等待患者完成签名',
|
||||
eventType: 'SIGNING_STARTED',
|
||||
channel: 'PAD',
|
||||
})
|
||||
}
|
||||
|
||||
if (record.signedAt) {
|
||||
events.push({
|
||||
id: `${record.id}-signed`,
|
||||
time: record.signedAt,
|
||||
text: `患者 ${record.signerName} 完成签名,系统已合成文书并双留存`,
|
||||
eventType: 'SIGNED',
|
||||
channel: record.method === 'pad' ? 'PAD' : 'SMS',
|
||||
})
|
||||
}
|
||||
|
||||
if (record.status === 'expired') {
|
||||
events.push({
|
||||
id: `${record.id}-expired`,
|
||||
time: record.expiresAt,
|
||||
text: '签署链接已过期,任务自动结束(可重新发起)',
|
||||
eventType: 'EXPIRED',
|
||||
})
|
||||
}
|
||||
|
||||
if (record.status === 'failed') {
|
||||
events.push({
|
||||
id: `${record.id}-failed`,
|
||||
time: record.updatedAt,
|
||||
text: '签名合成失败,请核对签名图与文书模板后重新发起',
|
||||
eventType: 'FAILED',
|
||||
reason: '签名合成失败',
|
||||
})
|
||||
}
|
||||
|
||||
if (record.status === 'void') {
|
||||
events.push({
|
||||
id: `${record.id}-voided`,
|
||||
time: record.updatedAt,
|
||||
text: `任务已作废${record.voidReason ? `,原因:${record.voidReason}` : ''}`,
|
||||
eventType: 'VOIDED',
|
||||
reason: record.voidReason,
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
export async function getSigningRecordEvents(id: string): Promise<SigningTaskEvent[]> {
|
||||
if (!useMockData) {
|
||||
return getSigningTaskEvents(id)
|
||||
}
|
||||
|
||||
const record = getMockRecords().find((item) => item.id === id)
|
||||
return record ? buildMockEvents(record) : []
|
||||
}
|
||||
@@ -366,6 +366,8 @@ function getRangeStart(range: SigningTaskQuery['dateRange']) {
|
||||
date.setDate(date.getDate() - 2)
|
||||
} else if (range === '7d') {
|
||||
date.setDate(date.getDate() - 6)
|
||||
} else if (range === '30d') {
|
||||
date.setDate(date.getDate() - 29)
|
||||
}
|
||||
|
||||
return date
|
||||
@@ -740,6 +742,8 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
|
||||
start.setDate(start.getDate() - 2)
|
||||
} else if (query.dateRange === '7d') {
|
||||
start.setDate(start.getDate() - 6)
|
||||
} else if (query.dateRange === '30d') {
|
||||
start.setDate(start.getDate() - 29)
|
||||
}
|
||||
|
||||
if (query.dateRange && query.dateRange !== 'all') {
|
||||
|
||||
@@ -171,7 +171,12 @@ export interface SigningTaskQuery extends PageQuery {
|
||||
visitType?: VisitType | 'all'
|
||||
}
|
||||
|
||||
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
||||
/**
|
||||
* 时间范围筛选。
|
||||
* `30d` 只被「签署记录查询」用到(历史记录跨度比工作台大),
|
||||
* 但两侧共用同一个联合类型与同一套区间换算,避免各写一份后口径漂移。
|
||||
*/
|
||||
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | '30d' | 'all'
|
||||
|
||||
/**
|
||||
* 文书填写项的控件类型。
|
||||
@@ -256,6 +261,34 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
||||
|
||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||
|
||||
/**
|
||||
* 签署记录:签署任务的历史全量视图。
|
||||
*
|
||||
* 字段沿用签署任务(同一份数据的另一种视角),额外带出「文档类别」:
|
||||
* 历史记录可能引用已经停用甚至归档的模板版本,类别必须随记录一起固化下来,
|
||||
* 不能靠当前文库反查——模板一下架,历史记录就会丢掉自己的分类。
|
||||
*/
|
||||
export interface SigningRecord extends SigningTaskRecord {
|
||||
category: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 签署记录查询条件。
|
||||
* 与工作台查询重叠但不等价:工作台按「当天待办」组织,这里按历史检索组织,
|
||||
* 因此用「文档类别」替代了工作台的「具体文档」,关键词也扩到任务号。
|
||||
*/
|
||||
export interface SigningRecordQuery extends PageQuery {
|
||||
keyword?: string
|
||||
status?: SigningTaskStatus | 'all'
|
||||
dateRange?: SigningDateRange
|
||||
campus?: WorkbenchCampus | 'all'
|
||||
method?: SigningMethod | 'all'
|
||||
department?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export type SigningRecordListResponse = PageResult<SigningRecord>
|
||||
|
||||
/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */
|
||||
export interface CreateSigningTaskInput {
|
||||
campus: WorkbenchCampus
|
||||
|
||||
Reference in New Issue
Block a user