feat(sign): 支持多位置签名并优化坐标识别与模板版本校验
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user