feat(签署记录): 新增签署记录查询页并复用签署任务接口

This commit is contained in:
yelan
2026-09-24 19:52:28 +08:00
parent 53c6f05969
commit c4718a3912
17 changed files with 2401 additions and 28 deletions
+675
View File
@@ -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') {
+34 -1
View File
@@ -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