Files
xh-medical-sign-web/clinical-web/src/api/workbench/records.ts
T

533 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 签署记录查询(工作台 · 签署记录查询)。
*
* 这里回答的是「历史签署记录」,与签署工作台那份「当前待办」不是同一个视角:
* 工作台按当天操作组织,记录页按条件检索全量历史,因此两边各有一份演示数据 ——
* 工作台的种子固定在某一天,直接拿它当历史记录,会让「今日 / 近 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 {
campusOfDepartment,
mockDocumentById,
mockDocumentCatalog,
type MockDocumentDef,
} from './mock-catalog'
import { getSigningTaskDetail, getSigningTaskEvents, getSigningTasks } from './signing'
import type {
PatientSex,
SigningApiOptions,
SigningFieldKind,
SigningMethod,
SigningRecord,
SigningRecordListResponse,
SigningRecordQuery,
SigningTaskEvent,
SigningTaskQuery,
SigningTaskRecord,
SigningTaskStatus,
VisitType,
} from './types'
const useMockData = mockFlags.signRecords
export const isSigningRecordsMockEnabled = useMockData
/** 签署记录的演示数据量上限:导出时逐页取回,避免一条筛选条件意外拉回整个库 */
const MAX_PAGES = 25
/** 导出/批量取回时单页条数 */
const BULK_PAGE_SIZE = 200
/**
* 演示正文。
*
* 记录页的「查看」要能打开当时的文书,所以演示记录必须带正文快照。
* 正文由文书定义现场生成(含结构化占位符,签名位与签署日期都能按记录取值回显),
* 不是渲染时兜底拼出来的假纸样 —— 那种兜底只在正文真的取不到时才允许出现。
*
* 惰性构建: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(
mockDocumentCatalog.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
/** mockDocumentCatalog 的 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 = mockDocumentById.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: campusOfDepartment(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) : []
}