From e5f0a5ec97610455a14a33b55bd6c84a788029f3 Mon Sep 17 00:00:00 2001 From: xy Date: Wed, 30 Sep 2026 18:14:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(sign):=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E7=AD=BE=E5=90=8D=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E8=AF=86=E5=88=AB=E4=B8=8E=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MULTI_SIGNATURE.md | 25 +++ clinical-web/src/api/management/documents.ts | 32 ++-- clinical-web/src/api/workbench/artifacts.ts | 49 ++++++ clinical-web/src/api/workbench/deliveries.ts | 26 +++ .../src/composables/useSigningTaskForm.ts | 58 +++++-- clinical-web/src/utils/signing-document.ts | 2 +- .../components/SignaturePositionPreview.vue | 163 ++++++++++++++---- .../documents/components/WordEditorDialog.vue | 96 +++++++---- .../src/views/workbench/records/index.vue | 29 ++-- .../components/BatchSignatureDialog.vue | 96 +++++++++++ .../src/views/workbench/signing/index.vue | 110 ++++++------ patient-h5/README.md | 5 +- patient-h5/src/api/index.ts | 12 +- patient-h5/src/api/types.ts | 7 +- patient-h5/src/composables/useSignFlow.ts | 31 ++-- patient-h5/src/utils/signing-vault.ts | 5 +- patient-h5/src/views/signer/SignerView.vue | 105 ++++++++--- 17 files changed, 622 insertions(+), 229 deletions(-) create mode 100644 MULTI_SIGNATURE.md create mode 100644 clinical-web/src/views/workbench/signing/components/BatchSignatureDialog.vue diff --git a/MULTI_SIGNATURE.md b/MULTI_SIGNATURE.md new file mode 100644 index 0000000..6d677cf --- /dev/null +++ b/MULTI_SIGNATURE.md @@ -0,0 +1,25 @@ +# 多位置签名(第一版) + +## 使用 + +1. 在文书编辑器插入多个签名占位符,可自定义显示名称。重复插入同类签名时自动分配新的位置 ID。 +2. 分别选中位置:没有坐标时自动识别并填入;已有坐标时保留原值,可拖动预览阴影或手动修改数值。识别失败会显示具体原因并提供重试。最后保存、审核发布模板版本。 +3. 基于新版本发起任务。患者 H5、现场鼠标签署页均按位置提供独立画布。 +4. 填写所需位置后点击确认,核对已填写数量,再完成整次签署。没有必签、角色限制或任选组规则;可部分填写,也可全部留空后明确确认。 +5. 已签文书和签署记录按位置恢复图片。正式结果以服务端 PDF 为准。 + +已发布版本和既有任务不会自动增加签名位置,需发布新版本并重新发起任务。 + +## 接口与存储 + +- 新接口:`POST /api/v1/sign-deliveries/{taskId}/signatures`(multipart)。 +- `files`:零至多张 PNG,重复同名 part;`metadata`:与文件顺序一一对应的 JSON 数组,每项包含 `fieldId`、图片尺寸、大小、SHA-256、MIME,可附带模板坐标断言。 +- `deliveryId`、`uploadToken`、`signer` 与原接口一致;`Idempotency-Key` 必填。`signer` 继续记录本次任务的确认信息,不用于判断各位置由哪个角色签署。 +- 未提交的位置留空;空数组表示用户明确确认无图片的签署。服务端按模板 ID 校验,再从最终排版读取各位置的真实坐标,整组成功后提交任务状态。现有科室复核配置仍适用。 +- 保留旧的单图片 `/signature` 接口兼容已有调用方。 +- 为兼容现有三类产物事务和故障清理,多个 PNG 的像素无损合并为一张证据图(`SIGNATURE_IMAGE`)。产物 metadata 的 `signatures` 数组记录每张图片的原始摘要、证据图裁剪区域和实际 PDF 位置。正式 PDF 使用各自原图合成;前端从证据图恢复各位置图片。空确认保存空列表和透明 1px 证据图。 +- 单图限 5 MiB,请求图片合计限 20 MiB;证据图最大 4096×4096、5 MiB。位置 ID 不可重复,已提交的位置必须能在正文中唯一定位。 + +## 验证 + +后端 `MultiSignatureTest` 覆盖不同页面的两张签名实际像素、部分签名、空确认、重复/非法位置及存储失败不完成任务。模板定位测试覆盖多个独立 ID 和重复 ID 拒绝。 diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index 5a0c83c..27d9722 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -137,27 +137,21 @@ export function readVersionContentHtml(dto: TemplateVersionResponseDto | null): * 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。 * * 正文填写项和 PDF 签名几何是两个独立契约:formFields 负责字段语义, - * signatureFields 只负责患者签名图在服务端生成 PDF 中的位置。 + * signatureFields 负责各签名图在服务端生成 PDF 中的位置。 */ export function buildSignatureFieldsPayload(contentHtml: string): unknown[] { - const field = collectFieldDefs(contentHtml).find((item) => item.id === 'signature') - if (!field || field.kind !== 'signature') { - throw new Error('请在正文中插入“患者签名”占位符') - } - if (!field.pdfPosition) { - throw new Error('请先为“患者签名”配置 PDF 页码和签名位置') - } - return [ - { - id: field.id, - page: field.pdfPosition.page, - x: field.pdfPosition.x, - y: field.pdfPosition.y, - width: field.pdfPosition.width, - height: field.pdfPosition.height, - coordinateSystem: field.pdfPosition.coordinateSystem, - }, - ] + const fields = collectFieldDefs(contentHtml).filter((field) => field.kind === 'signature') + if (!fields.length) throw new Error('请在正文中插入签名占位符') + const document = new DOMParser().parseFromString(contentHtml, 'text/html') + const ids = Array.from(document.querySelectorAll('[data-kind="signature"][data-field]')).map( + (node) => node.getAttribute('data-field'), + ) + if (new Set(ids).size !== ids.length) + throw new Error('签名占位符标识重复,请删除重复位置后重新插入') + return fields.map((field) => { + if (!field.pdfPosition) throw new Error(`请先为“${field.label}”配置 PDF 签名位置`) + return { id: field.id, ...field.pdfPosition } + }) } export function buildFormFieldsPayload(contentHtml: string): unknown[] { diff --git a/clinical-web/src/api/workbench/artifacts.ts b/clinical-web/src/api/workbench/artifacts.ts index 979e5fb..53776e4 100644 --- a/clinical-web/src/api/workbench/artifacts.ts +++ b/clinical-web/src/api/workbench/artifacts.ts @@ -230,3 +230,52 @@ export async function fetchSignatureDataUrl(artifact: SigningArtifact): Promise< return '' } } + +/** Restore each position from the lossless evidence sheet; legacy images remain supported. */ +export async function fetchSignatureImages( + artifact: SigningArtifact, +): Promise> { + const source = await fetchSignatureDataUrl(artifact) + if (!source) return {} + const entries = artifact.metadata.signatures + if (!Array.isArray(entries)) + return { [String(artifact.metadata.signatureFieldId || 'signature')]: source } + if (!entries.length) return {} + const image = new Image() + image.src = source + await image.decode() + const result: Record = {} + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue + const { field, sourceX, sourceY, imageWidth, imageHeight } = entry + if ( + typeof field?.id !== 'string' || + ![sourceX, sourceY, imageWidth, imageHeight].every(Number.isInteger) || + sourceX < 0 || + sourceY < 0 || + imageWidth <= 0 || + imageHeight <= 0 || + sourceX + imageWidth > image.naturalWidth || + sourceY + imageHeight > image.naturalHeight + ) + continue + const canvas = document.createElement('canvas') + canvas.width = imageWidth + canvas.height = imageHeight + const context = canvas.getContext('2d') + if (!context) continue + context.drawImage( + image, + sourceX, + sourceY, + imageWidth, + imageHeight, + 0, + 0, + imageWidth, + imageHeight, + ) + result[field.id] = canvas.toDataURL('image/png') + } + return result +} diff --git a/clinical-web/src/api/workbench/deliveries.ts b/clinical-web/src/api/workbench/deliveries.ts index d869bb0..c5bf540 100644 --- a/clinical-web/src/api/workbench/deliveries.ts +++ b/clinical-web/src/api/workbench/deliveries.ts @@ -128,6 +128,32 @@ export function uploadSigningSignature( .then(unwrapApiResponse) } +export function uploadSigningSignatures( + payload: Omit & { + signatures: { file: Blob; metadata: string }[] + }, +): Promise { + const formData = new FormData() + payload.signatures.forEach((item, index) => + formData.append('files', item.file, `signature-${index}.png`), + ) + appendJsonPart( + formData, + 'metadata', + payload.signatures.map((item) => JSON.parse(item.metadata) as unknown), + ) + appendJsonPart(formData, 'deliveryId', payload.deliveryId) + formData.append('uploadToken', payload.uploadToken) + appendSigner(formData, payload.signer) + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signatures`, + formData, + { headers: idempotencyHeaders(payload.idempotencyKey) }, + ) + .then(unwrapApiResponse) +} + /** * PAD 专用兼容路径。服务端要求同时提交 PAD 元数据和真实 PNG,旧的 JSON-only 提交会返回 409。 * 具体的 challenge、文件摘要和 storageKey 由设备适配层生成,页面不应伪造这些值。 diff --git a/clinical-web/src/composables/useSigningTaskForm.ts b/clinical-web/src/composables/useSigningTaskForm.ts index bed500c..0e628e2 100644 --- a/clinical-web/src/composables/useSigningTaskForm.ts +++ b/clinical-web/src/composables/useSigningTaskForm.ts @@ -273,6 +273,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const initialTemplate = getInitialTemplate() if ( + isSigningMockEnabled && initialTemplate && !templates.value.some((template) => template.id === initialTemplate.id) ) { @@ -396,23 +397,24 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { return } - if (loadedTemplateCampusId !== campusId) { - if (loadedTemplateCampusId) { - selectedTemplateId.value = '' - selectedDepartment.value = '' - selectedCategory.value = '' - } + if (loadedTemplateCampusId && loadedTemplateCampusId !== campusId) { + selectedTemplateId.value = '' + selectedDepartment.value = '' + selectedCategory.value = '' + } - loadingTemplates.value = true - - try { - templates.value = await getSigningTemplates({ campusId }) - loadedTemplateCampusId = campusId - } catch { - ElMessage.error('文档模板加载失败,请稍后重试') - } finally { - loadingTemplates.value = false - } + loadingTemplates.value = true + try { + // Publishing replaces the available version even within the same campus. + const available = await getSigningTemplates({ campusId }) + if (toValue(options.campusId) !== campusId) return + templates.value = available + loadedTemplateCampusId = campusId + } catch { + templates.value = [] + ElMessage.error('文档模板加载失败,请稍后重试') + } finally { + loadingTemplates.value = false } includeInitialTemplate() @@ -526,7 +528,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { return true } - return collectFieldDefs(template.contentHtml).some((field) => field.id === 'signature') + return collectFieldDefs(template.contentHtml).some((field) => field.kind === 'signature') } const signatureMissingMessage = '该文书没有签名占位符,请先在文档库中补上签名位再发起' @@ -651,6 +653,28 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { submitting.value = true try { + if (!isSigningMockEnabled) { + const selected = selectedTemplate.value + const campusId = toValue(options.campusId) + if (!campusId) throw new Error('请先选择院区') + const available = await getSigningTemplates({ campusId }) + if ( + toValue(options.campusId) !== campusId || + selectedTemplate.value?.versionId !== selected.versionId + ) + throw new Error('院区或文书选择已变化,请核对后重新提交') + const current = available.find((template) => template.id === selected.id) + if (!current || current.versionId !== selected.versionId) { + templates.value = available + Object.keys(staffAnswers).forEach((key) => delete staffAnswers[key]) + createTaskIdempotencyKey = newIdempotencyKey() + errors.document = current + ? `文书已更新为 ${current.version},请核对新版正文并重新填写后再发起签署。` + : '该文书已停用或暂无可用版本,请重新选择文书。' + ElMessage.warning(errors.document) + return null + } + } const task = await createSigningTask({ campus: toValue(options.campus), patientId: patient.value.id, diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index 244636f..195cfe4 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -519,7 +519,7 @@ function renderStructuredFields( readonly ? { ...field, required: false } : field, answers, interactive && !readonly, - signatureActionable && (!readonly || field.id === PRIMARY_SIGNATURE_FIELD_ID), + signatureActionable && (!readonly || field.kind === 'signature'), ) element.replaceWith(widget) }) diff --git a/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue b/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue index e9026cd..2ccd044 100644 --- a/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue +++ b/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue @@ -1,59 +1,114 @@ diff --git a/clinical-web/src/views/workbench/records/index.vue b/clinical-web/src/views/workbench/records/index.vue index 26de302..21351ce 100644 --- a/clinical-web/src/views/workbench/records/index.vue +++ b/clinical-web/src/views/workbench/records/index.vue @@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus' import { getCampuses, getDepartments } from '@/api/management/organization' import { downloadSigningArtifact, - fetchSignatureDataUrl, + fetchSignatureImages, getSigningArtifacts, } from '@/api/workbench/artifacts' import { mockCatalogCategories, mockCatalogDepartments } from '@/api/workbench/mock-catalog' @@ -121,9 +121,7 @@ function canRead(permission: string) { } const canView = computed(() => canRead('sign:record:view')) -const canExport = computed( - () => !loginStore.isReadOnly && canRead('sign:record:export'), -) +const canExport = computed(() => !loginStore.isReadOnly && canRead('sign:record:export')) const canViewArtifacts = computed(() => canRead('sign:artifact:view')) const canDownloadArtifact = computed(() => canRead('sign:task:download')) @@ -135,7 +133,15 @@ const campusOptions = computed[] = [ { label: '全部状态', value: 'all' }, ...( - ['pending', 'signing', 'confirming', 'signed', 'expired', 'void', 'failed'] as SigningTaskStatus[] + [ + 'pending', + 'signing', + 'confirming', + 'signed', + 'expired', + 'void', + 'failed', + ] as SigningTaskStatus[] ).map((status) => ({ label: statusLabels[status], value: status })), ] @@ -151,10 +157,7 @@ const methodOptions: SigningRecordFilterOption[] = [ */ const departmentOptions = computed(() => { const names = isSigningRecordsMockEnabled ? mockCatalogDepartments : departmentNames.value - return [ - { label: '全部科室', value: '' }, - ...names.map((name) => ({ label: name, value: name })), - ] + return [{ label: '全部科室', value: '' }, ...names.map((name) => ({ label: name, value: name }))] }) const categoryOptions = computed(() => { @@ -311,17 +314,17 @@ async function openRecord(row: SigningRecordRow) { ) if (signatureArtifact) { - const signatureDataUrl = await fetchSignatureDataUrl(signatureArtifact) + const signatureImages = await fetchSignatureImages(signatureArtifact) if (requestId !== detailRequestId) { return } - if (signatureDataUrl) { + if (Object.keys(signatureImages).length) { record = { ...record, - signatureDataUrl, - fieldAnswers: { ...(record.fieldAnswers ?? {}), signature: signatureDataUrl }, + signatureDataUrl: signatureImages.signature, + fieldAnswers: { ...(record.fieldAnswers ?? {}), ...signatureImages }, } } } diff --git a/clinical-web/src/views/workbench/signing/components/BatchSignatureDialog.vue b/clinical-web/src/views/workbench/signing/components/BatchSignatureDialog.vue new file mode 100644 index 0000000..6ba0642 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/BatchSignatureDialog.vue @@ -0,0 +1,96 @@ + + + diff --git a/clinical-web/src/views/workbench/signing/index.vue b/clinical-web/src/views/workbench/signing/index.vue index afc241e..086d33d 100644 --- a/clinical-web/src/views/workbench/signing/index.vue +++ b/clinical-web/src/views/workbench/signing/index.vue @@ -6,14 +6,14 @@ import { useRoute } from 'vue-router' import { getCampuses, getDepartments } from '@/api/management/organization' import { downloadSigningArtifact, - fetchSignatureDataUrl, + fetchSignatureImages, getSigningArtifacts, } from '@/api/workbench/artifacts' import { consumeSigningToken, createPadSigningSession, getSigningContext, - uploadSigningSignature, + uploadSigningSignatures, } from '@/api/workbench/deliveries' import { saveSigningForm, @@ -51,7 +51,6 @@ import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue' import { useAppStore } from '@/stores/app' import { useLoginStore } from '@/stores/login' import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document' -import { PRIMARY_SIGNATURE_FIELD_ID } from '@/utils/signing-fields' import { formatSigningMethodTitle, readSigningMethod, @@ -61,6 +60,8 @@ import { downloadSigningPdf } from '@/utils/signing-pdf' import OnlineSigningDialog from './components/OnlineSigningDialog.vue' import SignaturePadDialog from './components/SignaturePadDialog.vue' +import BatchSignatureDialog from './components/BatchSignatureDialog.vue' +import { collectFieldDefs } from '@/utils/signing-fields' import SignedDocumentDialog from './components/SignedDocumentDialog.vue' import SigningFilterBar from './components/SigningFilterBar.vue' import SigningTaskDetail from './components/SigningTaskDetail.vue' @@ -129,6 +130,14 @@ const campusIds = ref>({}) const campusNames = ref>({}) const departmentIds = ref>({}) const newDialogVisible = ref(false) +const initialSignatureImages = computed(() => + Object.fromEntries( + Object.entries(signingAnswers.value).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && entry[1].startsWith('data:image/png'), + ), + ), +) const signatureDialogVisible = ref(false) const signatureSubmitting = ref(false) const onlineDialogVisible = ref(false) @@ -365,10 +374,14 @@ async function loadTaskDetail(id: string | null) { ) if (signatureArtifact) { - const signatureDataUrl = await fetchSignatureDataUrl(signatureArtifact) + const signatureImages = await fetchSignatureImages(signatureArtifact) - if (requestId === detailRequestId && signatureDataUrl) { - selectedTask.value = { ...detail, signatureDataUrl } + if (requestId === detailRequestId) { + selectedTask.value = { + ...detail, + signatureDataUrl: signatureImages.signature, + fieldAnswers: { ...detail.fieldAnswers, ...signatureImages }, + } } } } @@ -540,39 +553,10 @@ async function assertSigningReady() { return false } - const pending = signingProgress.value?.pendingSignatures ?? [] - - if (!pending.length) { - return true - } - - try { - await ElMessageBox.confirm( - `文书中还有签名位未采集:${pending.join('、')}。可直接点击文中签名位完成,也可以继续采集主签名。`, - '还有签名位待采集', - { confirmButtonText: '继续签署', cancelButtonText: '返回采集', type: 'warning' }, - ) - return true - } catch { - return false - } + return true } -/** - * 点击文中签名位。 - * - * 分两条路 —— 原型 renderPreview 给每个签名位挂的 onclick 是 - * `if(curTask.method==='sms')openPhone();else openPad();`,且只挂在「待签署」任务上。 - * - * ① **主签名位**(患者签名,`PRIMARY_SIGNATURE_FIELD_ID`)正是任务级签署要采的那一笔, - * 与「手写板签署 / Pad 签署」按钮走的是同一件事,所以必须开**任务级**弹窗。 - * 这条不能少:Pad 方式的按钮只负责「把文书滚到签名位」(原型 openIpad 不弹窗), - * 真正落笔就是靠点这个签名位 —— 若这里只回灌一张图而不结束任务, - * Pad 任务就会永远停在「待签署」。 - * 短信任务照原型走患者端 H5 页(患者在自己手机上签,不是在这台电脑上画)。 - * ② 其余签名位(医师签名……)是本仓库在原型之外的扩展:它们只是文书上的填写项, - * 采完只回填该位置、不结束任务,所以走签名位弹窗。 - */ +/** 待签任务的所有签名位置进入统一采集页,填写后由用户确认完成。 */ async function handleSignatureRequest(request: SigningSignatureRequest) { const task = selectedTask.value @@ -580,7 +564,7 @@ async function handleSignatureRequest(request: SigningSignatureRequest) { return } - if (request.fieldId !== PRIMARY_SIGNATURE_FIELD_ID || task.status !== 'pending') { + if (task.status !== 'pending') { pendingSignatureField.value = request fieldSignatureDialogVisible.value = true return @@ -1150,7 +1134,7 @@ function buildSignatureMetadata( }) } -async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl: string) { +async function submitMousePadSignature(task: SigningTaskRecord, images: Record) { const session = mousePadSession.value if (!session?.token) { @@ -1161,7 +1145,6 @@ async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl if (formSaveError) throw new Error('填写内容未保存,请刷新后重试') const answerRevision = formDraft?.taskId === task.id ? formDraft.revision : task.formState?.revision - const image = decodeSignaturePng(signatureDataUrl) const consumed = await consumeSigningToken({ token: session.token, channel: 'PAD', @@ -1177,19 +1160,24 @@ async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl deliveryId: consumed.deliveryId, uploadToken: consumed.uploadToken, }) - const field = context.signatureFields.find((item) => item.id === PRIMARY_SIGNATURE_FIELD_ID) - - if (!field) { - throw new Error('模板没有患者签名位置,无法提交鼠标签名') - } - - const sha256 = await signatureSha256(image.blob) - await uploadSigningSignature({ + const signatures = await Promise.all( + Object.entries(images) + .filter(([, image]) => Boolean(image)) + .map(async ([id, dataUrl]) => { + const field = context.signatureFields.find((item) => item.id === id) + if (!field) throw new Error('签名位置不属于当前模板,请重新加载任务') + const image = decodeSignaturePng(dataUrl) + return { + file: image.blob, + metadata: buildSignatureMetadata(field, image, await signatureSha256(image.blob)), + } + }), + ) + await uploadSigningSignatures({ taskId: task.id, deliveryId: consumed.deliveryId, uploadToken: consumed.uploadToken, - file: image.blob, - metadata: buildSignatureMetadata(field, image, sha256), + signatures, signer: { ...createPatientSignerAssertion(context.signer), answerRevision }, }) @@ -1203,7 +1191,7 @@ async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl ) } -async function confirmPadSignature(signatureDataUrl: string) { +async function confirmPadSignature(images: Record) { const task = selectedTask.value if (!task) { @@ -1213,7 +1201,7 @@ async function confirmPadSignature(signatureDataUrl: string) { if (!isSigningMockEnabled) { signatureSubmitting.value = true try { - await submitMousePadSignature(task, signatureDataUrl) + await submitMousePadSignature(task, images) } catch (error) { signatureDialogVisible.value = false mousePadSession.value = null @@ -1230,9 +1218,9 @@ async function confirmPadSignature(signatureDataUrl: string) { try { const updated = await completeSigningTask( task.id, - signatureDataUrl, + images.signature || Object.values(images).find(Boolean) || '', signingSelection.value, - signingAnswers.value, + { ...signingAnswers.value, ...images }, ) await refreshTask(updated, signingCompletedMessage(updated)) signatureDialogVisible.value = false @@ -1441,15 +1429,15 @@ onMounted(() => { @close="newDialogVisible = false" @created="handleTaskCreated" /> - field.kind === 'signature', + ) " + :initial="initialSignatureImages" + :submitting="signatureSubmitting" @close="closeTaskPadSignature" @confirm="confirmPadSignature" /> diff --git a/patient-h5/README.md b/patient-h5/README.md index 11ceb4e..4a1b83c 100644 --- a/patient-h5/README.md +++ b/patient-h5/README.md @@ -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)。 diff --git a/patient-h5/src/api/index.ts b/patient-h5/src/api/index.ts index c829e0e..02545e1 100644 --- a/patient-h5/src/api/index.ts +++ b/patient-h5/src/api/index.ts @@ -41,12 +41,18 @@ export function uploadSigningSignature( payload: SignatureUploadInput, ): Promise { 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>( - `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`, + `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/${payload.signatures ? 'signatures' : 'signature'}`, formData, { headers: { diff --git a/patient-h5/src/api/types.ts b/patient-h5/src/api/types.ts index 5640622..82bbf3b 100644 --- a/patient-h5/src/api/types.ts +++ b/patient-h5/src/api/types.ts @@ -135,7 +135,7 @@ export interface SigningContextResponse { * 烙进正文 HTML 就查不出「谁在什么时候填了什么」。 */ staffAnswers?: Record | 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 diff --git a/patient-h5/src/composables/useSignFlow.ts b/patient-h5/src/composables/useSignFlow.ts index d9b5c3e..91feb74 100644 --- a/patient-h5/src/composables/useSignFlow.ts +++ b/patient-h5/src/composables/useSignFlow.ts @@ -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 { 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, diff --git a/patient-h5/src/utils/signing-vault.ts b/patient-h5/src/utils/signing-vault.ts index 24bbcc8..8785902 100644 --- a/patient-h5/src/utils/signing-vault.ts +++ b/patient-h5/src/utils/signing-vault.ts @@ -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 diff --git a/patient-h5/src/views/signer/SignerView.vue b/patient-h5/src/views/signer/SignerView.vue index 2cf9dd0..b3dce3c 100644 --- a/patient-h5/src/views/signer/SignerView.vue +++ b/patient-h5/src/views/signer/SignerView.vue @@ -1,15 +1,7 @@