feat: 提交
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->
|
||||
|
||||
Reference in New Issue
Block a user