feat(sign): 支持多位置签名并优化坐标识别与模板版本校验
This commit is contained in:
@@ -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 拒绝。
|
||||
@@ -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 由设备适配层生成,页面不应伪造这些值。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+126
-37
@@ -1,59 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
import { getSignaturePreview } from '@/api/management/signature-preview'
|
||||
import type { SignaturePosition, SignaturePreview } from '@/api/management/signature-preview'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldId: string
|
||||
templateId: string
|
||||
contentHtml: string
|
||||
position: SignaturePosition
|
||||
positionEmpty: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ change: [value: SignaturePosition] }>()
|
||||
const result = ref<SignaturePreview | null>(null)
|
||||
const busy = ref(false)
|
||||
const previewVisible = ref(true)
|
||||
const message = ref('')
|
||||
const renderedHtml = ref('')
|
||||
const generation = ref(0)
|
||||
const stale = computed(() => renderedHtml.value !== props.contentHtml)
|
||||
watch(
|
||||
() => props.templateId,
|
||||
() => {
|
||||
generation.value++
|
||||
result.value = null
|
||||
renderedHtml.value = ''
|
||||
},
|
||||
)
|
||||
async function render(detect: boolean) {
|
||||
const html = props.contentHtml
|
||||
// Position metadata does not affect PDF layout. Persisting detected/dragged coordinates
|
||||
// must not invalidate the preview or start another detection request.
|
||||
const previewHtml = computed(() => {
|
||||
const document = new DOMParser().parseFromString(props.contentHtml, 'text/html')
|
||||
for (const node of document.querySelectorAll('[data-field]')) {
|
||||
for (const attribute of Array.from(node.attributes)) {
|
||||
if (attribute.name.startsWith('data-pdf-')) node.removeAttribute(attribute.name)
|
||||
}
|
||||
}
|
||||
return document.documentElement.outerHTML
|
||||
})
|
||||
const stale = computed(() => renderedHtml.value !== previewHtml.value)
|
||||
const failed = ref(false)
|
||||
const detecting = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function render() {
|
||||
clearTimeout(timer)
|
||||
const html = previewHtml.value
|
||||
const id = props.templateId
|
||||
const fieldId = props.fieldId
|
||||
const detect = props.positionEmpty
|
||||
const originalPosition = JSON.stringify(props.position)
|
||||
const current = ++generation.value
|
||||
busy.value = true
|
||||
detecting.value = detect
|
||||
failed.value = false
|
||||
message.value = ''
|
||||
try {
|
||||
const response = await getSignaturePreview(id, html)
|
||||
if (current !== generation.value || html !== props.contentHtml || id !== props.templateId)
|
||||
if (
|
||||
current !== generation.value ||
|
||||
html !== previewHtml.value ||
|
||||
id !== props.templateId ||
|
||||
fieldId !== props.fieldId
|
||||
)
|
||||
return
|
||||
result.value = response
|
||||
renderedHtml.value = html
|
||||
if (detect) {
|
||||
if (response.candidates.length === 1) {
|
||||
emit('change', response.candidates[0]!)
|
||||
message.value = '已识别患者签名占位符,请检查阴影位置并微调。'
|
||||
// A user may enter coordinates while the request is running. Never overwrite those edits.
|
||||
if (detect && props.positionEmpty && originalPosition === JSON.stringify(props.position)) {
|
||||
const candidates = response.candidates.filter((field) => field.id === fieldId)
|
||||
if (candidates.length === 1) {
|
||||
emit('change', candidates[0]!)
|
||||
message.value = '已自动填入签名位置,可拖动阴影或修改上方数值。'
|
||||
} else {
|
||||
message.value = response.candidates.length
|
||||
? '发现多个患者签名占位符,请保留唯一占位符后重新识别。'
|
||||
: '未找到可定位的患者签名占位符,请先在正文中插入。'
|
||||
failed.value = true
|
||||
message.value = candidates.length
|
||||
? '此签名位置的标识重复,请删除重复占位符后重试。'
|
||||
: '未找到此签名位置,请确认正文中已插入签名占位符后重试。'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.value = 'PDF 预览失败,请检查正文格式后重试。'
|
||||
} catch (error) {
|
||||
if (current !== generation.value) return
|
||||
failed.value = true
|
||||
message.value = toUserMessage(
|
||||
error,
|
||||
detect ? '自动识别失败,请重试或手动填写位置。' : '预览加载失败,请重试。',
|
||||
)
|
||||
} finally {
|
||||
if (current === generation.value) busy.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (props.position.page > 0 && props.position.width > 0 && props.position.height > 0)
|
||||
void render(false)
|
||||
|
||||
watch(
|
||||
() => [props.templateId, props.fieldId, previewHtml.value, props.positionEmpty] as const,
|
||||
(value, previous) => {
|
||||
const selectionChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
|
||||
const contentChanged = !previous || value[2] !== previous[2]
|
||||
if (!selectionChanged && !contentChanged && !value[3]) return
|
||||
clearTimeout(timer)
|
||||
generation.value++
|
||||
busy.value = false
|
||||
failed.value = false
|
||||
message.value = ''
|
||||
if (selectionChanged) {
|
||||
result.value = null
|
||||
renderedHtml.value = ''
|
||||
}
|
||||
if (selectionChanged || props.positionEmpty) {
|
||||
busy.value = true
|
||||
detecting.value = props.positionEmpty
|
||||
// Coalesce editor changes, field selection and draft synchronisation into one request.
|
||||
timer = setTimeout(() => void render(), 200)
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(timer)
|
||||
generation.value++
|
||||
})
|
||||
function overlay(page: SignaturePreview['pages'][number]) {
|
||||
const p = props.position
|
||||
@@ -118,17 +173,34 @@ function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||||
</script>
|
||||
<template>
|
||||
<section class="signature-preview">
|
||||
<button type="button" :disabled="busy" @click="render(true)">
|
||||
{{ busy ? '生成中…' : '自动识别患者签名' }}
|
||||
</button>
|
||||
<button type="button" :disabled="busy" @click="render(false)">生成 / 刷新 PDF 预览</button>
|
||||
<p>使用示例患者数据生成 PDF;修改坐标实时更新阴影,可拖动阴影调整位置,宽高在上方输入。</p>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
<p v-if="result && stale" class="warning">正文已变化,请刷新 PDF 预览并重新确认位置。</p>
|
||||
<p v-if="result && !valid" class="warning">
|
||||
签名区域无效、页码不存在或超出页面,请调整后再保存。
|
||||
<div class="preview-toolbar">
|
||||
<div>
|
||||
<strong>签名位置预览</strong>
|
||||
<p v-if="busy">{{ detecting ? '正在自动识别签名位置…' : '正在加载预览…' }}</p>
|
||||
<p v-else-if="positionEmpty">尚未配置位置,将根据正文中的签名占位符自动填写。</p>
|
||||
<p v-else>已保留当前坐标,可拖动阴影或修改上方数值。</p>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
:aria-expanded="previewVisible"
|
||||
@click="previewVisible = !previewVisible"
|
||||
>
|
||||
{{ previewVisible ? '隐藏预览' : '显示预览' }}
|
||||
</button>
|
||||
<button v-if="!busy" type="button" @click="render">
|
||||
{{ positionEmpty ? '重试自动识别' : failed ? '重试加载预览' : '刷新预览' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="message" :class="{ warning: failed }" :role="failed ? 'alert' : 'status'">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div v-if="result && !stale" class="pages">
|
||||
<p v-if="result && stale" class="warning">正文已变化,请刷新预览并手动检查位置。</p>
|
||||
<p v-if="result && !stale && !positionEmpty && !valid" class="warning">
|
||||
签名区域无效、页码不存在或超出页面,请调整上方数值。
|
||||
</p>
|
||||
<div v-if="result && !stale" v-show="previewVisible" class="pages">
|
||||
<div v-for="page in result.pages" :key="page.page">
|
||||
<p>
|
||||
第 {{ page.page }} 页({{ page.width.toFixed(1) }} × {{ page.height.toFixed(1) }} pt)
|
||||
@@ -141,7 +213,7 @@ function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||||
:style="overlay(page)"
|
||||
@pointerdown.prevent="move($event, page)"
|
||||
>
|
||||
患者签名
|
||||
签名
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,10 +225,27 @@ function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||||
padding: 12px;
|
||||
border: 1px solid #cadce4;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfc;
|
||||
}
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-toolbar strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
p {
|
||||
|
||||
@@ -76,9 +76,12 @@ const message = reactive({ text: '', tone: '' as '' | 'ok' | 'warn' })
|
||||
* 那种位置必须有占位符,否则签署时不会回填(系统不会另外在文书上插患者信息)。
|
||||
*/
|
||||
const AUTO_FILL_LABELS = ['患者姓名', '性别', '年龄', '就诊号', '与患者关系', '签署日期']
|
||||
const MANUAL_LABELS = ['患者签名', '勾选项']
|
||||
const MANUAL_LABELS = ['签名', '勾选项']
|
||||
|
||||
function findPreset(label: string): SigningFieldDef | null {
|
||||
if (label === '签名') {
|
||||
return { id: 'signature', label, kind: 'signature', required: false, options: [] }
|
||||
}
|
||||
return SIGNING_FIELD_PRESETS.find((field) => field.label === label) ?? null
|
||||
}
|
||||
|
||||
@@ -139,6 +142,29 @@ const fieldDraft = reactive({
|
||||
pdfCoordinateSystem: 'TOP_LEFT' as 'TOP_LEFT' | 'PDF_BOTTOM_LEFT',
|
||||
})
|
||||
|
||||
const signaturePreviewHtml = computed(() => {
|
||||
const document = new DOMParser().parseFromString(valueHtml.value, 'text/html')
|
||||
// A field converted to “signature” must be locatable before its first coordinates exist.
|
||||
for (const node of document.querySelectorAll('[data-field]')) {
|
||||
if (
|
||||
node.getAttribute('data-field') === selectedFieldId.value &&
|
||||
fieldDraft.kind === 'signature'
|
||||
)
|
||||
node.setAttribute('data-kind', 'signature')
|
||||
}
|
||||
return document.documentElement.outerHTML
|
||||
})
|
||||
|
||||
const signaturePositionEmpty = computed(() =>
|
||||
[
|
||||
fieldDraft.pdfPage,
|
||||
fieldDraft.pdfX,
|
||||
fieldDraft.pdfY,
|
||||
fieldDraft.pdfWidth,
|
||||
fieldDraft.pdfHeight,
|
||||
].every((value) => !value.trim()),
|
||||
)
|
||||
|
||||
const previewPosition = computed<SignaturePosition>(() => ({
|
||||
page: Number(fieldDraft.pdfPage),
|
||||
x: Number(fieldDraft.pdfX),
|
||||
@@ -154,6 +180,12 @@ function changePosition(position: SignaturePosition) {
|
||||
fieldDraft.pdfWidth = String(position.width)
|
||||
fieldDraft.pdfHeight = String(position.height)
|
||||
fieldDraft.pdfCoordinateSystem = position.coordinateSystem
|
||||
// Keep this position when switching to another field; other draft settings still require “更新该项”.
|
||||
if (selectedField.value)
|
||||
paneRef.value?.updateField(selectedField.value.id, {
|
||||
kind: fieldDraft.kind,
|
||||
pdfPosition: position,
|
||||
})
|
||||
}
|
||||
|
||||
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
|
||||
@@ -240,9 +272,16 @@ function selectFieldFromEditor(field: SigningFieldDef) {
|
||||
function insertField(field: SigningFieldDef) {
|
||||
// 自定义文本项每插一个就是独立填写项,字段键必须当场取一个没被占用的
|
||||
// (见 allocateCustomFieldId)——两个文本框是两处要填的内容,共用一个键才是错的
|
||||
const target = field.id.startsWith('custom:')
|
||||
? { ...field, id: allocateCustomFieldId(valueHtml.value, field.label) }
|
||||
: field
|
||||
const target =
|
||||
field.kind === 'signature' && detectedFields.value.some((item) => item.id === field.id)
|
||||
? {
|
||||
...field,
|
||||
id: `sign_${globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`,
|
||||
required: false,
|
||||
}
|
||||
: field.id.startsWith('custom:')
|
||||
? { ...field, id: allocateCustomFieldId(valueHtml.value, field.label) }
|
||||
: field
|
||||
|
||||
paneRef.value?.insertField(target)
|
||||
}
|
||||
@@ -272,7 +311,7 @@ function applyFieldDraft() {
|
||||
const options = fieldDraft.options.map((option) => option.trim()).filter(Boolean)
|
||||
|
||||
let pdfPosition: SigningFieldDef['pdfPosition']
|
||||
if (field.id === 'signature' && fieldDraft.kind === 'signature') {
|
||||
if (fieldDraft.kind === 'signature') {
|
||||
const page = Number(fieldDraft.pdfPage)
|
||||
const x = Number(fieldDraft.pdfX)
|
||||
const y = Number(fieldDraft.pdfY)
|
||||
@@ -292,7 +331,7 @@ function applyFieldDraft() {
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
setMessage('患者签名位置须填写有效页码及 PDF point 坐标', 'warn')
|
||||
setMessage('签名位置须填写有效页码及坐标', 'warn')
|
||||
return false
|
||||
}
|
||||
pdfPosition = { page, x, y, width, height, coordinateSystem: fieldDraft.pdfCoordinateSystem }
|
||||
@@ -378,9 +417,12 @@ function validate(requireSignaturePosition = true): string | null {
|
||||
return `「${broken.label}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
|
||||
}
|
||||
|
||||
const patientSignature = detectedFields.value.find((field) => field.id === 'signature')
|
||||
if (requireSignaturePosition && !patientSignature?.pdfPosition) {
|
||||
return '请选中“患者签名”占位符,配置其 PDF 页码和签名位置后再保存'
|
||||
const signatures = detectedFields.value.filter((field) => field.kind === 'signature')
|
||||
if (
|
||||
requireSignaturePosition &&
|
||||
(!signatures.length || signatures.some((field) => !field.pdfPosition))
|
||||
) {
|
||||
return '请为每个签名占位符识别并保存 PDF 签名位置'
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -391,8 +433,8 @@ const saveHints = computed(() => {
|
||||
const ids = new Set(detectedFields.value.map((field) => field.id))
|
||||
const hints: string[] = []
|
||||
|
||||
if (!ids.has('signature')) {
|
||||
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「患者签名」')
|
||||
if (!detectedFields.value.some((field) => field.kind === 'signature')) {
|
||||
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「签名」')
|
||||
}
|
||||
|
||||
if (!ids.has('signedDate')) {
|
||||
@@ -476,7 +518,7 @@ async function save() {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedField.value?.id === 'signature') {
|
||||
if (selectedField.value?.kind === 'signature') {
|
||||
if (!applyFieldDraft()) return
|
||||
await nextTick()
|
||||
valueHtml.value = paneRef.value?.getHtml() ?? valueHtml.value
|
||||
@@ -601,15 +643,6 @@ function requestClose() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SignaturePositionPreview
|
||||
v-if="template && selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
|
||||
:key="template.id"
|
||||
:template-id="template.id"
|
||||
:content-html="valueHtml"
|
||||
:position="previewPosition"
|
||||
@change="changePosition"
|
||||
/>
|
||||
|
||||
<div v-if="isOptionKind" class="fp-row">
|
||||
<b class="fp-title">选项设置(支持多个)</b>
|
||||
<span class="fp-hint">选项逐项展示为可勾选框;勾选类默认可多选,选择类默认单选</span>
|
||||
@@ -623,11 +656,8 @@ function requestClose() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
|
||||
class="fp-row signature-position-row"
|
||||
>
|
||||
<b class="fp-title">患者签名 PDF 位置</b>
|
||||
<div v-if="fieldDraft.kind === 'signature'" class="fp-row signature-position-row">
|
||||
<b class="fp-title">签名位置</b>
|
||||
<span class="fp-hint">PDF point;页码从 1 开始</span>
|
||||
<label class="fp-field"
|
||||
><span>页码</span><input v-model="fieldDraft.pdfPage" inputmode="numeric"
|
||||
@@ -654,11 +684,13 @@ function requestClose() {
|
||||
</div>
|
||||
|
||||
<SignaturePositionPreview
|
||||
v-if="template && selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
|
||||
:key="template.id"
|
||||
v-if="template && fieldDraft.kind === 'signature'"
|
||||
:key="`${template.id}:${selectedField.id}`"
|
||||
:field-id="selectedField.id"
|
||||
:template-id="template.id"
|
||||
:content-html="valueHtml"
|
||||
:content-html="signaturePreviewHtml"
|
||||
:position="previewPosition"
|
||||
:position-empty="signaturePositionEmpty"
|
||||
@change="changePosition"
|
||||
/>
|
||||
|
||||
@@ -724,16 +756,14 @@ function requestClose() {
|
||||
:class="`is-${button.field.kind}`"
|
||||
:title="
|
||||
button.field.kind === 'signature'
|
||||
? '签署时由患者手写采集,签名图落在文书原位置'
|
||||
? '插入独立签名位置,可改名为患者签名、家属签名或医生签名'
|
||||
: '签署时由患者逐项勾选;点正文里的胶囊可配置多个选项'
|
||||
"
|
||||
@click="insertField(button.field)"
|
||||
>
|
||||
[{{ button.label }}]
|
||||
</button>
|
||||
<span class="pb-note">
|
||||
签名 / 勾选由患者在签署时填写;其余签署时按患者信息自动回填
|
||||
</span>
|
||||
<span class="pb-note"> 签名可重复插入并自定义名称;勾选项在签署时填写 </span>
|
||||
</div>
|
||||
</template>
|
||||
</EditorWangPane>
|
||||
|
||||
@@ -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<SigningRecordFilterOption<WorkbenchCampus | 'all'
|
||||
const statusOptions: SigningRecordFilterOption<SigningRecordFilterForm['status']>[] = [
|
||||
{ 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<SigningMethod | 'all'>[] = [
|
||||
*/
|
||||
const departmentOptions = computed<SigningRecordFilterOption[]>(() => {
|
||||
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<SigningRecordFilterOption[]>(() => {
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import SignatureCanvas from './SignatureCanvas.vue'
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
fields: { id: string; label: string }[]
|
||||
initial: Record<string, string>
|
||||
submitting: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; confirm: [images: Record<string, string>] }>()
|
||||
const images = ref<Record<string, string>>({})
|
||||
const accepted = ref(false)
|
||||
const canvasVersions = ref<Record<string, number>>({})
|
||||
function clearField(id: string) {
|
||||
images.value[id] = ''
|
||||
canvasVersions.value[id] = (canvasVersions.value[id] ?? 0) + 1
|
||||
}
|
||||
function capture(id: string, value: string | null) {
|
||||
// Canvas mount/resize emits null; clearing a position is an explicit action below.
|
||||
if (value) images.value[id] = value
|
||||
}
|
||||
const count = computed(() => Object.values(images.value).filter(Boolean).length)
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
images.value = { ...props.initial }
|
||||
accepted.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
async function confirm() {
|
||||
if (!accepted.value || props.submitting) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`本次已填写 ${count.value} 处签名,未填写的位置将留空。确认完成签署?`,
|
||||
'确认签署',
|
||||
{
|
||||
confirmButtonText: '确认完成',
|
||||
cancelButtonText: '返回填写',
|
||||
},
|
||||
)
|
||||
emit('confirm', { ...images.value })
|
||||
} catch {
|
||||
/* User returned to signing. */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="填写签名并确认"
|
||||
width="680px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="!submitting"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<p>请按实际需要填写各处签名,确认后统一生成签署文件。</p>
|
||||
<fieldset :disabled="submitting" class="signature-fields">
|
||||
<section v-for="field in fields" :key="field.id">
|
||||
<h3>{{ field.label }}</h3>
|
||||
<img v-if="images[field.id]" :src="images[field.id]" class="captured" alt="已采集签名" />
|
||||
<SignatureCanvas
|
||||
:key="canvasVersions[field.id] ?? 0"
|
||||
@update:data-url="capture(field.id, $event)"
|
||||
/>
|
||||
<el-button text @click="clearField(field.id)">清除此位置</el-button>
|
||||
</section>
|
||||
</fieldset>
|
||||
<el-checkbox v-model="accepted" :disabled="submitting"
|
||||
>已核对文书及本次填写的签名,确认提交</el-checkbox
|
||||
>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitting" @click="emit('close')">返回</el-button>
|
||||
<el-button type="primary" :disabled="!accepted" :loading="submitting" @click="confirm"
|
||||
>确认完成签署</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.signature-fields {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
max-height: 55vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.signature-fields:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
.captured {
|
||||
max-width: 160px;
|
||||
max-height: 70px;
|
||||
}
|
||||
</style>
|
||||
@@ -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<Record<string, string>>({})
|
||||
const campusNames = ref<Record<string, WorkbenchCampus>>({})
|
||||
const departmentIds = ref<Record<string, string>>({})
|
||||
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<string, string>) {
|
||||
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<string, string>) {
|
||||
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"
|
||||
/>
|
||||
<SignaturePadDialog
|
||||
<BatchSignatureDialog
|
||||
:visible="signatureDialogVisible"
|
||||
:task="selectedTask"
|
||||
:mouse-test-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
|
||||
:patient-consent-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
|
||||
:submitting="signatureSubmitting"
|
||||
:confirm-label="
|
||||
!isSigningMockEnabled && selectedTask?.method === 'pad' ? '提交鼠标签署' : '确认签署'
|
||||
:fields="
|
||||
collectFieldDefs(selectedTask?.documentContentHtml ?? '').filter(
|
||||
(field) => field.kind === 'signature',
|
||||
)
|
||||
"
|
||||
:initial="initialSignatureImages"
|
||||
:submitting="signatureSubmitting"
|
||||
@close="closeTaskPadSignature"
|
||||
@confirm="confirmPadSignature"
|
||||
/>
|
||||
|
||||
@@ -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