689 lines
22 KiB
TypeScript
689 lines
22 KiB
TypeScript
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
|
||
}
|
||
}
|