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