feat(signing): 统一演示文书库并支持下载签名原图

This commit is contained in:
yelan
2026-09-16 17:15:10 +08:00
parent 4556515c17
commit 2279f7a624
6 changed files with 381 additions and 106 deletions
+74 -1
View File
@@ -4,6 +4,7 @@ import { unwrapApiResponse } from '@/utils/api-response'
import { mockFlags } from '@/utils/mock-flags'
import { request } from '@/utils/request'
import { getMockSigningTask } from './signing'
import type {
ApiPageResponse,
ApiResponseOf,
@@ -70,9 +71,71 @@ function normalizeArtifact(dto: SignArtifactResponseDto): SigningArtifact {
export const isArtifactMockEnabled = useMockData
/**
* 演示签名原图产物的 id 前缀。
* 演示环境没有文件服务,下载时靠 id 反查任务里的签名图,
* 保证「签名原图」在演示里也是真能下载到的文件,而不是占位提示。
*/
const MOCK_SIGNATURE_PREFIX = 'mock-signature-'
function estimateDataUrlBytes(dataUrl: string) {
const payload = dataUrl.split(',')[1]
if (!payload) {
return 0
}
// base64 每 4 个字符还原 3 字节,末尾可能有填充,这里取近似值用于展示
return Math.floor((payload.replace(/=+$/, '').length * 3) / 4)
}
/** 演示产物:已签署且有签名图时,给出可下载的签名原图 */
function buildMockArtifacts(taskId: string): SigningArtifact[] {
const task = getMockSigningTask(taskId)
if (!task || task.status !== 'signed' || !task.signatureDataUrl) {
return []
}
return [
{
id: `${MOCK_SIGNATURE_PREFIX}${task.id}`,
taskId: task.id,
pipelineId: `mock-pipeline-${task.id}`,
artifactType: 'SIGNATURE_IMAGE',
label: artifactLabels.SIGNATURE_IMAGE ?? '签名原图',
fileName: '签名原图.png',
mimeType: 'image/png',
sizeBytes: estimateDataUrlBytes(task.signatureDataUrl),
sha256: '',
metadata: {},
createdAt: formatDateTime(task.signedAt ?? task.updatedAt),
},
]
}
/** 把签名图 dataURL 还原成可下载的二进制 Blob */
function dataUrlToBlob(dataUrl: string): Blob {
const [meta, payload] = dataUrl.split(',')
const mimeType = /data:([^;]+)/.exec(meta ?? '')?.[1] ?? 'application/octet-stream'
if (!payload) {
return new Blob([], { type: mimeType })
}
const binary = window.atob(payload)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return new Blob([bytes], { type: mimeType })
}
export async function getSigningArtifacts(taskId: string): Promise<SigningArtifact[]> {
if (useMockData) {
return []
return buildMockArtifacts(taskId)
}
try {
@@ -115,6 +178,16 @@ export async function getSigningArtifact(
export function downloadSigningArtifact(artifactId: string): Promise<Blob> {
if (useMockData) {
// 演示环境没有文件服务,按 id 反查任务里的签名图,返回真实的 PNG
if (artifactId.startsWith(MOCK_SIGNATURE_PREFIX)) {
const taskId = artifactId.slice(MOCK_SIGNATURE_PREFIX.length)
const signatureDataUrl = getMockSigningTask(taskId)?.signatureDataUrl
if (signatureDataUrl) {
return Promise.resolve(dataUrlToBlob(signatureDataUrl))
}
}
return Promise.resolve(new Blob(['Mock 签署文件,仅用于界面演示。'], { type: 'text/plain' }))
}
+52 -88
View File
@@ -139,86 +139,6 @@ const mockPatients: PatientProfile[] = [
},
]
const mockTemplates: SigningTemplate[] = [
{
id: 'tpl-001',
versionId: 'tpl-001-v2',
name: '住院患者知情同意书',
code: 'DOC-HOS-001',
version: 'V2.0',
department: '全院通用',
category: '住院告知',
description: '住院期间诊疗事项、风险与患者权利告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-002',
versionId: 'tpl-002-v1',
name: '住院须知及告知书',
code: 'DOC-HOS-002',
version: 'V1.0',
department: '全院通用',
category: '住院告知',
description: '住院流程、陪护要求和患者须知确认。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-003',
versionId: 'tpl-003-v3',
name: '内镜检查知情同意书',
code: 'DOC-DIG-001',
version: 'V3.0',
department: '消化内科',
category: '检查告知',
description: '内镜检查适应证、风险及替代方案告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-004',
versionId: 'tpl-004-v1',
name: 'MRI 磁共振检查知情同意书',
code: 'DOC-RAD-001',
version: 'V1.0',
department: '放射科',
category: '检查告知',
description: '磁共振检查注意事项、禁忌证及风险告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-005',
versionId: 'tpl-005-v2',
name: '心脏介入手术知情同意书',
code: 'DOC-CARD-001',
version: 'V2.0',
department: '心内科',
category: '手术告知',
description: '介入治疗方案、围术期风险及替代方案告知。',
supportedMethods: ['pad'],
},
{
id: 'tpl-006',
versionId: 'tpl-006-v1',
name: '体检报告发放及知情同意书',
code: 'DOC-PE-001',
version: 'V1.0',
department: '健康管理中心',
category: '体检告知',
description: '体检报告领取方式、隐私保护和异常结果随访告知。',
supportedMethods: ['pad', 'sms'],
},
{
id: 'tpl-007',
versionId: 'tpl-007-v1',
name: '患者授权委托书',
code: 'DOC-GEN-003',
version: 'V1.0',
department: '医务处',
category: '授权文书',
description: '患者授权家属或代理人办理相关医疗事项。',
supportedMethods: ['pad', 'sms'],
},
]
const defaultMockRecords: SigningTaskRecord[] = [
{
id: 'task-001',
@@ -329,12 +249,43 @@ const defaultMockRecords: SigningTaskRecord[] = [
},
]
/**
* 给演示任务补上文书正文快照。
*
* 预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照;
* 缺正文时详情会退化成前端写死的回退纸样——内容与模板无关、没有签名位、
* 也没有固定页眉。这里按 documentId 从文书库取正文补上;
* 实在取不到的保持原样,由详情页走回退分支,不伪造正文。
*/
function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] {
const templates = new Map(readLocalSigningTemplates().map((template) => [template.id, template]))
return records.map((record) => {
if (record.documentContentHtml) {
return record
}
const template = templates.get(record.documentId)
if (!template?.contentHtml) {
return record
}
return {
...record,
templateVersionId: record.templateVersionId || template.versionId,
documentContentHtml: template.contentHtml,
}
})
}
/**
* 演示任务记录:优先用本地保存的,刷新后仍保留(任务固化的文书 HTML、签名图都在里面)。
* 所有写操作都要调用 persistMockRecords 落盘,否则刷新会回到切换前的状态。
*/
const mockRecords: SigningTaskRecord[] =
readMockState<SigningTaskRecord[]>(MOCK_STORAGE_KEYS.signingTasks) ?? defaultMockRecords
const mockRecords: SigningTaskRecord[] = hydrateTaskDocuments(
readMockState<SigningTaskRecord[]>(MOCK_STORAGE_KEYS.signingTasks) ?? defaultMockRecords,
)
function persistMockRecords() {
writeMockState(MOCK_STORAGE_KEYS.signingTasks, mockRecords)
@@ -392,6 +343,21 @@ function addHours(value: string, hours: number) {
export const isSigningMockEnabled = useMockData
/**
* 仅 Mock 模式可用:按 id 读取演示任务记录。
* 供签署产物模块(api/workbench/artifacts)复用同一份内存数据——
* 它没有正文快照的本地持久化,直接读 localStorage 会漏掉补过正文的预置任务。
* 真实模式返回 null。
*/
export function getMockSigningTask(taskId: string): SigningTaskRecord | null {
if (!useMockData) {
return null
}
const task = mockRecords.find((record) => record.id === taskId)
return task ? cloneTask(task) : null
}
/** 演示患者清单(仅 Mock 模式):给签署弹窗做“点一下就能定位”的输入提示 */
export function getMockPatientHints(): { id: string; name: string }[] {
return useMockData ? mockPatients.map((item) => ({ id: item.id, name: item.name })) : []
@@ -904,12 +870,10 @@ export async function getSigningTemplates(
return records.map(mapAvailableTemplate)
}
// 文档库本地新建的文书只存在 localStorage,必须一起返回;
// 否则文档库刚建好的文书在签署侧选不到(内置 Mock 模板是写死的演示数据)。
return Promise.resolve([
...readLocalSigningTemplates(),
...mockTemplates.map((template) => ({ ...template })),
])
// 文档库是模板的唯一来源,两侧读同一份数据:
// 否则签署端会出现文档库里看不到、也无法预览/编辑/删除的模板,
// 而那类模板没有正文,签出来只是前端写死的回退纸样。
return Promise.resolve(readLocalSigningTemplates())
}
export async function prepareSigningTask(