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
+13 -19
View File
@@ -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[] {
@@ -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<Record<string, string>> {
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<string, string> = {}
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
}
@@ -128,6 +128,32 @@ export function uploadSigningSignature(
.then(unwrapApiResponse)
}
export function uploadSigningSignatures(
payload: Omit<SigningTokenUploadInput, 'file' | 'metadata'> & {
signatures: { file: Blob; metadata: string }[]
},
): Promise<SignatureUploadResponse> {
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<ApiResponseOf<SignatureUploadResponse>>(
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signatures`,
formData,
{ headers: idempotencyHeaders(payload.idempotencyKey) },
)
.then(unwrapApiResponse)
}
/**
* PAD 专用兼容路径。服务端要求同时提交 PAD 元数据和真实 PNG,旧的 JSON-only 提交会返回 409。
* 具体的 challenge、文件摘要和 storageKey 由设备适配层生成,页面不应伪造这些值。