import { loadDocumentFont } from '../../../packages/document-fonts' import { reactive } from 'vue' import { consumeSigningToken, fetchSigningContext, saveSigningForm, uploadSigningSignature, } from '@/api' import type { SignDeliveryChannel, PatientAnswer, SignatureUploadResponse, SigningContextResponse, } from '@/api/types' import { DEMO_DOCUMENT_HTML, DEMO_PATIENT_FIELD_ID, DEMO_STAFF_ANSWERS } from '@/mock/demo-document' import { DEMO_NARCOTIC_HTML, DEMO_NARCOTIC_STAFF_ANSWERS } from '@/mock/demo-narcotic' import type { SignerRelation, SignFlowState } from '@/types/signing' import { readPendingSigningUpload, removePendingSigningUpload, savePendingSigningUpload, } from '@/utils/signing-vault' import { buildSignaturePngMetadata } from '@/utils/signing-png' /** * 本地会话(sessionStorage)。 * * 为什么需要它:`EntryView` 会在请求前把 `token` 从地址栏清掉(防外泄,必要), * 但清掉之后患者只要**下拉刷新 / 切后台被系统回收 / 误触返回**,就再也进不来了 —— * 表现成「链接失效」,而链接其实还是好的。 * * 所以消费成功后把投递凭证与上下文缓存到 sessionStorage(关掉标签页即失效, * 不写 localStorage 是为了不在这台设备上长期留患者信息)。 */ const SESSION_KEY = 'medical-sign:patient-session' interface PersistedSession { taskId: string | null taskNo: string deliveryId: string | null uploadToken: string | null channel: SignDeliveryChannel | '' expiresAt: string | null savedAt: number /** 保存于首次 consume 之前;响应丢失后使用同一 token 和 key 安全重试。 */ pendingToken: string | null consumeIdempotencyKey: string | null uploadIdempotencyKey: string | null /** 连上下文一起缓存:刷新后没有 token 可再用,重拉不了 */ context: SigningContextResponse | null } const initialState: SignFlowState = { mode: 'mock', status: 'idle', taskStatus: '', patientName: '张*', sex: '', age: '', visitNo: 'MZ20260827018', department: '', documentFontFamily: '', documentFontReady: false, documentTitle: '儿科急诊特殊检查知情同意书', templateVersion: 'v1.0.0', documentContentHtml: '', contentSha256: '', staffAnswers: {}, form: null, formSaving: false, formError: '', fields: [], signatureFields: [], documentLoaded: false, documentError: '', taskNo: 'CT-20260827-001', taskId: '', deliveryId: '', uploadToken: '', channel: '', consentAccepted: false, signerType: '', signerName: '', relation: '', signatureCaptured: false, submittedAt: '', backendStatus: '', pipelineId: '', uploadResult: null, errorMessage: '', } export const signingState = reactive({ ...initialState }) /** 最近一次拿到的上下文,只用于写本地缓存 */ let lastContext: SigningContextResponse | null = null let sessionExpiresAt: string | null = null let pendingToken: string | null = null let consumeIdempotencyKey: string | null = null let uploadIdempotencyKey: string | null = null let formQueue: Promise = Promise.resolve() export function updatePatientAnswer(id: string, value: PatientAnswer) { const form = signingState.form const proof = currentProof() if (!form?.editable || !proof || signingState.status === 'submitting' || signingState.formError) return if (!form.fields.some((field) => field.id === id && field.editable)) return form.answers = { ...form.answers, [id]: value } const answers = JSON.parse(JSON.stringify(form.answers)) as Record signingState.formSaving = true formQueue = formQueue.then(async () => { if (signingState.form !== form || signingState.formError) return try { const saved = await saveSigningForm(proof, form.revision, answers) form.revision = saved.revision } catch { signingState.formError = '填写内容保存失败,请重新加载并核对后再签署。' } }) const current = formQueue void current.finally(() => { if (current === formQueue) signingState.formSaving = false }) } export async function flushPatientAnswers() { await formQueue if (signingState.formError) throw new Error(signingState.formError) const form = signingState.form if (!form) return for (const field of form.fields) { if (!field.editable || !field.required) continue const value = form.answers[field.id] if ( value === undefined || value === false || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length) ) { throw new Error(`请完成必填项:${field.label}`) } } } export function resetSignFlow() { Object.assign(signingState, { ...initialState, staffAnswers: {}, fields: [], signatureFields: [], uploadResult: null, }) lastContext = null sessionExpiresAt = null pendingToken = null consumeIdempotencyKey = null uploadIdempotencyKey = null } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '签署链接验证失败,请联系现场医务人员。' } /* ---------------- 本地会话 ---------------- */ function readPersistedSession(): PersistedSession | null { try { const raw = sessionStorage.getItem(SESSION_KEY) if (!raw) { return null } const parsed = JSON.parse(raw) as PersistedSession if ( !parsed || (!parsed.pendingToken && (!parsed.taskId || !parsed.deliveryId || !parsed.uploadToken)) ) { return null } return parsed } catch { return null } } function createIdempotencyKey() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return `patient-h5-${crypto.randomUUID()}` } return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}` } function persistSession() { if ( !pendingToken && (!signingState.taskId || !signingState.deliveryId || !signingState.uploadToken) ) { return } try { const payload: PersistedSession = { taskId: signingState.taskId || null, taskNo: signingState.taskNo, deliveryId: signingState.deliveryId || null, uploadToken: signingState.uploadToken || null, channel: signingState.channel, expiresAt: sessionExpiresAt, savedAt: Date.now(), pendingToken, consumeIdempotencyKey, uploadIdempotencyKey, context: lastContext, } sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload)) } catch { throw new Error('浏览器无法保存本次签署恢复凭证,请检查隐私或存储设置后重新打开链接。') } } export function clearPersistedSession() { try { sessionStorage.removeItem(SESSION_KEY) } catch { // 同上 } } function isSessionExpired(session: PersistedSession) { const expiresAt = session.expiresAt ? new Date(session.expiresAt).getTime() : Number.NaN if (!Number.isNaN(expiresAt)) { return expiresAt <= Date.now() } // A response without an explicit expiry is still bounded to the service's 48h signing window. return Date.now() - session.savedAt > 48 * 60 * 60 * 1000 } /** 用本地会话恢复现场。恢复不了返回 false,由调用方走「链接无效」分支 */ function restoreSession() { const session = readPersistedSession() if (!session) { return false } if (isSessionExpired(session)) { clearPersistedSession() return false } signingState.mode = 'real' signingState.taskId = session.taskId ?? '' signingState.taskNo = session.taskNo || session.taskId || '' signingState.deliveryId = session.deliveryId ?? '' signingState.uploadToken = session.uploadToken ?? '' signingState.channel = session.channel sessionExpiresAt = session.expiresAt pendingToken = session.pendingToken consumeIdempotencyKey = session.consumeIdempotencyKey uploadIdempotencyKey = session.uploadIdempotencyKey if (session.context) { applyContext(session.context) } signingState.status = session.pendingToken ? 'loading' : 'ready' return true } /* ---------------- 上下文 ---------------- */ /** * 把 `context` 响应铺进页面状态。 * * 逐字段判空再覆盖(而不是整体替换):后端可能只补了一部分字段, * 缺的字段应该保留原值,不能把患者姓名冲成空串。 */ function applyContext(context: SigningContextResponse) { lastContext = context const patient = context.patient ?? undefined if (patient?.name?.trim()) { signingState.patientName = patient.name.trim() } if (patient?.sex) { signingState.sex = patient.sex } if (patient?.age !== null && patient?.age !== undefined) { signingState.age = String(patient.age) } if (patient?.visitNo) { signingState.visitNo = patient.visitNo } if (patient?.department) { signingState.department = patient.department } // 签署人由医生在医护端填好,患者端只展示 —— 取不到就退回患者本人 const signer = context.signer ?? undefined if (signer?.type) { signingState.signerType = signer.type } if (signer?.name?.trim()) { signingState.signerName = signer.name.trim() } if (signer?.relation) { signingState.relation = signer.relation as SignerRelation } const template = context.template ?? undefined if (template?.name?.trim()) { signingState.documentTitle = template.name.trim() } if (template?.versionNo) { signingState.templateVersion = template.versionNo } if (context.document) { signingState.documentContentHtml = context.document.contentHtml ?? '' signingState.contentSha256 = context.document.contentSha256 ?? '' } if (context.staffAnswers) { signingState.staffAnswers = { ...context.staffAnswers } } signingState.form = context.form ? { ...context.form, answers: { ...context.form.answers } } : null if (context.form) signingState.documentContentHtml = context.form.contentHtml signingState.formError = '' if (context.fields) { signingState.fields = [...context.fields] } if (context.signatureFields) { const patientField = context.signatureFields.find((field) => field.id === 'signature') signingState.signatureFields = patientField ? [patientField] : context.signatureFields.length === 1 ? [...context.signatureFields] : [] } if (context.status) { signingState.taskStatus = context.status } if (context.channel) { signingState.channel = context.channel } if (context.deliveryId) { signingState.deliveryId = context.deliveryId } if (context.uploadResult) { signingState.uploadResult = context.uploadResult signingState.pipelineId = context.uploadResult.pipelineId } signingState.backendStatus = context.status ?? signingState.backendStatus signingState.signatureCaptured = Boolean( context.uploadResult || context.status === 'GENERATING' || context.status === 'WAITING_CONFIRM' || context.status === 'SIGNED', ) if (context.uploadResult?.completedAt) { signingState.submittedAt = context.uploadResult.completedAt } else if (context.signedAt) { signingState.submittedAt = context.signedAt } if (context.expiresAt) { sessionExpiresAt = context.expiresAt } const hasPatientSignatureField = signingState.signatureFields.length > 0 signingState.documentLoaded = Boolean(signingState.documentContentHtml) && hasPatientSignatureField signingState.documentError = signingState.documentLoaded ? '' : signingState.documentContentHtml ? '本文书尚未配置有效的患者 PDF 签名位置,请联系医务人员。' : '暂未读取到本次签署的文书正文,无法展示正式文书。' } function getHttpStatus(error: unknown) { if (typeof error !== 'object' || error === null || !('response' in error)) { return undefined } const response = (error as { response?: { status?: unknown } }).response return typeof response?.status === 'number' ? response.status : undefined } function isRejectedProof(error: unknown) { const status = getHttpStatus(error) return status === 401 || status === 403 || status === 404 || status === 410 || status === 422 } function currentProof() { if (!signingState.taskId || !signingState.deliveryId || !signingState.uploadToken) { return null } return { taskId: signingState.taskId, deliveryId: signingState.deliveryId, uploadToken: signingState.uploadToken, } } function isPostUploadStatus(status: string) { return status === 'GENERATING' || status === 'WAITING_CONFIRM' || status === 'SIGNED' } async function loadSigningContext(): Promise { const proof = currentProof() if (!proof) { signingState.documentLoaded = false signingState.documentError = '签署凭证不完整,请重新打开签署链接。' return null } try { const context = await fetchSigningContext(proof) applyContext(context) signingState.documentFontReady = false signingState.documentFontFamily = await loadDocumentFont( context.document?.font ?? null, import.meta.env.VITE_API_BASE_URL || '/api', ) signingState.documentFontReady = true persistSession() return context } catch (error) { if (isRejectedProof(error)) { clearPersistedSession() await removePendingSigningUpload(signingState.taskId).catch(() => undefined) signingState.taskId = '' signingState.deliveryId = '' signingState.uploadToken = '' } signingState.documentLoaded = false signingState.documentError = isRejectedProof(error) ? '签署凭证已失效或被撤销,请联系现场医务人员重新发起。' : '暂时无法读取本次签署的文书内容,请检查网络后重试。' return null } } /** * 演示模式的上下文。 * * 与真实模式走**同一条渲染路径**(`DocumentPaper` 读的是同样的字段), * 只是数据是内置的 —— 演示与真实共用一套代码,才不会出现 * 「演示模式下好好的、真实模式一片空白」这种最难查的偏差。 */ function applyDemoContext() { // `?demo=narcotic` 切到精麻同意书:它是演示文库(clinical-web)里唯一真实存在的模板, // 字段也最多(代办人 / 身份核验 / 用药信息),最能压出占位符渲染的问题 const useNarcotic = new URLSearchParams(window.location.search).get('demo') === 'narcotic' signingState.documentTitle = useNarcotic ? '麻醉药品、精神药品知情同意书' : 'CT 增强扫描知情同意书' signingState.templateVersion = 'v1.0.0' signingState.documentContentHtml = useNarcotic ? DEMO_NARCOTIC_HTML : DEMO_DOCUMENT_HTML signingState.staffAnswers = { ...(useNarcotic ? DEMO_NARCOTIC_STAFF_ANSWERS : DEMO_STAFF_ANSWERS), } signingState.fields = [ { id: DEMO_PATIENT_FIELD_ID, label: '患者签名', kind: 'signature', required: true }, ] signingState.documentFontReady = true signingState.documentLoaded = true signingState.documentError = '' signingState.taskStatus = 'WAITING_SIGN' signingState.signerName = signingState.signerName || signingState.patientName signingState.relation = signingState.relation || '本人' } /* ---------------- 流程 ---------------- */ export async function initializeSignFlow(token?: string) { resetSignFlow() const candidate = token?.trim() // 地址栏没有 token 时恢复:若上次消费响应丢失,按原 key 重放;若已经拿到 proof,重取上下文。 if (!candidate && restoreSession()) { if (pendingToken && consumeIdempotencyKey) { return consumeAndLoad(pendingToken, consumeIdempotencyKey) } const context = await loadSigningContext() signingState.status = context ? 'ready' : 'error' signingState.errorMessage = context ? '' : signingState.documentError return Boolean(context) } const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false' signingState.mode = shouldUseRealFlow ? 'real' : 'mock' if (!shouldUseRealFlow) { // 演示模式也要给一份带占位符的正文,否则「医生填了什么、患者该签哪里」根本验不了 applyDemoContext() signingState.status = 'ready' return true } if (!candidate) { signingState.status = 'blocked' signingState.errorMessage = '请使用短信或二维码中的有效签署链接进入。' return false } signingState.status = 'loading' pendingToken = candidate consumeIdempotencyKey = createIdempotencyKey() // Store before the network call so a response lost after server commit can be replayed safely. persistSession() return consumeAndLoad(candidate, consumeIdempotencyKey) } async function consumeAndLoad(token: string, idempotencyKey: string) { signingState.status = 'loading' try { const session = await consumeSigningToken({ token }, idempotencyKey) signingState.taskId = session.taskId signingState.taskNo = session.taskId signingState.deliveryId = session.deliveryId signingState.uploadToken = session.uploadToken signingState.channel = session.channel pendingToken = null signingState.status = 'ready' persistSession() const context = await loadSigningContext() if (!context) { signingState.status = 'error' signingState.errorMessage = signingState.documentError return false } // The server status is authoritative after response loss; do not ask the patient to sign twice. if (isPostUploadStatus(context.status ?? '')) { signingState.signatureCaptured = true signingState.status = 'submitted' } return true } catch (error) { signingState.status = 'error' signingState.errorMessage = getErrorMessage(error) return false } } export async function submitRealSignature(input: { file: Blob consentAccepted: boolean }): Promise { if (signingState.mode !== 'real') { throw new Error('当前是演示签署流程') } if ( !signingState.taskId || !signingState.deliveryId || !signingState.uploadToken || !input.consentAccepted || !signingState.documentLoaded || !signingState.documentFontReady ) { throw new Error('签署凭证或知情确认状态无效,请重新打开签署链接。') } signingState.status = 'submitting' signingState.errorMessage = '' try { const signatureField = signingState.signatureFields[0] if (!signatureField) { throw new Error('当前文书未配置患者签名位置,请联系现场医务人员。') } const signerType = signingState.signerType.toUpperCase() === 'FAMILY' ? 'FAMILY' : 'SELF' const signerName = signingState.signerName.trim() || signingState.patientName.trim() const relation = signingState.relation || '本人' if (!signerName) { throw new Error('签署人信息不完整,请联系现场医务人员。') } const observedSignedAt = new Date().toISOString() const metadata = await buildSignaturePngMetadata(input.file, signatureField) const pending = await readPendingSigningUpload(signingState.taskId) const idempotencyKey = pending?.idempotencyKey ?? uploadIdempotencyKey ?? createIdempotencyKey() uploadIdempotencyKey = idempotencyKey if (!pending) await flushPatientAnswers() const signer = { answerRevision: signingState.form?.revision, type: signerType, name: signerName, relation, consentAccepted: true as const, observedSignedAt, } // Preserve the exact PNG and idempotency key before upload; response loss can then retry // without asking the patient to redraw or creating a second pipeline. await savePendingSigningUpload({ taskId: signingState.taskId, deliveryId: signingState.deliveryId, expiresAt: sessionExpiresAt, file: input.file, metadata: pending?.metadata ?? metadata, signer: pending?.signer ?? signer, idempotencyKey, savedAt: Date.now(), }) const payload = await readPendingSigningUpload(signingState.taskId) if (!payload) { throw new Error('签署恢复信息已过期,请联系现场医务人员重新发起。') } const result = await uploadSigningSignature({ taskId: signingState.taskId, deliveryId: signingState.deliveryId, uploadToken: signingState.uploadToken, file: payload.file, metadata: payload.metadata, signer: payload.signer, idempotencyKey: payload.idempotencyKey, }) signingState.signatureCaptured = true signingState.submittedAt = result.completedAt ?? new Date().toLocaleString('zh-CN') signingState.backendStatus = result.status signingState.pipelineId = result.pipelineId signingState.uploadResult = result // 保留短期 proof 以便结果页刷新时安全读取状态;服务端只允许它读上下文,不能重复上传。 signingState.status = 'submitted' await removePendingSigningUpload(signingState.taskId) persistSession() return result } catch (error) { const refreshedContext = await loadSigningContext().catch(() => null) if (refreshedContext && isPostUploadStatus(refreshedContext.status ?? '')) { signingState.signatureCaptured = true signingState.status = 'submitted' signingState.backendStatus = refreshedContext.status ?? '' signingState.submittedAt = refreshedContext.signedAt ?? new Date().toISOString() await removePendingSigningUpload(signingState.taskId).catch(() => undefined) persistSession() return null } if ([400, 409, 422].includes(getHttpStatus(error) ?? 0)) { await removePendingSigningUpload(signingState.taskId).catch(() => undefined) uploadIdempotencyKey = null } signingState.status = 'ready' signingState.errorMessage = getErrorMessage(error) throw error } }