Files
xh-medical-sign-web/patient-h5/src/composables/useSignFlow.ts
T

689 lines
22 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.
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<SignFlowState>({ ...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<void> = 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<string, PatientAnswer>
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<SigningContextResponse | null> {
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<SignatureUploadResponse | null> {
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
}
}