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
@@ -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"
/>