feat(sign): 补齐医护端与患者端签署流程
This commit is contained in:
+20
-27
@@ -5,16 +5,20 @@ import type {
|
||||
ApiResponse,
|
||||
SignatureUploadInput,
|
||||
SignatureUploadResponse,
|
||||
SigningProof,
|
||||
SigningContextResponse,
|
||||
TokenConsumeRequest,
|
||||
TokenConsumeResponse,
|
||||
} from './types'
|
||||
|
||||
export function consumeSigningToken(payload: TokenConsumeRequest): Promise<TokenConsumeResponse> {
|
||||
export function consumeSigningToken(
|
||||
payload: TokenConsumeRequest,
|
||||
idempotencyKey: string,
|
||||
): Promise<TokenConsumeResponse> {
|
||||
return request
|
||||
.post<ApiResponse<TokenConsumeResponse>>('/v1/sign-deliveries/token/consume', payload, {
|
||||
headers: {
|
||||
'Idempotency-Key': createIdempotencyKey(),
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
},
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
@@ -23,48 +27,37 @@ export function consumeSigningToken(payload: TokenConsumeRequest): Promise<Token
|
||||
/**
|
||||
* 读取本次签署的完整上下文:患者信息 / 签署人 / 文书正文 / 医生已填结果。
|
||||
*
|
||||
* 契约见 `.workbuddy-ai/患者端H5真实接入方案-2026-09-28.md` 第三节。
|
||||
* **后端就绪前这个调用会失败**,所以调用方必须能优雅降级 ——
|
||||
* 拿不到正文就不能放行签署(见 `useSignFlow` 的 `documentError`)。
|
||||
* Context 使用已消费凭证,不再把原始一次性 Token 放进请求 URL、日志或后续请求。
|
||||
*/
|
||||
export function fetchSigningContext(token: string): Promise<SigningContextResponse> {
|
||||
export function fetchSigningContext(proof: SigningProof): Promise<SigningContextResponse> {
|
||||
return request
|
||||
.get<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', {
|
||||
params: { token },
|
||||
})
|
||||
.post<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', proof)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
function createIdempotencyKey(value?: string) {
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `patient-h5-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
export function uploadSigningSignature(
|
||||
payload: SignatureUploadInput,
|
||||
): Promise<SignatureUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', payload.file, 'signature.png')
|
||||
formData.append('metadata', payload.metadata)
|
||||
formData.append('deliveryId', payload.deliveryId)
|
||||
formData.append('uploadToken', payload.uploadToken)
|
||||
formData.append(
|
||||
'metadata',
|
||||
new Blob([JSON.stringify(payload.metadata)], { type: 'application/json' }),
|
||||
)
|
||||
formData.append(
|
||||
'signer',
|
||||
new Blob([JSON.stringify(payload.signer)], { type: 'application/json' }),
|
||||
)
|
||||
|
||||
return request
|
||||
.post<ApiResponse<SignatureUploadResponse>>(
|
||||
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
||||
formData,
|
||||
{
|
||||
params: {
|
||||
deliveryId: payload.deliveryId,
|
||||
uploadToken: payload.uploadToken,
|
||||
},
|
||||
headers: {
|
||||
'Idempotency-Key': createIdempotencyKey(payload.idempotencyKey),
|
||||
'Idempotency-Key': payload.idempotencyKey,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -24,6 +24,38 @@ export interface TokenConsumeRequest {
|
||||
taskId?: string
|
||||
}
|
||||
|
||||
export interface SignatureFieldPosition {
|
||||
id: string
|
||||
page: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT'
|
||||
}
|
||||
|
||||
/** Facts read from the actual PNG plus an optional assertion against a configured PDF field. */
|
||||
export interface SignaturePngMetadata {
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
sizeBytes: number
|
||||
sha256: string
|
||||
contentType: 'image/png'
|
||||
fieldId: string
|
||||
page: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
coordinateSystem: SignatureFieldPosition['coordinateSystem']
|
||||
}
|
||||
|
||||
export interface SigningProof {
|
||||
taskId: string
|
||||
deliveryId: string
|
||||
uploadToken: string
|
||||
}
|
||||
|
||||
export interface TokenConsumeResponse {
|
||||
deliveryId: string
|
||||
taskId: string
|
||||
@@ -35,12 +67,7 @@ export interface TokenConsumeResponse {
|
||||
|
||||
/** 患者端看到的任务状态。不是 `WAITING_SIGN` 时不该放行签署 */
|
||||
export type SigningTaskSignStatus =
|
||||
| 'WAITING_SIGN'
|
||||
| 'SIGNED'
|
||||
| 'EXPIRED'
|
||||
| 'VOIDED'
|
||||
| 'FAILED'
|
||||
| string
|
||||
'WAITING_SIGN' | 'WAITING_CONFIRM' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'FAILED' | string
|
||||
|
||||
export interface SigningContextPatient {
|
||||
name?: string | null
|
||||
@@ -107,10 +134,24 @@ export interface SigningContextResponse {
|
||||
staffAnswers?: Record<string, string> | null
|
||||
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||
fields?: SigningContextField[] | null
|
||||
/** Configured PDF signature locations, separate from body form-field definitions. */
|
||||
signatureFields?: SignatureFieldPosition[] | null
|
||||
/** Recoverable summary of the real server pipeline and artifacts after a successful upload. */
|
||||
uploadResult?: SignatureUploadResponse | null
|
||||
/** Server-owned patient signature completion timestamp (separate from department confirmation). */
|
||||
signedAt?: string | null
|
||||
}
|
||||
|
||||
export type SignatureUploadStatus =
|
||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string
|
||||
| 'CREATED'
|
||||
| 'WAITING_SIGN'
|
||||
| 'SIGNED'
|
||||
| 'EXPIRED'
|
||||
| 'VOIDED'
|
||||
| 'GENERATING'
|
||||
| 'WAITING_CONFIRM'
|
||||
| 'FAILED'
|
||||
| string
|
||||
|
||||
export interface SignatureUploadResponse {
|
||||
taskId: string
|
||||
@@ -132,6 +173,13 @@ export interface SignatureUploadInput {
|
||||
deliveryId: string
|
||||
uploadToken: string
|
||||
file: Blob
|
||||
metadata: string
|
||||
idempotencyKey?: string
|
||||
metadata: SignaturePngMetadata
|
||||
signer: {
|
||||
type: string
|
||||
name: string
|
||||
relation: string
|
||||
consentAccepted: true
|
||||
observedSignedAt: string
|
||||
}
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { SignDeliveryChannel, SignatureUploadResponse, SigningContextField } from '@/api/types'
|
||||
import type {
|
||||
SignDeliveryChannel,
|
||||
SignatureFieldPosition,
|
||||
SignatureUploadResponse,
|
||||
SigningContextField,
|
||||
} from '@/api/types'
|
||||
|
||||
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
||||
|
||||
@@ -34,6 +39,8 @@ export interface SignFlowState {
|
||||
staffAnswers: Record<string, string>
|
||||
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||
fields: SigningContextField[]
|
||||
/** PDF points and page number supplied by the task's frozen template version. */
|
||||
signatureFields: SignatureFieldPosition[]
|
||||
/** 有没有真的拿到正文 —— 没拿到就不该让患者签 */
|
||||
documentLoaded: boolean
|
||||
documentError: string
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SignatureFieldPosition, SignaturePngMetadata } from '@/api/types'
|
||||
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
|
||||
|
||||
function hex(value: ArrayBuffer) {
|
||||
return [...new Uint8Array(value)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function readImageSize(file: Blob) {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const size = { width: bitmap.width, height: bitmap.height }
|
||||
bitmap.close()
|
||||
return size
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = objectUrl
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve()
|
||||
image.onerror = () => reject(new Error('无法读取签名图片尺寸,请重新签名。'))
|
||||
})
|
||||
return { width: image.naturalWidth, height: image.naturalHeight }
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSignaturePngMetadata(
|
||||
file: Blob,
|
||||
signatureField: SignatureFieldPosition,
|
||||
): Promise<SignaturePngMetadata> {
|
||||
if (file.type !== 'image/png') {
|
||||
throw new Error('签名图片必须是 PNG 格式。')
|
||||
}
|
||||
|
||||
const header = new Uint8Array(await file.slice(0, PNG_SIGNATURE.length).arrayBuffer())
|
||||
|
||||
if (!PNG_SIGNATURE.every((byte, index) => header[index] === byte)) {
|
||||
throw new Error('签名图片内容不是有效的 PNG 文件,请重新签名。')
|
||||
}
|
||||
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error('当前浏览器无法校验签名图片,请通过 HTTPS 页面重新打开签署链接。')
|
||||
}
|
||||
|
||||
const [{ width, height }, digest] = await Promise.all([
|
||||
readImageSize(file),
|
||||
crypto.subtle.digest('SHA-256', await file.arrayBuffer()),
|
||||
])
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error('签名图片尺寸无效,请重新签名。')
|
||||
}
|
||||
|
||||
return {
|
||||
imageWidth: width,
|
||||
imageHeight: height,
|
||||
sizeBytes: file.size,
|
||||
sha256: hex(digest),
|
||||
contentType: 'image/png',
|
||||
fieldId: signatureField.id,
|
||||
page: signatureField.page,
|
||||
x: signatureField.x,
|
||||
y: signatureField.y,
|
||||
width: signatureField.width,
|
||||
height: signatureField.height,
|
||||
coordinateSystem: signatureField.coordinateSystem,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { SignaturePngMetadata } from '@/api/types'
|
||||
|
||||
export interface PendingSigningUpload {
|
||||
taskId: string
|
||||
deliveryId: string
|
||||
expiresAt: string | null
|
||||
file: Blob
|
||||
metadata: SignaturePngMetadata
|
||||
signer: {
|
||||
type: string
|
||||
name: string
|
||||
relation: string
|
||||
consentAccepted: true
|
||||
observedSignedAt: string
|
||||
}
|
||||
idempotencyKey: string
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
const DATABASE_NAME = 'medical-sign:patient-vault'
|
||||
const DATABASE_VERSION = 1
|
||||
const UPLOAD_STORE = 'pending-uploads'
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
return Promise.reject(new Error('此浏览器不支持安全保存签署恢复信息,请使用最新版浏览器。'))
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result
|
||||
|
||||
if (!database.objectStoreNames.contains(UPLOAD_STORE)) {
|
||||
database.createObjectStore(UPLOAD_STORE, { keyPath: 'taskId' })
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(new Error('无法保存签署恢复信息,请检查浏览器存储设置。'))
|
||||
})
|
||||
}
|
||||
|
||||
async function withStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
action: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
) {
|
||||
const database = await openDatabase()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const transaction = database.transaction(UPLOAD_STORE, mode)
|
||||
const request = action(transaction.objectStore(UPLOAD_STORE))
|
||||
let result: T
|
||||
request.onsuccess = () => {
|
||||
result = request.result
|
||||
}
|
||||
request.onerror = () => reject(new Error('签署恢复信息保存失败,请稍后重试。'))
|
||||
transaction.oncomplete = () => {
|
||||
database.close()
|
||||
resolve(result)
|
||||
}
|
||||
transaction.onerror = () => {
|
||||
database.close()
|
||||
reject(new Error('签署恢复信息保存失败,请稍后重试。'))
|
||||
}
|
||||
transaction.onabort = () => {
|
||||
database.close()
|
||||
reject(new Error('签署恢复信息保存失败,请稍后重试。'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function savePendingSigningUpload(upload: PendingSigningUpload): Promise<IDBValidKey> {
|
||||
return withStore('readwrite', (store) => store.put(upload))
|
||||
}
|
||||
|
||||
export async function readPendingSigningUpload(
|
||||
taskId: string,
|
||||
): Promise<PendingSigningUpload | null> {
|
||||
const upload = await withStore<PendingSigningUpload | undefined>('readonly', (store) =>
|
||||
store.get(taskId),
|
||||
)
|
||||
|
||||
if (!upload) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expiresAt = upload.expiresAt ? new Date(upload.expiresAt).getTime() : Number.NaN
|
||||
|
||||
if (!Number.isNaN(expiresAt) && expiresAt <= Date.now()) {
|
||||
await removePendingSigningUpload(taskId)
|
||||
return null
|
||||
}
|
||||
|
||||
return upload
|
||||
}
|
||||
|
||||
export async function removePendingSigningUpload(taskId: string): Promise<void> {
|
||||
await withStore<undefined>('readwrite', (store) => store.delete(taskId))
|
||||
}
|
||||
@@ -69,6 +69,20 @@ const terminal = computed(() => {
|
||||
? `签署时间:${signingState.submittedAt}。无需重复签署。`
|
||||
: '本次文书已完成签署,无需重复签署。',
|
||||
}
|
||||
case 'WAITING_CONFIRM':
|
||||
return {
|
||||
eyebrow: '患者已签署',
|
||||
title: '签署已提交,等待科室确认',
|
||||
copy: signingState.submittedAt
|
||||
? `提交时间:${signingState.submittedAt}。无需重复签署。`
|
||||
: '患者签名已提交,等待医务人员完成后续确认。无需重复签署。',
|
||||
}
|
||||
case 'GENERATING':
|
||||
return {
|
||||
eyebrow: '签名已提交',
|
||||
title: '文书正在生成',
|
||||
copy: '签名已收到,系统正在生成正式文书。无需重复签署。',
|
||||
}
|
||||
case 'EXPIRED':
|
||||
return {
|
||||
eyebrow: '链接已过期',
|
||||
@@ -197,7 +211,9 @@ onMounted(() => {
|
||||
<div>
|
||||
<strong>请使用实际签署人的信息</strong>
|
||||
<p>
|
||||
本次签署人:{{ signerLine }}。文书内容已由医务人员填写完成,如与实际不符请联系现场医务人员。
|
||||
本次签署人:{{
|
||||
signerLine
|
||||
}}。文书内容已由医务人员填写完成,如与实际不符请联系现场医务人员。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,16 +6,19 @@ import { clearPersistedSession, resetSignFlow, signingState } from '@/composable
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const resultTitle = computed(() =>
|
||||
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||
? '签名已提交,文书生成中'
|
||||
: '知情同意书已提交',
|
||||
)
|
||||
const resultTitle = computed(() => {
|
||||
if (signingState.mode !== 'real') return '知情同意书已提交'
|
||||
if (signingState.backendStatus === 'GENERATING') return '签名已提交,文书生成中'
|
||||
if (signingState.backendStatus === 'WAITING_CONFIRM') return '签署已提交,等待科室确认'
|
||||
return '知情同意书已提交'
|
||||
})
|
||||
|
||||
const resultCopy = computed(() =>
|
||||
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||
? '签名图片已安全提交,系统正在生成签署后文书。最终状态以医签通服务端为准。'
|
||||
: '本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。',
|
||||
: signingState.mode === 'real' && signingState.backendStatus === 'WAITING_CONFIRM'
|
||||
? '患者签名和签署文书已安全保存,等待医务人员完成科室确认。请勿重复签署。'
|
||||
: '本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。',
|
||||
)
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
@@ -25,6 +28,7 @@ const statusLabel = computed(() => {
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
SIGNED: '已签署',
|
||||
WAITING_CONFIRM: '待科室确认',
|
||||
GENERATING: '生成中',
|
||||
WAITING_SIGN: '待签署',
|
||||
FAILED: '处理失败',
|
||||
|
||||
Reference in New Issue
Block a user