feat(sign): 支持多位置签名并优化坐标识别与模板版本校验

This commit is contained in:
xy
2026-09-30 18:14:22 +08:00
parent 69825dfa0a
commit e5f0a5ec97
17 changed files with 622 additions and 229 deletions
+4 -1
View File
@@ -84,9 +84,12 @@ patient-h5 不直接访问医院完整电子病历。
已完成 Vite 基础初始化、路由、签署流程状态管理和第一版移动端页面骨架。Mock 模式可以演示完整交互;真实模式已经接入一次性 Token 消费和 PNG 签名上传请求,并增加路由保护、重复提交保护和服务端结果展示。正式文书公开读取接口尚未提供,因此真实模式暂不会进入签署步骤。
### 患者填写项
真实上下文响应新增 `form`(规范化正文、字段定义、答案、版本号、可编辑状态)。阅读页支持复选框、单选/多选、文本及日期输入,调用 `/v1/sign-deliveries/token/form` 自动保存;请求携带当前短期签署凭证、完整患者答案和 `expectedRevision`。凭证和患者答案不放在 URL 中。
进入签署确认前等待保存并检查必填项;最终上传携带 `signer.answerRevision`。恢复上传保留同一份 PNG、答案版本和幂等键。保存失败或版本冲突时需重新加载并核对内容;不会继续签署未保存的答案。签署确认页只读回显本次填写结果,签署后由服务端快照决定正文与 PDF 内容。
### 多位置签名
患者签署页支持各占位符独立手写、部分填写后统一确认,也允许全部留空后明确确认。接口、模板配置及证据存储说明见 [多位置签名说明](../MULTI_SIGNATURE.md)。
+9 -3
View File
@@ -41,12 +41,18 @@ export function uploadSigningSignature(
payload: SignatureUploadInput,
): Promise<SignatureUploadResponse> {
const formData = new FormData()
formData.append('file', payload.file, 'signature.png')
if (payload.signatures) {
for (const [index, signature] of payload.signatures.entries())
formData.append('files', signature.file, `signature-${index}.png`)
} else if (payload.file) formData.append('file', payload.file, 'signature.png')
formData.append('deliveryId', payload.deliveryId)
formData.append('uploadToken', payload.uploadToken)
formData.append(
'metadata',
new Blob([JSON.stringify(payload.metadata)], { type: 'application/json' }),
new Blob(
[JSON.stringify(payload.signatures?.map((item) => item.metadata) ?? payload.metadata)],
{ type: 'application/json' },
),
)
formData.append(
'signer',
@@ -55,7 +61,7 @@ export function uploadSigningSignature(
return request
.post<ApiResponse<SignatureUploadResponse>>(
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/${payload.signatures ? 'signatures' : 'signature'}`,
formData,
{
headers: {
+4 -3
View File
@@ -135,7 +135,7 @@ export interface SigningContextResponse {
* 烙进正文 HTML 就查不出「谁在什么时候填了什么」。
*/
staffAnswers?: Record<string, string> | null
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
/** 文书中的签名位置定义;不按角色筛选。 */
fields?: SigningContextField[] | null
/** Configured PDF signature locations, separate from body form-field definitions. */
signatureFields?: SignatureFieldPosition[] | null
@@ -175,8 +175,9 @@ export interface SignatureUploadInput {
taskId: string
deliveryId: string
uploadToken: string
file: Blob
metadata: SignaturePngMetadata
file?: Blob
metadata?: SignaturePngMetadata
signatures?: { file: Blob; metadata: SignaturePngMetadata }[]
signer: {
type: string
name: string
+17 -14
View File
@@ -355,12 +355,7 @@ function applyContext(context: SigningContextResponse) {
}
if (context.signatureFields) {
const patientField = context.signatureFields.find((field) => field.id === 'signature')
signingState.signatureFields = patientField
? [patientField]
: context.signatureFields.length === 1
? [...context.signatureFields]
: []
signingState.signatureFields = [...context.signatureFields]
}
if (context.status) {
@@ -582,7 +577,8 @@ async function consumeAndLoad(token: string, idempotencyKey: string) {
}
export async function submitRealSignature(input: {
file: Blob
file?: Blob
signatures?: { fieldId: string; file: Blob }[]
consentAccepted: boolean
}): Promise<SignatureUploadResponse | null> {
if (signingState.mode !== 'real') {
@@ -604,10 +600,6 @@ 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 || '本人'
@@ -615,8 +607,19 @@ export async function submitRealSignature(input: {
throw new Error('签署人信息不完整,请联系现场医务人员。')
}
const observedSignedAt = new Date().toISOString()
const metadata = await buildSignaturePngMetadata(input.file, signatureField)
const pending = await readPendingSigningUpload(signingState.taskId)
const signatures =
pending?.signatures ??
(await Promise.all(
(
input.signatures ??
(input.file ? [{ fieldId: signingState.signatureFields[0]!.id, file: input.file }] : [])
).map(async (item) => {
const field = signingState.signatureFields.find((field) => field.id === item.fieldId)
if (!field) throw new Error('签名位置不属于当前文书')
return { file: item.file, metadata: await buildSignaturePngMetadata(item.file, field) }
}),
))
const idempotencyKey = pending?.idempotencyKey ?? uploadIdempotencyKey ?? createIdempotencyKey()
uploadIdempotencyKey = idempotencyKey
if (!pending) await flushPatientAnswers()
@@ -635,8 +638,7 @@ export async function submitRealSignature(input: {
taskId: signingState.taskId,
deliveryId: signingState.deliveryId,
expiresAt: sessionExpiresAt,
file: input.file,
metadata: pending?.metadata ?? metadata,
signatures,
signer: pending?.signer ?? signer,
idempotencyKey,
savedAt: Date.now(),
@@ -650,6 +652,7 @@ export async function submitRealSignature(input: {
taskId: signingState.taskId,
deliveryId: signingState.deliveryId,
uploadToken: signingState.uploadToken,
signatures: payload.signatures,
file: payload.file,
metadata: payload.metadata,
signer: payload.signer,
+3 -2
View File
@@ -4,8 +4,9 @@ export interface PendingSigningUpload {
taskId: string
deliveryId: string
expiresAt: string | null
file: Blob
metadata: SignaturePngMetadata
file?: Blob
metadata?: SignaturePngMetadata
signatures?: { file: Blob; metadata: SignaturePngMetadata }[]
signer: {
type: string
name: string
+80 -25
View File
@@ -1,15 +1,7 @@
<script setup lang="ts">
/**
* 签署页。
*
* 按业务口径,**患者只签自己的名**:文书里的选项、医师签名都由医生在医护端填好,
* 因此这一页去掉了原来的「签署人姓名 + 与患者关系」填写 —— 那两项现在由医生填,
* 这里只做只读展示。
*
* 签名仍走 Canvas(`SignatureCanvas`)。将来接真实手写板设备时,换的是采集层
* (设备适配器),这一页的交互不用动。
*/
import { computed, ref } from 'vue'
/** 多位置独立采集,用户确认后整组提交。签名位置不区分角色、不设必签规则。 */
import { computed, ref, onMounted } from 'vue'
import { readPendingSigningUpload } from '@/utils/signing-vault'
import { useRouter } from 'vue-router'
import DocumentPaper from '@/components/DocumentPaper.vue'
@@ -17,7 +9,50 @@ import SignatureCanvas from '@/components/SignatureCanvas.vue'
import { signingState, submitRealSignature } from '@/composables/useSignFlow'
const router = useRouter()
const signatureDataUrl = ref('')
const signatureImages = ref<Record<string, string>>({})
const signatureDataUrl = computed(() => Object.values(signatureImages.value).find(Boolean) ?? '')
const signatureSlots = computed(() => {
const document = new DOMParser().parseFromString(signingState.documentContentHtml, 'text/html')
return Array.from(document.querySelectorAll<HTMLElement>('[data-field][data-kind=signature]'))
.map((node) => ({
id: node.dataset.field!,
label: node.dataset.label || node.textContent || '签名',
}))
.filter(
(field) =>
signingState.mode === 'mock' ||
signingState.signatureFields.some((position) => position.id === field.id),
)
})
const restoring = ref(true)
const retrying = ref(false)
async function restorePendingImages() {
if (signingState.mode !== 'real') return
const pending = await readPendingSigningUpload(signingState.taskId)
retrying.value = Boolean(pending?.signatures)
if (!pending?.signatures) return
const restored: Record<string, string> = {}
for (const item of pending.signatures) {
restored[item.metadata.fieldId!] = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = reject
reader.readAsDataURL(item.file)
})
}
signatureImages.value = {
...Object.fromEntries(signatureSlots.value.map((field) => [field.id, ''])),
...restored,
}
}
onMounted(() => {
signatureImages.value = Object.fromEntries(signatureSlots.value.map((field) => [field.id, '']))
void restorePendingImages()
.catch(() => undefined)
.finally(() => {
restoring.value = false
})
})
const errorMessage = ref('')
const submitting = ref(false)
@@ -54,10 +89,12 @@ function dataUrlToBlob(dataUrl: string) {
}
async function submitSigning() {
if (!signatureDataUrl.value) {
errorMessage.value = '请完成手写签名。'
if (
!window.confirm(
`本次已填写 ${Object.values(signatureImages.value).filter(Boolean).length} 处签名,确认完成签署?未填写的位置将留空。`,
)
)
return
}
if (signingState.mode === 'real') {
submitting.value = true
@@ -65,12 +102,15 @@ async function submitSigning() {
try {
await submitRealSignature({
file: dataUrlToBlob(signatureDataUrl.value),
signatures: Object.entries(signatureImages.value)
.filter(([, value]) => Boolean(value))
.map(([fieldId, value]) => ({ fieldId, file: dataUrlToBlob(value) })),
consentAccepted: signingState.consentAccepted,
})
await router.push('/result')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '签署提交失败,请稍后重试。'
await restorePendingImages().catch(() => undefined)
} finally {
submitting.value = false
}
@@ -97,7 +137,7 @@ async function submitSigning() {
<section class="mobile-heading">
<p class="eyebrow">签署确认</p>
<h1>请在文书签名处签名</h1>
<p>文书内容已由医务人员填写完成,您只需完成患者签名。</p>
<p>请按实际需要填写各处签名,确认后完成本次签署。</p>
</section>
<section class="mobile-card signer-summary">
@@ -119,24 +159,30 @@ async function submitSigning() {
<DocumentPaper
:font-family="signingState.documentFontFamily"
:content-html="signingState.documentContentHtml"
:answers="signingState.staffAnswers"
:answers="{ ...signingState.staffAnswers, ...signatureImages }"
:fields="signingState.form?.fields"
:patient-answers="signingState.form?.answers"
:patient-signature="signatureDataUrl || null"
:patient-signature="signatureImages.signature || null"
:signed-at="signedDate"
/>
<SignatureCanvas
:height="200"
placeholder="请在此签名"
@update:data-url="signatureDataUrl = $event ?? ''"
/>
<p v-if="retrying">已恢复上次提交的签名,请点击确认重试本次提交。</p>
<fieldset :disabled="submitting || restoring || retrying" class="signature-inputs">
<section v-for="slot in signatureSlots" :key="slot.id" class="mobile-card">
<h2>{{ slot.label }}</h2>
<SignatureCanvas
:height="200"
:placeholder="slot.label"
@update:data-url="signatureImages[slot.id] = $event ?? ''"
/>
</section>
</fieldset>
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
<button
class="mobile-primary-button"
type="button"
:disabled="submitting"
:disabled="submitting || restoring"
@click="submitSigning"
>
{{ submitting ? '提交中…' : '确认提交签署' }}
@@ -146,6 +192,15 @@ async function submitSigning() {
</template>
<style scoped>
.signature-inputs {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.signature-inputs:disabled {
pointer-events: none;
}
.signer-summary {
display: flex;
flex-direction: column;