feat: 提交

This commit is contained in:
xy
2026-09-29 15:50:21 +08:00
parent c4363889b1
commit 8d8fa2af47
14 changed files with 911 additions and 40 deletions
@@ -5,10 +5,12 @@ const props = withDefaults(
defineProps<{
height?: number
placeholder?: string
disabled?: boolean
}>(),
{
height: 300,
placeholder: '请 在 此 签 名',
disabled: false,
},
)
@@ -66,7 +68,7 @@ function getPoint(event: PointerEvent) {
}
function startDrawing(event: PointerEvent) {
if (!context || !canvas.value) {
if (props.disabled || !context || !canvas.value) {
return
}
@@ -80,7 +82,7 @@ function startDrawing(event: PointerEvent) {
}
function draw(event: PointerEvent) {
if (!drawing || !context) {
if (props.disabled || !drawing || !context) {
return
}
@@ -150,6 +152,7 @@ onBeforeUnmount(() => {
<div class="signature-canvas" :style="{ height: `${height}px` }">
<canvas
ref="canvas"
:aria-disabled="disabled"
@pointercancel="stopDrawing"
@pointerdown="startDrawing"
@pointerleave="stopDrawing"
@@ -178,6 +181,10 @@ onBeforeUnmount(() => {
cursor: crosshair;
}
.signature-canvas canvas[aria-disabled='true'] {
cursor: not-allowed;
}
.signature-placeholder {
position: absolute;
inset: 0;
@@ -23,11 +23,19 @@ const props = withDefaults(
/** 说明这一笔签名要落在哪里,避免医师签在了患者签名位 */
hint?: string
confirmLabel?: string
/** 在真实模式下要求先显式启用鼠标输入,避免把本地画布误当成硬件已连接。 */
mouseTestRequired?: boolean
/** 真实患者签署需由签署人明确确认知情同意。 */
patientConsentRequired?: boolean
submitting?: boolean
}>(),
{
title: undefined,
hint: '请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):',
confirmLabel: '确认签署',
mouseTestRequired: false,
patientConsentRequired: false,
submitting: false,
},
)
@@ -44,13 +52,24 @@ const emit = defineEmits<{
const titleId = useId()
const signatureDataUrl = ref<string | null>(null)
const mouseTestActive = ref(false)
const patientConsentAccepted = ref(false)
const signatureCanvas = ref<{ clear: () => void } | null>(null)
const signatureInputEnabled = computed(() => !props.mouseTestRequired || mouseTestActive.value)
const signatureHint = computed(() => {
if (!props.mouseTestRequired) return props.hint
return mouseTestActive.value
? '鼠标测试已启用。请让任务中登记的签署人使用鼠标完成签名,签名确认后将提交并生成正式签署文件。'
: '点击“鼠标测试”启用鼠标签名;测试画布中的笔迹将作为本次正式签名提交。'
})
watch(
() => props.visible,
async (visible) => {
if (visible) {
signatureDataUrl.value = null
mouseTestActive.value = !props.mouseTestRequired
patientConsentAccepted.value = false
await nextTick()
signatureCanvas.value?.clear()
}
@@ -62,7 +81,22 @@ function clearSignature() {
signatureDataUrl.value = null
}
function startMouseTest() {
mouseTestActive.value = true
clearSignature()
}
function confirmSignature() {
if (props.mouseTestRequired && !mouseTestActive.value) {
ElMessage.warning('请先点击“鼠标测试”启用鼠标签名')
return
}
if (props.patientConsentRequired && !patientConsentAccepted.value) {
ElMessage.warning('请先确认签署人已阅读并同意签署')
return
}
if (!signatureDataUrl.value) {
ElMessage.warning('请先完成签名')
return
@@ -84,26 +118,61 @@ function confirmSignature() {
<header class="dialog-header">
<strong :id="titleId">{{ dialogTitle }}</strong>
<span>{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
<button
type="button"
class="dialog-close"
aria-label="关闭"
:disabled="submitting"
@click="emit('close')"
>
×
</button>
</header>
<div class="dialog-body">
<p class="dialog-hint">{{ hint }}</p>
<div v-if="mouseTestRequired" class="mouse-test-control">
<span>{{ mouseTestActive ? '鼠标输入已启用' : '鼠标签名尚未启用' }}</span>
<button
type="button"
class="action-button action-button--ghost"
:disabled="submitting"
@click="startMouseTest"
>
{{ mouseTestActive ? '重新测试鼠标' : '鼠标测试' }}
</button>
</div>
<p class="dialog-hint">{{ signatureHint }}</p>
<SignatureCanvas
ref="signatureCanvas"
:height="300"
:disabled="!signatureInputEnabled"
:placeholder="signatureInputEnabled ? '请 在 此 签 名' : '点击鼠标测试启用签名'"
@update:data-url="signatureDataUrl = $event"
/>
<label v-if="patientConsentRequired" class="consent-control">
<input v-model="patientConsentAccepted" type="checkbox" />
签署人已阅读文书内容并同意签署
</label>
</div>
<footer class="dialog-footer">
<span>签名将实时合成到文档签名域</span>
<span>{{
mouseTestRequired ? '鼠标绘制的签名将进入正式签署流水线' : '签名将实时合成到文档签名域'
}}</span>
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
<button
type="button"
class="action-button action-button--ghost"
:disabled="submitting"
@click="clearSignature"
>
清空重写
</button>
<button type="button" class="action-button" @click="confirmSignature">
{{ confirmLabel }}
<button
type="button"
class="action-button"
:disabled="submitting"
@click="confirmSignature"
>
{{ submitting ? '正在提交…' : confirmLabel }}
</button>
</footer>
</section>
@@ -177,6 +246,31 @@ function confirmSignature() {
font-size: 12px;
}
.mouse-test-control {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: var(--mut);
font-size: 12px;
}
.consent-control {
display: flex;
gap: 8px;
align-items: center;
margin-top: 12px;
color: var(--ink);
font-size: 13px;
}
.consent-control input {
width: 16px;
height: 16px;
accent-color: var(--brand);
}
.dialog-footer {
display: flex;
gap: 10px;
@@ -206,6 +300,12 @@ function confirmSignature() {
background: var(--brand-d);
}
.action-button:disabled,
.dialog-close:disabled {
cursor: wait;
opacity: 0.65;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
@@ -30,6 +30,8 @@ const props = defineProps<{
auditLoading: boolean
auditError: boolean
allowLocalSigning: boolean
/** 真实模式下允许有签署权限的用户通过浏览器鼠标采集现场手写签名。 */
allowMouseSigning: boolean
artifacts: SigningArtifact[]
artifactsLoading: boolean
artifactsError: boolean
@@ -188,9 +190,16 @@ defineExpose({ getDocumentElement, locateSignatureSlot })
手写板 → 直接开签名板;Pad → 把文书滚到签名位等高亮;短信 → 开「发送签署短信」;
公众号 → 开「消息推送」 -->
<template v-if="isOnsiteMethod(task.method)">
<template v-if="allowLocalSigning">
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
{{ formatSigningMethodTitle(task.method) }}
<template v-if="allowLocalSigning || allowMouseSigning">
<button
type="button"
class="action-button"
:disabled="padSessionLoading"
@click="emit('action', 'pad-sign')"
>
{{
padSessionLoading ? '正在创建签署会话…' : formatSigningMethodTitle(task.method)
}}
</button>
</template>
<template v-else-if="canSignTask">
@@ -9,7 +9,12 @@ import {
fetchSignatureDataUrl,
getSigningArtifacts,
} from '@/api/workbench/artifacts'
import { createPadSigningSession } from '@/api/workbench/deliveries'
import {
consumeSigningToken,
createPadSigningSession,
getSigningContext,
uploadSigningSignature,
} from '@/api/workbench/deliveries'
import {
completeSigningTask,
confirmDepartmentSigning,
@@ -32,7 +37,9 @@ import type {
SigningDateRange,
SigningMethod,
SigningArtifact,
PatientSignerAssertionInput,
SignDeliveryResponseDto,
SigningContextSignatureFieldDto,
SigningTaskEvent,
SigningTaskRecord,
SigningTaskStatus,
@@ -116,11 +123,13 @@ const artifactsLoading = ref(false)
const artifactsError = ref(false)
const padSessionStatus = ref<string | null>(null)
const padSessionLoading = ref(false)
const mousePadSession = ref<{ deliveryId: string; token: string } | null>(null)
const campusIds = ref<Record<string, string>>({})
const campusNames = ref<Record<string, WorkbenchCampus>>({})
const departmentIds = ref<Record<string, string>>({})
const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const signatureSubmitting = ref(false)
const onlineDialogVisible = ref(false)
/** 「发送签署短信」弹窗(原型 #mdSmsFlow) */
const smsSendVisible = ref(false)
@@ -281,6 +290,8 @@ async function loadTaskDetail(id: string | null) {
fieldSignatureInjections.value = {}
pendingSignatureField.value = null
fieldSignatureDialogVisible.value = false
signatureDialogVisible.value = false
mousePadSession.value = null
// 预览弹窗跟着任务走:换任务时留在上一份文书的预览上会让人以为看的是当前任务
signedPreviewVisible.value = false
@@ -294,6 +305,7 @@ async function loadTaskDetail(id: string | null) {
artifactsLoading.value = false
artifactsError.value = false
padSessionStatus.value = null
mousePadSession.value = null
return
}
@@ -305,6 +317,7 @@ async function loadTaskDetail(id: string | null) {
taskEvents.value = []
artifacts.value = []
padSessionStatus.value = null
mousePadSession.value = null
try {
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
@@ -508,9 +521,38 @@ async function handleSignatureRequest(request: SigningSignatureRequest) {
return
}
await openTaskPadSignature(task)
}
async function openTaskPadSignature(task: SigningTaskRecord) {
mousePadSession.value = null
if (!isSigningMockEnabled) {
padSessionLoading.value = true
try {
const session = await createPadSigningSession(task.id, { deviceId: 'browser-mouse-test' })
if (!session.token) {
throw new Error('后端未返回手写板会话令牌')
}
mousePadSession.value = { deliveryId: session.deliveryId, token: session.token }
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '鼠标测试会话创建失败,请稍后重试')
return
} finally {
padSessionLoading.value = false
}
}
signatureDialogVisible.value = true
}
function closeTaskPadSignature() {
signatureDialogVisible.value = false
mousePadSession.value = null
}
function closeFieldSignatureDialog() {
fieldSignatureDialogVisible.value = false
pendingSignatureField.value = null
@@ -588,11 +630,6 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
}
if (action === 'pad-sign') {
if (!isSigningMockEnabled) {
ElMessage.info(`${readSigningMethod(task.method).short}设备签署接口待接入`)
return
}
/**
* 原型把两种现场方式分得很清楚:
* - 手写板 → `openPad()`,直接开签名板;
@@ -613,7 +650,7 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
return
}
signatureDialogVisible.value = true
await openTaskPadSignature(task)
return
}
@@ -947,6 +984,147 @@ async function downloadArtifact(artifact: SigningArtifact) {
}
}
function decodeSignaturePng(dataUrl: string) {
const match = /^data:image\/png;base64,([\s\S]+)$/.exec(dataUrl)
if (!match) {
throw new Error('签名画布没有生成有效的 PNG 图片')
}
const binary = atob(match[1])
const buffer = new ArrayBuffer(binary.length)
const bytes = new Uint8Array(buffer)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]
if (pngSignature.some((value, index) => bytes[index] !== value) || bytes.length < 24) {
throw new Error('签名 PNG 格式无效')
}
const view = new DataView(buffer)
const width = view.getUint32(16, false)
const height = view.getUint32(20, false)
if (!width || !height) {
throw new Error('签名图片尺寸无效')
}
return {
blob: new Blob([buffer], { type: 'image/png' }),
width,
height,
}
}
async function signatureSha256(blob: Blob) {
if (!globalThis.crypto?.subtle) {
throw new Error('当前浏览器环境不支持签名校验,请通过 HTTPS 或 localhost 访问')
}
const digest = await globalThis.crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join('')
}
function createPatientSignerAssertion(signer: {
type: string | null
name: string | null
relation: string | null
}): PatientSignerAssertionInput {
const type = signer.type?.trim().toUpperCase()
if (type !== 'SELF' && type !== 'FAMILY') {
throw new Error('任务中没有有效的签署人类型,请刷新任务后重试')
}
const name = signer.name?.trim() ?? ''
const relation = signer.relation?.trim() ?? ''
if (!name || !relation) {
throw new Error('任务中缺少签署人姓名或关系,请检查任务信息')
}
return {
type,
name,
relation,
consentAccepted: true,
observedSignedAt: new Date().toISOString(),
}
}
function buildSignatureMetadata(
field: SigningContextSignatureFieldDto,
image: { blob: Blob; width: number; height: number },
sha256: string,
) {
return JSON.stringify({
fieldId: field.id,
page: field.page,
x: field.x,
y: field.y,
width: field.width,
height: field.height,
coordinateSystem: field.coordinateSystem,
imageWidth: image.width,
imageHeight: image.height,
sha256,
contentType: 'image/png',
sizeBytes: image.blob.size,
})
}
async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl: string) {
const session = mousePadSession.value
if (!session?.token) {
throw new Error('手写板会话已失效,请重新打开鼠标签署')
}
const image = decodeSignaturePng(signatureDataUrl)
const consumed = await consumeSigningToken({
token: session.token,
channel: 'PAD',
taskId: task.id,
})
if (!consumed.uploadToken) {
throw new Error('签署会话未返回一次性上传授权')
}
const context = await getSigningContext({
taskId: task.id,
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({
taskId: task.id,
deliveryId: consumed.deliveryId,
uploadToken: consumed.uploadToken,
file: image.blob,
metadata: buildSignatureMetadata(field, image, sha256),
signer: createPatientSignerAssertion(context.signer),
})
signatureDialogVisible.value = false
mousePadSession.value = null
await loadTasks(task.id, true)
ElMessage.success(
task.needConfirm
? '现场鼠标签署完成,任务已流转至科室确认节点'
: '鼠标签署完成,签署文件已生成',
)
}
async function confirmPadSignature(signatureDataUrl: string) {
const task = selectedTask.value
@@ -954,6 +1132,23 @@ async function confirmPadSignature(signatureDataUrl: string) {
return
}
if (!isSigningMockEnabled) {
signatureSubmitting.value = true
try {
await submitMousePadSignature(task, signatureDataUrl)
} catch (error) {
signatureDialogVisible.value = false
mousePadSession.value = null
ElMessage.error(
error instanceof Error ? `鼠标签署失败:${error.message}` : '鼠标签署失败,请稍后重试',
)
} finally {
signatureSubmitting.value = false
}
return
}
signatureSubmitting.value = true
try {
const updated = await completeSigningTask(
task.id,
@@ -965,6 +1160,8 @@ async function confirmPadSignature(signatureDataUrl: string) {
signatureDialogVisible.value = false
} catch {
ElMessage.error('手写签署失败,请稍后重试')
} finally {
signatureSubmitting.value = false
}
}
@@ -1136,6 +1333,7 @@ onMounted(() => {
:audit-loading="eventsLoading"
:audit-error="eventsError"
:allow-local-signing="isSigningMockEnabled"
:allow-mouse-signing="canSignTask && selectedTask?.method === 'pad'"
:artifacts="artifacts"
:artifacts-loading="artifactsLoading"
:artifacts-error="artifactsError"
@@ -1166,7 +1364,13 @@ onMounted(() => {
<SignaturePadDialog
:visible="signatureDialogVisible"
:task="selectedTask"
@close="signatureDialogVisible = false"
:mouse-test-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
:patient-consent-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
:submitting="signatureSubmitting"
:confirm-label="
!isSigningMockEnabled && selectedTask?.method === 'pad' ? '提交鼠标签署' : '确认签署'
"
@close="closeTaskPadSignature"
@confirm="confirmPadSignature"
/>
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->