/** * 签署记录查询(工作台 · 签署记录查询)。 * * 这里回答的是「历史签署记录」,与签署工作台那份「当前待办」不是同一个视角: * 工作台按当天操作组织,记录页按条件检索全量历史,因此两边各有一份演示数据 —— * 工作台的种子固定在某一天,直接拿它当历史记录,会让「今日 / 近 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 { formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method' 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 | 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 [ `

${document.name}

`, '

一、患者与就诊信息

', '', ``, ``, ``, ``, '
患者姓名${field('patient', '患者姓名', 'text', true)}性别${field('sex', '性别')}
年龄${field('age', '年龄')}住院号/门诊号${field('visit', '就诊号')}
', '

二、风险告知与注意事项

', `
    ${document.items.map((item) => `
  1. ${item}
  2. `).join('')}
`, '

医务人员已就上述诊疗方案、医疗风险、替代方案及可能出现的不良后果向患者本人(或授权委托人)充分说明,患者已完全理解并表示同意。

', '

三、签字确认

', '', ``, ``, ``, '
患者(或授权委托人)签名:${field('signature', '患者签名', 'signature', true)}签署日期:${field('signedDate', '签署日期', 'date', true)}
经治医师签名:${field('doctor', '医师签名', 'signature')}
', '

注:本知情同意书采用电子方式签署,签名图片与文书原件一并留存,可随时查验。

', ].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 /** 患者签署后是否需要科室复核签章(原型 needConfirm) */ needConfirm?: boolean } 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: 52, visitType: '住院', doc: 'mock-doc-admission', status: 'pending', method: 'ipad', days: 0, at: '09:05', source: '工作台发起' }, { patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'pending', method: 'wx', days: 0, at: '08:50' }, // 「待科室确认」样本:患者已签完,等科室复核签章(原型 T0818006 那种) { patient: '王秀兰', sex: '女', age: 63, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'confirming', method: 'sms', days: 0, at: '10:40', minutes: 5, needConfirm: true }, // 昨天 { 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: '文书选择错误,已重新发起' }, { patient: '罗文静', sex: '女', age: 44, visitType: '住院', doc: 'mock-doc-mri', status: 'signed', method: 'ipad', days: 1, at: '11:35', minutes: 5 }, // 近 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: 47, visitType: '门诊', doc: 'mock-doc-blood', status: 'confirming', method: 'pad', days: 2, at: '16:20', minutes: 6, needConfirm: true }, { 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' }, { patient: '梁俊豪', sex: '男', age: 35, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'wx', days: 6, at: '10:28', minutes: 4 }, // 近 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 = { 门诊: '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 hasSigned = seed.status === 'signed' || seed.status === 'confirming' const signedAt = hasSigned ? 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, needConfirm: seed.needConfirm ?? false, source: seed.source ?? '电子病历', createdAt, updatedAt: signedAt ?? createdAt, signedAt, expiresAt: formatDateTime(expiresAtDate), voidReason: seed.voidReason, // 已签署的记录带上签名图,否则「列表说已签署、打开没有签名」自相矛盾 signatureDataUrl: hasSigned ? 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 { 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 { 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 { 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 backendChannel = toBackendSigningMethod(record.method) // 线上方式(短信 / 公众号)才有一条「已发送」留痕,现场方式是设备上直接采集 const isOnline = record.method === 'sms' || record.method === 'wx' const createdAt = record.createdAt ?? record.updatedAt const events: SigningTaskEvent[] = [ { id: `${record.id}-created`, time: createdAt, text: `张文静创建签署任务(${formatSigningMethod(record.method)})`, eventType: 'TASK_CREATED', channel: backendChannel, }, ] if (isOnline) { events.push({ id: `${record.id}-sent`, time: createdAt, text: record.method === 'wx' ? `已向 ${record.patientName} 推送公众号签署通知` : `已向 ${record.patientName} 预留手机号发送签署短信`, eventType: 'SEND_PREPARED', channel: backendChannel, attemptNo: 1, }) } if (record.status === 'signing') { events.push({ id: `${record.id}-signing`, time: record.updatedAt, text: '已打开签名采集,等待患者完成签名', eventType: 'SIGNING_STARTED', channel: backendChannel, }) } if (record.signedAt) { events.push({ id: `${record.id}-signed`, time: record.signedAt, text: `患者 ${record.signerName} 完成签名,系统已合成文书并双留存`, eventType: 'SIGNED', channel: backendChannel, }) } if (record.status === 'expired') { events.push({ id: `${record.id}-expired`, time: record.expiresAt, text: '签署链接已过期,任务自动结束(可重新发起)', eventType: 'EXPIRED', }) } // 需科室确认的记录:签完先入科室确认队列,科室签章后才归档(与工作台留痕同一口径) if (record.needConfirm && record.signedAt) { events.push({ id: `${record.id}-confirm-queued`, time: record.signedAt, text: `已推送至${record.department}确认队列,等待科室复核签章`, eventType: 'CONFIRM_QUEUED', channel: backendChannel, }) if (record.status === 'signed') { events.push({ id: `${record.id}-confirmed`, time: record.updatedAt, text: '科室复核签章完成,文档归档至本次诊疗记录', eventType: 'CONFIRMED', channel: backendChannel, }) } } 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 { if (!useMockData) { return getSigningTaskEvents(id) } const record = getMockRecords().find((item) => item.id === id) return record ? buildMockEvents(record) : [] }