feat(sign): 补齐医护端与患者端签署流程

This commit is contained in:
xy
2026-09-28 19:57:57 +08:00
parent a25716c861
commit 8024075439
22 changed files with 1082 additions and 163 deletions
+235 -50
View File
@@ -9,6 +9,12 @@ import type {
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)。
@@ -22,17 +28,18 @@ import type { SignerRelation, SignFlowState } from '@/types/signing'
*/
const SESSION_KEY = 'medical-sign:patient-session'
/** 后端没给 `expiresAt` 时的兜底有效期,避免本地会话永不过期 */
const SESSION_FALLBACK_TTL_MS = 2 * 60 * 60 * 1000
interface PersistedSession {
taskId: string
taskId: string | null
taskNo: string
deliveryId: string
uploadToken: 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
}
@@ -52,6 +59,7 @@ const initialState: SignFlowState = {
contentSha256: '',
staffAnswers: {},
fields: [],
signatureFields: [],
documentLoaded: false,
documentError: '',
taskNo: 'CT-20260827-001',
@@ -75,18 +83,24 @@ export const signingState = reactive<SignFlowState>({ ...initialState })
/** 最近一次拿到的上下文,只用于写本地缓存 */
let lastContext: SigningContextResponse | null = null
/** 会话有效期:consume 不返回,由 context 补上 */
let sessionExpiresAt: string | null = null
let pendingToken: string | null = null
let consumeIdempotencyKey: string | null = null
let uploadIdempotencyKey: string | null = null
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) {
@@ -105,7 +119,10 @@ function readPersistedSession(): PersistedSession | null {
const parsed = JSON.parse(raw) as PersistedSession
if (!parsed?.taskId || !parsed?.uploadToken) {
if (
!parsed ||
(!parsed.pendingToken && (!parsed.taskId || !parsed.deliveryId || !parsed.uploadToken))
) {
return null
}
@@ -115,26 +132,40 @@ function readPersistedSession(): PersistedSession | 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 (!signingState.taskId || !signingState.uploadToken) {
if (
!pendingToken &&
(!signingState.taskId || !signingState.deliveryId || !signingState.uploadToken)
) {
return
}
try {
const payload: PersistedSession = {
taskId: signingState.taskId,
taskId: signingState.taskId || null,
taskNo: signingState.taskNo,
deliveryId: signingState.deliveryId,
uploadToken: signingState.uploadToken,
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('浏览器无法保存本次签署恢复凭证,请检查隐私或存储设置后重新打开链接。')
}
}
@@ -153,7 +184,8 @@ function isSessionExpired(session: PersistedSession) {
return expiresAt <= Date.now()
}
return Date.now() - session.savedAt > SESSION_FALLBACK_TTL_MS
// 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,由调用方走「链接无效」分支 */
@@ -170,18 +202,21 @@ function restoreSession() {
}
signingState.mode = 'real'
signingState.taskId = session.taskId
signingState.taskNo = session.taskNo || session.taskId
signingState.deliveryId = session.deliveryId
signingState.uploadToken = session.uploadToken
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 = 'ready'
signingState.status = session.pendingToken ? 'loading' : 'ready'
return true
}
@@ -256,6 +291,15 @@ function applyContext(context: SigningContextResponse) {
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
}
@@ -268,24 +312,96 @@ function applyContext(context: SigningContextResponse) {
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
}
signingState.documentLoaded = Boolean(signingState.documentContentHtml)
const hasPatientSignatureField = signingState.signatureFields.length > 0
signingState.documentLoaded =
Boolean(signingState.documentContentHtml) && hasPatientSignatureField
signingState.documentError = signingState.documentLoaded
? ''
: '暂未读取到本次签署的文书正文,无法展示正式文书。'
: signingState.documentContentHtml
? '本文书尚未配置有效的患者 PDF 签名位置,请联系医务人员。'
: '暂未读取到本次签署的文书正文,无法展示正式文书。'
}
async function loadSigningContext(token: string) {
try {
applyContext(await fetchSigningContext(token))
} catch {
// 接口未接入 / 任务无效:如实告知,**不放行签署** ——
// 让患者在没看过正式文书的情况下签名,比签不了更严重
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 = '暂时无法读取本次签署的文书内容,请联系现场医务人员。'
signingState.documentError = '签署凭证不完整,请重新打开签署链接。'
return null
}
try {
const context = await fetchSigningContext(proof)
applyContext(context)
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
}
}
@@ -299,8 +415,7 @@ async function loadSigningContext(token: string) {
function applyDemoContext() {
// `?demo=narcotic` 切到精麻同意书:它是演示文库(clinical-web)里唯一真实存在的模板,
// 字段也最多(代办人 / 身份核验 / 用药信息),最能压出占位符渲染的问题
const useNarcotic =
new URLSearchParams(window.location.search).get('demo') === 'narcotic'
const useNarcotic = new URLSearchParams(window.location.search).get('demo') === 'narcotic'
signingState.documentTitle = useNarcotic
? '麻醉药品、精神药品知情同意书'
@@ -327,9 +442,15 @@ export async function initializeSignFlow(token?: string) {
const candidate = token?.trim()
// 地址栏没有 token 时先尝试恢复本地会话(刷新 / 切后台回来都会走到这里)
// 地址栏没有 token 时恢复:若上次消费响应丢失,按原 key 重放;若已经拿到 proof,重取上下文。
if (!candidate && restoreSession()) {
return true
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'
@@ -350,19 +471,38 @@ export async function initializeSignFlow(token?: string) {
}
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: candidate })
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'
await loadSigningContext(candidate)
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'
@@ -374,7 +514,7 @@ export async function initializeSignFlow(token?: string) {
export async function submitRealSignature(input: {
file: Blob
consentAccepted: boolean
}): Promise<SignatureUploadResponse> {
}): Promise<SignatureUploadResponse | null> {
if (signingState.mode !== 'real') {
throw new Error('当前是演示签署流程')
}
@@ -392,32 +532,77 @@ export async function submitRealSignature(input: {
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
const signer = {
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: input.file,
metadata: JSON.stringify({
// 签署人信息由医生在医护端填好,患者端原样带上,不再自己填
signerName: signingState.signerName,
signerType: signingState.signerType,
relation: signingState.relation,
consentAccepted: input.consentAccepted,
signedAt: new Date().toISOString(),
}),
file: payload.file,
metadata: payload.metadata,
signer: payload.signer,
idempotencyKey: payload.idempotencyKey,
})
signingState.signatureCaptured = true
signingState.submittedAt = new Date().toLocaleString('zh-CN')
signingState.submittedAt = result.completedAt ?? new Date().toLocaleString('zh-CN')
signingState.backendStatus = result.status
signingState.pipelineId = result.pipelineId
signingState.uploadResult = result
// 凭证用掉了就清掉:既避免重复提交,也不在这台设备上留着
signingState.uploadToken = ''
// 保留短期 proof 以便结果页刷新时安全读取状态;服务端只允许它读上下文,不能重复上传。
signingState.status = 'submitted'
clearPersistedSession()
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
}
signingState.status = 'ready'
signingState.errorMessage = getErrorMessage(error)
throw error