From 9173d623a1744386f94604bca5dcf2cb32d47b36 Mon Sep 17 00:00:00 2001 From: cnxianyi Date: Tue, 29 Sep 2026 21:19:14 +0800 Subject: [PATCH] feat: enable patient form interactions and revision-aware signing --- clinical-web/README.md | 7 + clinical-web/src/api/workbench/signing.ts | 39 ++++-- clinical-web/src/api/workbench/types.ts | 19 +++ .../src/components/document/DocumentPaper.vue | 35 ++++- .../src/composables/useSigningTaskForm.ts | 6 +- clinical-web/src/utils/signing-document.ts | 33 +++-- .../signing/components/SigningTaskDetail.vue | 7 +- .../src/views/workbench/signing/index.vue | 80 +++++++++++- patient-h5/README.md | 7 + patient-h5/src/api/index.ts | 16 +++ patient-h5/src/api/types.ts | 21 +++ patient-h5/src/components/DocumentPaper.vue | 122 +++++++++++++++--- patient-h5/src/composables/useSignFlow.ts | 68 +++++++++- patient-h5/src/types/signing.ts | 4 + patient-h5/src/utils/signing-vault.ts | 1 + patient-h5/src/views/consent/ConsentView.vue | 33 ++++- patient-h5/src/views/signer/SignerView.vue | 2 + 17 files changed, 451 insertions(+), 49 deletions(-) diff --git a/clinical-web/README.md b/clinical-web/README.md index 6e24097..0b4eb35 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -269,3 +269,10 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana 当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。 文档库正文编辑器:选中「患者签名」占位符后,可以自动识别 PDF 位置,或生成 PDF 预览后输入坐标。阴影框支持拖动定位,宽高输入即时更新;点击「更新该项」或保存正文写入位置。正文变化后需重新生成预览。预览使用示例患者,实际患者文字长度可能影响排版,建议为填写项预留固定空间。重复的患者签名占位符不会自动选取。后端在保存及发布时检查页码与页面边界。 + + +### 工作台患者填写项 + +真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。 + +旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。 diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 867366f..684caf9 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -37,6 +37,7 @@ import type { SignTaskResponseDto, SignTaskSendPreparationResponseDto, SigningAnswers, + SigningFormState, SigningApiOptions, SigningMethod, SigningTaskEvent, @@ -917,16 +918,21 @@ function mapSigningTask( export async function hydrateSigningTaskContent( task: SigningTaskRecord, ): Promise { - if (task.documentContentHtml || useMockData || !task.templateVersionId) { - return task - } - - const contentHtml = await fetchTemplateContentByVersion( - task.templateVersionId, - task.contentSha256, + if (useMockData || task.formState) return task + const response = await request.get>( + `/v1/sign-tasks/${encodeURIComponent(task.id)}/form`, ) - - return contentHtml ? { ...task, documentContentHtml: contentHtml } : task + const form = unwrapApiResponse(response) + const fieldAnswers: SigningAnswers = { ...form.staffAnswers, ...form.answers } + for (const field of form.fields) { + const value = fieldAnswers[field.id] + if (field.editable || typeof value !== 'string') continue + if (field.options.length && field.multi) + fieldAnswers[field.id] = value.split('、').filter(Boolean) + else if (field.kind === 'checkbox' && !field.options.length) + fieldAnswers[field.id] = !['', 'false', '0', 'off'].includes(value.toLowerCase()) + } + return { ...task, documentContentHtml: form.contentHtml, fieldAnswers, formState: form } } function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate { @@ -1157,8 +1163,6 @@ export async function getSigningTaskDetail( `/v1/sign-tasks/${encodeURIComponent(id)}`, ) const task = mapSigningTask(unwrapApiResponse(response), options) - // 任务接口不返回正文,这里按哈希把正文快照补回;取不到就保持空, - // 由详情组件提示"后端未提供正文",不退化成前端写死的回退纸样 return hydrateSigningTaskContent(task) } catch (error) { if (isNotFoundError(error)) { @@ -1731,3 +1735,16 @@ export function getSigningStatusLabel(status: SigningTaskStatus) { export function getVisitTypeLabel(type: VisitType) { return type } + +export async function saveSigningForm( + id: string, + expectedRevision: number, + answers: SigningAnswers, +): Promise { + return request + .post>(`/v1/sign-tasks/${encodeURIComponent(id)}/form`, { + expectedRevision, + answers, + }) + .then(unwrapApiResponse) +} diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 5a9efec..df9a377 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -334,6 +334,7 @@ export interface SigningTaskRecord extends WorkbenchTask { * 文书填写项取值(含签名图),签署时固化。 * 重新打开文书时用它回显,签名因此落在文书原本的签名位上。 */ + formState?: SigningFormState fieldAnswers?: SigningAnswers } @@ -663,6 +664,7 @@ export interface SigningContextResponseDto { } export interface PatientSignerAssertionInput { + answerRevision?: number type: 'SELF' | 'FAMILY' name: string relation: string @@ -775,3 +777,20 @@ export interface SigningTaskEvent { } export type ApiResponseOf = ApiResponse + +export interface SigningFormState { + contentHtml: string + fields: { + id: string + label: string + kind: string + required: boolean + multi: boolean + options: string[] + editable: boolean + }[] + answers: SigningAnswers + staffAnswers: Record + revision: number + editable: boolean +} diff --git a/clinical-web/src/components/document/DocumentPaper.vue b/clinical-web/src/components/document/DocumentPaper.vue index 084f6a2..9f62afd 100644 --- a/clinical-web/src/components/document/DocumentPaper.vue +++ b/clinical-web/src/components/document/DocumentPaper.vue @@ -123,6 +123,28 @@ function syncFromDom() { emit('selectionChange', [...selected]) } +/** Repeated occurrences of one field must share the same answer. */ +function syncEditedField(target: EventTarget | null) { + const root = containerRef.value + const host = target instanceof Element ? target.closest('[data-sign-field]') : null + const id = host?.getAttribute('data-sign-field') + if (root && host && id) { + const wrapper = document.createElement('div') + wrapper.append(host.cloneNode(true)) + const value = readAnswersFromRoot(wrapper)[id] + if (value !== undefined) + root.querySelectorAll('[data-sign-field]').forEach((other) => { + if (other !== host && other.getAttribute('data-sign-field') === id) + applyAnswerToFieldHost(other, value) + }) + } + syncFromDom() +} + +function handleInput(event: Event) { + if (props.interactive) syncEditedField(event.target) +} + /** 按已固化的取值还原 DOM 状态(存量勾选表 + 填写项都覆盖) */ function applyStoredValues() { const container = containerRef.value @@ -201,7 +223,7 @@ function handleDocumentClick(event: MouseEvent) { const container = containerRef.value if (option && container && toggleOptionAt(container, option)) { - syncFromDom() + syncEditedField(option) return } } @@ -219,6 +241,13 @@ function handleDocumentKeydown(event: KeyboardEvent) { return } + const option = + event.target instanceof Element ? event.target.closest(OPTION_TICK_SELECTOR) : null + if (option && containerRef.value && toggleOptionAt(containerRef.value, option)) { + event.preventDefault() + syncEditedField(option) + return + } const host = resolveSignatureHost(event.target) if (host) { @@ -285,8 +314,8 @@ defineExpose({ getDocumentElement })
field.kind !== 'signature' && !systemFields.has(field.id), + (field) => + field.kind !== 'signature' && + field.kind !== 'checkbox' && + field.kind !== 'choice' && + !systemFields.has(field.id), ) }) diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index d65f4f3..b1a1d78 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -271,13 +271,15 @@ function buildOptionItem( displayValue: string = itemValue, ): HTMLElement { const item = doc.createElement('span') - item.className = [ - OPTION_CLASS, - on ? OPTION_ON_CLASS : '', - interactive ? OPTION_TICK_CLASS : '', - ] + item.className = [OPTION_CLASS, on ? OPTION_ON_CLASS : '', interactive ? OPTION_TICK_CLASS : ''] .filter(Boolean) .join(' ') + if (interactive) { + item.setAttribute('role', 'checkbox') + item.setAttribute('tabindex', '0') + item.setAttribute('aria-checked', String(on)) + item.setAttribute('aria-label', displayValue || '勾选此项') + } item.setAttribute(OPTION_VALUE_ATTR, itemValue) item.setAttribute(OPTION_TEXT_ATTR, displayValue) item.textContent = optionDisplay(displayValue, on) @@ -288,6 +290,7 @@ function buildOptionItem( /** 切换一个选项的勾选态(改类名 + 重建 ☑ / ☐ 文案),返回切换后的状态 */ function setOptionState(item: HTMLElement, on: boolean): boolean { item.classList.toggle(OPTION_ON_CLASS, on) + if (item.hasAttribute('aria-checked')) item.setAttribute('aria-checked', String(on)) item.textContent = optionDisplay(item.getAttribute(OPTION_TEXT_ATTR) ?? '', on) return on @@ -409,7 +412,8 @@ function buildFieldWidget( if (interactive) { const input = doc.createElement('input') input.className = INPUT_CLASS - input.type = 'text' + input.type = field.kind === 'date' ? 'date' : 'text' + input.maxLength = 4000 input.value = text // value 属性同理:只设 property 的话序列化出来是空值 input.setAttribute('value', text) @@ -509,7 +513,15 @@ function renderStructuredFields( return } - element.replaceWith(buildFieldWidget(doc, field, answers, interactive, signatureActionable)) + const readonly = element.getAttribute('data-readonly') === 'true' + const widget = buildFieldWidget( + doc, + readonly ? { ...field, required: false } : field, + answers, + interactive && !readonly, + signatureActionable && (!readonly || field.id === PRIMARY_SIGNATURE_FIELD_ID), + ) + element.replaceWith(widget) }) } @@ -555,7 +567,7 @@ function convertCheckboxTables(root: Element, interactive: boolean, selected: Se const cells = Array.from(row.querySelectorAll('td, th')) const checkCell = cells[checkIndex] - if (!checkCell) { + if (!checkCell || checkCell.querySelector('[data-sign-field], [data-field]')) { return } @@ -853,9 +865,8 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers { // 直接落库会在证据里留下一个 undefined 而不是 false if (kind === 'checkbox') { answers[id] = - host - .querySelector(`.${OPTION_CLASS}`) - ?.classList.contains(OPTION_ON_CLASS) ?? false + host.querySelector(`.${OPTION_CLASS}`)?.classList.contains(OPTION_ON_CLASS) ?? + false return } diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index d613897..d2c79b7 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 + formSaveMessage?: string + formSaving?: boolean /** 真实模式下允许有签署权限的用户通过浏览器鼠标采集现场手写签名。 */ allowMouseSigning: boolean artifacts: SigningArtifact[] @@ -400,12 +402,15 @@ defineExpose({ getDocumentElement, locateSignatureSlot })
+

+ {{ formSaving ? '正在保存填写内容…' : formSaveMessage }} +

, +): Promise { + return request + .post>('/v1/sign-deliveries/token/form', { + ...proof, + expectedRevision, + answers, + }) + .then(unwrapApiResponse) +} diff --git a/patient-h5/src/api/types.ts b/patient-h5/src/api/types.ts index 8ebc3eb..be22436 100644 --- a/patient-h5/src/api/types.ts +++ b/patient-h5/src/api/types.ts @@ -116,6 +116,7 @@ export interface SigningContextField { * 这个接口要能**重复调用**(患者刷新、返回上一步都要能重进)。 */ export interface SigningContextResponse { + form?: SigningFormState | null taskId: string deliveryId?: string | null channel?: SignDeliveryChannel | null @@ -179,7 +180,27 @@ export interface SignatureUploadInput { name: string relation: string consentAccepted: true + answerRevision?: number observedSignedAt: string } idempotencyKey: string } + +export type PatientAnswer = string | string[] | boolean +export interface SigningFormField { + id: string + label: string + kind: string + required: boolean + multi: boolean + options: string[] + editable: boolean +} +export interface SigningFormState { + contentHtml: string + fields: SigningFormField[] + answers: Record + staffAnswers: Record + revision: number + editable: boolean +} diff --git a/patient-h5/src/components/DocumentPaper.vue b/patient-h5/src/components/DocumentPaper.vue index 908db6a..0dce53a 100644 --- a/patient-h5/src/components/DocumentPaper.vue +++ b/patient-h5/src/components/DocumentPaper.vue @@ -1,24 +1,14 @@ @@ -48,15 +58,28 @@ function continueToSigner() { +

{{ signingState.formError }}

+

正在保存填写内容…

{{ errorMessage }}

- + diff --git a/patient-h5/src/views/signer/SignerView.vue b/patient-h5/src/views/signer/SignerView.vue index b709ec1..48c8119 100644 --- a/patient-h5/src/views/signer/SignerView.vue +++ b/patient-h5/src/views/signer/SignerView.vue @@ -119,6 +119,8 @@ async function submitSigning() {