diff --git a/clinical-web/README.md b/clinical-web/README.md index 351360a..6e24097 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -267,3 +267,5 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana 生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省使用演示数据的只有签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)与「报表分析」目录下的四个菜单(`VITE_MOCK_REPORT_ANALYTICS`,后端没有分析聚合接口),其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。 + +文档库正文编辑器:选中「患者签名」占位符后,可以自动识别 PDF 位置,或生成 PDF 预览后输入坐标。阴影框支持拖动定位,宽高输入即时更新;点击「更新该项」或保存正文写入位置。正文变化后需重新生成预览。预览使用示例患者,实际患者文字长度可能影响排版,建议为填写项预留固定空间。重复的患者签名占位符不会自动选取。后端在保存及发布时检查页码与页面边界。 diff --git a/clinical-web/src/api/management/signature-preview.ts b/clinical-web/src/api/management/signature-preview.ts new file mode 100644 index 0000000..ba624e0 --- /dev/null +++ b/clinical-web/src/api/management/signature-preview.ts @@ -0,0 +1,20 @@ +import { request } from '@/utils/request' +import { unwrapApiResponse } from '@/utils/api-response' +import type { ApiResponseOf, SigningFieldDef } from '@/api/workbench/types' + +export type SignaturePosition = NonNullable +export interface SignaturePreview { + pages: { page: number; width: number; height: number; image: string }[] + candidates: (SignaturePosition & { id: string })[] +} +export async function getSignaturePreview( + templateId: string, + contentHtml: string, +): Promise { + const response = await request.post>( + `/v1/templates/${encodeURIComponent(templateId)}/signature-preview`, + { contentHtml }, + { timeout: 60000 }, + ) + return unwrapApiResponse(response) +} diff --git a/clinical-web/src/api/workbench/deliveries.ts b/clinical-web/src/api/workbench/deliveries.ts index 56fdfdc..d869bb0 100644 --- a/clinical-web/src/api/workbench/deliveries.ts +++ b/clinical-web/src/api/workbench/deliveries.ts @@ -3,7 +3,10 @@ import { request } from '@/utils/request' import type { ApiResponseOf, + SigningContextRequest, + SigningContextResponseDto, PadBindingRequest, + PatientSignerAssertionInput, PadSessionUploadInput, SendSmsDeliveryRequest, SignDeliveryResponseDto, @@ -77,6 +80,14 @@ export function consumeSigningToken( .then(unwrapApiResponse) } +export function getSigningContext( + payload: SigningContextRequest, +): Promise { + return request + .post>('/v1/sign-deliveries/token/context', payload) + .then(unwrapApiResponse) +} + function createSignatureFormData(file: File | Blob, metadata: string) { const formData = new FormData() const fileName = @@ -86,20 +97,31 @@ function createSignatureFormData(file: File | Blob, metadata: string) { return formData } +function appendJsonPart(formData: FormData, name: string, value: unknown) { + formData.append( + name, + new Blob([JSON.stringify(value)], { type: 'application/json' }), + `${name}.json`, + ) +} + +function appendSigner(formData: FormData, signer: PatientSignerAssertionInput) { + appendJsonPart(formData, 'signer', signer) +} + export function uploadSigningSignature( payload: SigningTokenUploadInput, ): Promise { const formData = createSignatureFormData(payload.file, payload.metadata) + appendJsonPart(formData, 'deliveryId', payload.deliveryId) + formData.append('uploadToken', payload.uploadToken) + appendSigner(formData, payload.signer) return request .post>( `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`, formData, { - params: { - deliveryId: payload.deliveryId, - uploadToken: payload.uploadToken, - }, headers: idempotencyHeaders(payload.idempotencyKey), }, ) @@ -140,14 +162,13 @@ export function uploadSigningSignatureByProof( payload: SigningTokenUploadInput, ): Promise { const formData = createSignatureFormData(payload.file, payload.metadata) + appendJsonPart(formData, 'taskId', payload.taskId) + appendJsonPart(formData, 'deliveryId', payload.deliveryId) + formData.append('uploadToken', payload.uploadToken) + appendSigner(formData, payload.signer) return request .post>('/v1/sign-deliveries/token/signature', formData, { - params: { - taskId: payload.taskId, - deliveryId: payload.deliveryId, - uploadToken: payload.uploadToken, - }, headers: idempotencyHeaders(payload.idempotencyKey), }) .then(unwrapApiResponse) diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 44732f7..5a9efec 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -633,6 +633,43 @@ export interface TokenConsumeResponse { timestamp?: string | null } +export interface SigningContextRequest { + taskId: string + deliveryId: string + uploadToken: string +} + +export interface SigningContextSignatureFieldDto { + id: string + page: number + x: number + y: number + width: number + height: number + coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT' +} + +export interface SigningContextResponseDto { + taskId: string + deliveryId: string + channel: SignDeliveryChannel + status: BackendSigningTaskStatus + signer: { + type: string | null + name: string | null + relation: string | null + } + signatureFields: SigningContextSignatureFieldDto[] +} + +export interface PatientSignerAssertionInput { + type: 'SELF' | 'FAMILY' + name: string + relation: string + consentAccepted: true + observedSignedAt: string +} + export type SignatureUploadStatus = BackendSigningTaskStatus export interface SignatureUploadResponse { @@ -661,6 +698,7 @@ export interface SignatureUploadInput { export interface SigningTokenUploadInput extends Omit { taskId: string + signer: PatientSignerAssertionInput } export interface PadSessionUploadInput extends SignatureUploadInput { diff --git a/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue b/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue new file mode 100644 index 0000000..e9026cd --- /dev/null +++ b/clinical-web/src/views/management/documents/components/SignaturePositionPreview.vue @@ -0,0 +1,197 @@ + + + diff --git a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue index 717edb6..a88102a 100644 --- a/clinical-web/src/views/management/documents/components/WordEditorDialog.vue +++ b/clinical-web/src/views/management/documents/components/WordEditorDialog.vue @@ -1,5 +1,5 @@ + + + + diff --git a/clinical-web/src/views/management/settings/index.vue b/clinical-web/src/views/management/settings/index.vue index 181f2aa..d615b7c 100644 --- a/clinical-web/src/views/management/settings/index.vue +++ b/clinical-web/src/views/management/settings/index.vue @@ -20,6 +20,7 @@ import { useLoginStore } from '@/stores/login' import DeviceSettingsCard from './components/DeviceSettingsCard.vue' import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue' import EmbedSettingsCard from './components/EmbedSettingsCard.vue' +import MousePadTestDialog from './components/MousePadTestDialog.vue' import MenuResourceSettings from './components/MenuResourceSettings.vue' import PushSettingsCard from './components/PushSettingsCard.vue' import SignatureSettingsCard from './components/SignatureSettingsCard.vue' @@ -109,6 +110,13 @@ function sendTestSms() { /* ---------------- 签署设备管理 ---------------- */ const devices = ref(mockSignatureDevices.map((device) => ({ ...device }))) +const mouseTestVisible = ref(false) +const mouseTestDeviceName = ref('') + +function openMouseTest(device: SignatureDevice) { + mouseTestDeviceName.value = `${device.model} · ${device.location}` + mouseTestVisible.value = true +} function operateDevice(device: SignatureDevice) { if (device.kind === 'pad') { @@ -286,6 +294,7 @@ function removeDocType(name: string) { v-else-if="activeSection === 'device'" :devices="devices" @operate="operateDevice" + @mouse-test="openMouseTest" /> 确认轮换 + + diff --git a/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue b/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue index 43ca89a..57c2c4e 100644 --- a/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue +++ b/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue @@ -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(() => {
{ cursor: crosshair; } +.signature-canvas canvas[aria-disabled='true'] { + cursor: not-allowed; +} + .signature-placeholder { position: absolute; inset: 0; diff --git a/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue b/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue index aab0b75..5247db7 100644 --- a/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue +++ b/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue @@ -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(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() {
{{ dialogTitle }} {{ task.patientName }} · {{ task.documentName }} -
-

{{ hint }}

+
+ {{ mouseTestActive ? '鼠标输入已启用' : '鼠标签名尚未启用' }} + +
+

{{ signatureHint }}

+
- 签名将实时合成到文档签名域 + {{ + mouseTestRequired ? '鼠标绘制的签名将进入正式签署流水线' : '签名将实时合成到文档签名域' + }} - -
@@ -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; diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index d300317..a604892 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -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 → 把文书滚到签名位等高亮;短信 → 开「发送签署短信」; 公众号 → 开「消息推送」 -->