feat(患者签署): 接入签署上下文接口并补齐文书渲染与手写签名链路
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user