feat(患者签署): 接入签署上下文接口并补齐文书渲染与手写签名链路

This commit is contained in:
yelan
2026-09-28 10:38:22 +08:00
parent d711e03a4e
commit 28c45dc7b2
13 changed files with 1126 additions and 277 deletions
+294 -14
View File
@@ -1,21 +1,65 @@
import { reactive } from 'vue'
import { consumeSigningToken, uploadSigningSignature } from '@/api'
import type { SignatureUploadResponse } from '@/api/types'
import type { SignFlowState } from '@/types/signing'
import { consumeSigningToken, fetchSigningContext, uploadSigningSignature } from '@/api'
import type {
SignDeliveryChannel,
SignatureUploadResponse,
SigningContextResponse,
} from '@/api/types'
import { DEMO_DOCUMENT_HTML, DEMO_PATIENT_FIELD_ID, DEMO_STAFF_ANSWERS } from '@/mock/demo-document'
import type { SignerRelation, SignFlowState } from '@/types/signing'
/**
* 本地会话(sessionStorage)。
*
* 为什么需要它:`EntryView` 会在请求前把 `token` 从地址栏清掉(防外泄,必要),
* 但清掉之后患者只要**下拉刷新 / 切后台被系统回收 / 误触返回**,就再也进不来了 ——
* 表现成「链接失效」,而链接其实还是好的。
*
* 所以消费成功后把投递凭证与上下文缓存到 sessionStorage(关掉标签页即失效,
* 不写 localStorage 是为了不在这台设备上长期留患者信息)。
*/
const SESSION_KEY = 'medical-sign:patient-session'
/** 后端没给 `expiresAt` 时的兜底有效期,避免本地会话永不过期 */
const SESSION_FALLBACK_TTL_MS = 2 * 60 * 60 * 1000
interface PersistedSession {
taskId: string
taskNo: string
deliveryId: string
uploadToken: string
channel: SignDeliveryChannel | ''
expiresAt: string | null
savedAt: number
/** 连上下文一起缓存:刷新后没有 token 可再用,重拉不了 */
context: SigningContextResponse | null
}
const initialState: SignFlowState = {
mode: 'mock',
status: 'idle',
taskStatus: '',
patientName: '张*',
sex: '',
age: '',
visitNo: 'MZ20260827018',
department: '',
documentTitle: '儿科急诊特殊检查知情同意书',
templateVersion: 'v1.0.0',
documentContentHtml: '',
contentSha256: '',
staffAnswers: {},
fields: [],
documentLoaded: false,
documentError: '',
taskNo: 'CT-20260827-001',
taskId: '',
deliveryId: '',
uploadToken: '',
channel: '',
consentAccepted: false,
signerType: '',
signerName: '',
relation: '',
signatureCaptured: false,
@@ -28,26 +72,263 @@ const initialState: SignFlowState = {
export const signingState = reactive<SignFlowState>({ ...initialState })
/** 最近一次拿到的上下文,只用于写本地缓存 */
let lastContext: SigningContextResponse | null = null
/** 会话有效期:consume 不返回,由 context 补上 */
let sessionExpiresAt: string | null = null
export function resetSignFlow() {
Object.assign(signingState, {
...initialState,
staffAnswers: {},
fields: [],
uploadResult: null,
})
lastContext = null
sessionExpiresAt = 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?.taskId || !parsed?.uploadToken) {
return null
}
return parsed
} catch {
return null
}
}
function persistSession() {
if (!signingState.taskId || !signingState.uploadToken) {
return
}
try {
const payload: PersistedSession = {
taskId: signingState.taskId,
taskNo: signingState.taskNo,
deliveryId: signingState.deliveryId,
uploadToken: signingState.uploadToken,
channel: signingState.channel,
expiresAt: sessionExpiresAt,
savedAt: Date.now(),
context: lastContext,
}
sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload))
} catch {
// 无痕模式 / 配额写满:存不下就不存,不影响本次签署
}
}
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()
}
return Date.now() - session.savedAt > SESSION_FALLBACK_TTL_MS
}
/** 用本地会话恢复现场。恢复不了返回 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
if (session.context) {
applyContext(session.context)
}
signingState.status = '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 }
}
if (context.fields) {
signingState.fields = [...context.fields]
}
if (context.status) {
signingState.taskStatus = context.status
}
if (context.channel) {
signingState.channel = context.channel
}
if (context.deliveryId) {
signingState.deliveryId = context.deliveryId
}
if (context.expiresAt) {
sessionExpiresAt = context.expiresAt
}
signingState.documentLoaded = Boolean(signingState.documentContentHtml)
signingState.documentError = signingState.documentLoaded
? ''
: '暂未读取到本次签署的文书正文,无法展示正式文书。'
}
async function loadSigningContext(token: string) {
try {
applyContext(await fetchSigningContext(token))
} catch {
// 接口未接入 / 任务无效:如实告知,**不放行签署** ——
// 让患者在没看过正式文书的情况下签名,比签不了更严重
signingState.documentLoaded = false
signingState.documentError = '暂时无法读取本次签署的文书内容,请联系现场医务人员。'
}
}
/**
* 演示模式的上下文。
*
* 与真实模式走**同一条渲染路径**(`DocumentPaper` 读的是同样的字段),
* 只是数据是内置的 —— 演示与真实共用一套代码,才不会出现
* 「演示模式下好好的、真实模式一片空白」这种最难查的偏差。
*/
function applyDemoContext() {
signingState.documentTitle = 'CT 增强扫描知情同意书'
signingState.templateVersion = 'v1.0.0'
signingState.documentContentHtml = DEMO_DOCUMENT_HTML
signingState.staffAnswers = { ...DEMO_STAFF_ANSWERS }
signingState.fields = [
{ id: DEMO_PATIENT_FIELD_ID, label: '患者签名', kind: 'signature', required: 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 时先尝试恢复本地会话(刷新 / 切后台回来都会走到这里)
if (!candidate && restoreSession()) {
return true
}
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
signingState.mode = shouldUseRealFlow ? 'real' : 'mock'
if (!shouldUseRealFlow) {
// 演示模式也要给一份带占位符的正文,否则「医生填了什么、患者该签哪里」根本验不了
applyDemoContext()
signingState.status = 'ready'
return true
}
@@ -61,17 +342,17 @@ export async function initializeSignFlow(token?: string) {
signingState.status = 'loading'
try {
const session = await consumeSigningToken({ token: candidate, channel: 'SMS' })
const session = await consumeSigningToken({ token: candidate })
signingState.taskId = session.taskId
signingState.taskNo = session.taskId
signingState.deliveryId = session.deliveryId
signingState.uploadToken = session.uploadToken
signingState.channel = session.channel
signingState.documentTitle = '待签署知情同意文书'
signingState.patientName = '患者信息待确认'
signingState.visitNo = ''
signingState.status = 'ready'
await loadSigningContext(candidate)
persistSession()
return true
} catch (error) {
signingState.status = 'error'
@@ -82,8 +363,6 @@ export async function initializeSignFlow(token?: string) {
export async function submitRealSignature(input: {
file: Blob
signerName: string
relation: NonNullable<SignFlowState['relation']>
consentAccepted: boolean
}): Promise<SignatureUploadResponse> {
if (signingState.mode !== 'real') {
@@ -109,23 +388,24 @@ export async function submitRealSignature(input: {
uploadToken: signingState.uploadToken,
file: input.file,
metadata: JSON.stringify({
signerName: input.signerName,
relation: input.relation,
// 签署人信息由医生在医护端填好,患者端原样带上,不再自己填
signerName: signingState.signerName,
signerType: signingState.signerType,
relation: signingState.relation,
consentAccepted: input.consentAccepted,
signedAt: new Date().toISOString(),
}),
})
signingState.signerName = input.signerName
signingState.relation = input.relation
signingState.consentAccepted = input.consentAccepted
signingState.signatureCaptured = true
signingState.submittedAt = new Date().toLocaleString('zh-CN')
signingState.backendStatus = result.status
signingState.pipelineId = result.pipelineId
signingState.uploadResult = result
// 凭证用掉了就清掉:既避免重复提交,也不在这台设备上留着
signingState.uploadToken = ''
signingState.status = 'submitted'
clearPersistedSession()
return result
} catch (error) {
signingState.status = 'ready'