From 8024075439089a6204f05de2a62cc91eb4366a9c Mon Sep 17 00:00:00 2001 From: xy Date: Mon, 28 Sep 2026 19:57:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(sign):=20=E8=A1=A5=E9=BD=90=E5=8C=BB?= =?UTF-8?q?=E6=8A=A4=E7=AB=AF=E4=B8=8E=E6=82=A3=E8=80=85=E7=AB=AF=E7=AD=BE?= =?UTF-8?q?=E7=BD=B2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- clinical-web/src/api/management/documents.ts | 30 +- clinical-web/src/api/management/types.ts | 2 + clinical-web/src/api/workbench/signing.ts | 25 +- clinical-web/src/api/workbench/types.ts | 42 ++- .../signing/NewSigningTaskDialog.vue | 180 ++++++++++- .../src/composables/useSigningTaskForm.ts | 118 +++++++- clinical-web/src/utils/signing-field-node.ts | 9 +- clinical-web/src/utils/signing-fields.ts | 49 ++- .../documents/components/WordEditorDialog.vue | 133 ++++++-- .../src/views/management/documents/index.vue | 9 +- .../src/views/management/documents/types.ts | 1 + .../signing/components/SmsSendDialog.vue | 13 +- .../src/views/workbench/signing/index.vue | 11 +- patient-h5/src/api/index.ts | 47 ++- patient-h5/src/api/types.ts | 66 +++- patient-h5/src/composables/useSignFlow.ts | 285 +++++++++++++++--- patient-h5/src/types/signing.ts | 9 +- patient-h5/src/utils/signing-png.ts | 73 +++++ patient-h5/src/utils/signing-vault.ts | 99 ++++++ patient-h5/src/views/entry/EntryView.vue | 18 +- patient-h5/src/views/result/ResultView.vue | 16 +- 22 files changed, 1082 insertions(+), 163 deletions(-) create mode 100644 patient-h5/src/utils/signing-png.ts create mode 100644 patient-h5/src/utils/signing-vault.ts diff --git a/README.md b/README.md index 8cd71be..7c47159 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ - `clinical-web`:医护端布局、工作台、签署任务、文书库、报表和设置页面; - `patient-h5`:签署入口、文书确认、签署人信息、手写签名和结果页面; -- 默认仍使用演示数据,但医护端已按 MEDISIGN 接口文档接入登录、患者/就诊、模板、签署任务、文件产物、投递、用户组织和模板权限等真实接口;患者 H5 已接入一次性 Token 消费和签名上传请求; +- 默认仍使用演示数据。真实签署流程中,医护端创建任务时提交模板表单答案和实际签署人;患者 H5 消费一次性 Token 后读取服务端固化的患者、签署人和文书上下文,再上传签名 PNG; +- 真实签署通道目前限于后端已定义的 SMS 与 PAD。短信接口只确认投递意图已排队;短信供应商、微信公众号和 iPad 设备适配仍需后端/集成服务接入; - `packages` 目录暂时保留,等接口和公共类型稳定后再接入共享包。 ## 项目定位 @@ -87,6 +88,13 @@ medical-sign/ - 前端不负责最终权限和签署有效性判断,后端必须重新校验; - 患者 H5 不在 URL 中暴露身份证号、完整病历等敏感信息。 +## 签署接口契约 + +- 模板版本的 `formFields` 保存文书字段定义,`signatureFields` 单独保存患者签名在 PDF 中的页码、坐标、宽高和坐标系;编辑器中的坐标单位为 PDF point。发布模板前必须配置 `signature` 字段的位置。 +- `GET /api/v1/templates/available-versions` 返回 `formFields` 和 `needConfirm`。创建任务时将人工填写值放入 `staffAnswers`,并传入 `signerType`、`signerName`、`signerRelation`;患者、性别、年龄、就诊号和电话等已知字段仍由后端快照覆盖。 +- H5 先调用 `POST /api/v1/sign-deliveries/token/consume`,再用返回的 `taskId`、`deliveryId`、`uploadToken` 调用 `POST /api/v1/sign-deliveries/token/context`。上传使用 `POST /api/v1/sign-deliveries/{taskId}/signature` multipart,包含 `file`、`metadata` 和 `signer` JSON 部分,并复用 `Idempotency-Key` 安全重试。 +- 患者签署后,任务可能处于 `GENERATING`、`WAITING_CONFIRM` 或 `SIGNED`。刷新结果页时 H5 使用只读 context 查询恢复服务端状态,不能将这些状态退回为可重复签署。 + ## 构建与部署规划 两个应用分别构建为 `dist`: diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index d45ab60..12483be 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -136,19 +136,37 @@ export function readVersionContentHtml(dto: TemplateVersionResponseDto | null): /** * 把正文 HTML 里的占位符编译成后端可留存的结构化字段定义。 * - * 后端版本 DTO 的 signatureFields 字段承载「这份模板有哪些填写项」, - * 正文本身仍以 contentHtml 提交。不提交这个字段的话,后端拿不到字段口径, - * 也就无法在签署侧做必填校验与证据固化。 + * 正文填写项和 PDF 签名几何是两个独立契约:formFields 负责字段语义, + * signatureFields 只负责患者签名图在服务端生成 PDF 中的位置。 */ export function buildSignatureFieldsPayload(contentHtml: string): unknown[] { + const field = collectFieldDefs(contentHtml).find((item) => item.id === 'signature') + if (!field || field.kind !== 'signature') { + throw new Error('请在正文中插入“患者签名”占位符') + } + if (!field.pdfPosition) { + throw new Error('请先为“患者签名”配置 PDF 页码和签名位置') + } + return [ + { + id: field.id, + page: field.pdfPosition.page, + x: field.pdfPosition.x, + y: field.pdfPosition.y, + width: field.pdfPosition.width, + height: field.pdfPosition.height, + coordinateSystem: field.pdfPosition.coordinateSystem, + }, + ] +} + +export function buildFormFieldsPayload(contentHtml: string): unknown[] { return collectFieldDefs(contentHtml).map((field) => ({ id: field.id, label: field.label, kind: field.kind, required: field.required, options: field.options, - // 单/多选必须一起上报:后端靠它渲染签署控件与做取值校验, - // 缺了这个字段就只能按 kind 猜,多选组会被当成单个勾选框 ...(field.multi === undefined ? {} : { multi: field.multi }), ...(field.risk ? { risk: field.risk } : {}), })) @@ -519,6 +537,7 @@ export async function createTemplateWithContent( await createTemplateVersion(template.id, { ...version, + formFields: buildFormFieldsPayload(version.contentHtml), signatureFields: buildSignatureFieldsPayload(version.contentHtml), }) @@ -539,6 +558,7 @@ export async function updateTemplateContent( ): Promise { await createTemplateVersion(templateId, { ...version, + formFields: buildFormFieldsPayload(version.contentHtml), signatureFields: buildSignatureFieldsPayload(version.contentHtml), }) } diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index 0180468..efc1cb3 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -149,6 +149,8 @@ export interface CreateTemplateVersionRequest { versionNo?: string contentHtml: string signatureFields?: unknown[] + formFields?: unknown[] + needConfirm?: boolean fileStorageKey?: string fileRelativePath?: string fileSizeBytes?: number diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 1769ff5..597a6dd 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -10,8 +10,15 @@ import { mockFlags } from '@/utils/mock-flags' import { readLocalSigningTemplates } from '@/utils/mock-document-library' import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage' import { request } from '@/utils/request' -import { ALL_SIGNING_METHODS, formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method' -import { resendSigningSms as resendSigningSmsDelivery } from './deliveries' +import { + ALL_SIGNING_METHODS, + formatSigningMethod, + toBackendSigningMethod, +} from '@/utils/signing-method' +import { + resendSigningSms as resendSigningSmsDelivery, + sendSigningSms as sendSigningSmsDelivery, +} from './deliveries' import type { ApiPageResponse, @@ -666,6 +673,7 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing supportedMethods: [...ALL_SIGNING_METHODS], // 需科室确认随模板带出;后端没给就是 false(签完直接归档) needConfirm: dto.needConfirm ?? false, + formFields: dto.formFields ?? [], // 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回 contentSha256: dto.contentSha256, contentHtml: cachedContent || undefined, @@ -757,7 +765,7 @@ function mapSigningTask( documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`, department: dto.visitSnapshot.departmentName || '未指定科室', departmentId: dto.departmentId, - signerName: dto.patientSnapshot.name, + signerName: dto.signerName ?? dto.patientSnapshot.name, // 签署人类型 / 关系:后端补齐后自动带出,取不到即 undefined,列表不显示代签标签 signerType: dto.signerType ?? undefined, signerRelation: dto.signerRelation ?? undefined, @@ -1183,7 +1191,11 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise< visitId: input.visitId, templateVersionId: input.templateVersionId, signMethod: toBackendSigningMethod(input.method), - idempotencyKey: createIdempotencyKey(), + idempotencyKey: input.idempotencyKey || createIdempotencyKey(), + staffAnswers: input.staffAnswers ?? {}, + signerType: input.signerType ?? 'SELF', + signerName: input.signerName, + signerRelation: input.signerRelation ?? (input.signerType === 'FAMILY' ? undefined : '本人'), } const response = await request.post>( '/v1/sign-tasks', @@ -1283,7 +1295,10 @@ export function sendSigningSms( mobile: string | null, ): Promise { if (!useMockData) { - throw new Error('当前后端未提供发送签署短信接口') + if (!mobile) return Promise.reject(new Error('请填写完整手机号后发送短信')) + return sendSigningSmsDelivery(id, { destination: mobile, templateCode: 'SIGN_LINK' }).then(() => + getSigningTaskDetail(id), + ) } const task = mockRecords.find((record) => record.id === id) diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 3acdc27..44732f7 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -27,13 +27,7 @@ export interface WorkbenchOverviewRequestDto { * `confirming` 只能下载患者已签版本。 */ export type SigningTaskStatus = - | 'pending' - | 'signing' - | 'confirming' - | 'signed' - | 'expired' - | 'void' - | 'failed' + 'pending' | 'signing' | 'confirming' | 'signed' | 'expired' | 'void' | 'failed' export type PatientSex = '男' | '女' | '未知' @@ -87,6 +81,8 @@ export interface SigningTemplate { * 发起签署时随文书带到任务上,决定签完落「待科室确认」还是「已签署」。 */ needConfirm?: boolean + /** 医护端发起时必须采集的结构化正文填写项。 */ + formFields?: SigningFormField[] /** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */ contentHtml?: string /** @@ -97,6 +93,16 @@ export interface SigningTemplate { contentSha256?: string } +export interface SigningFormField { + id: string + label: string + kind: SigningFieldKind + required: boolean + options?: string[] + multi?: boolean + risk?: string +} + export interface WorkbenchSummary { todaySigned: number todaySignedChange: number @@ -219,6 +225,15 @@ export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | '30d' | 'al */ export type SigningFieldKind = 'text' | 'date' | 'choice' | 'checkbox' | 'signature' +export interface SigningPdfPosition { + page: number + x: number + y: number + width: number + height: number + coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT' +} + /** 文书里的一个填写项定义,由模板内容中的占位符解析得到。 */ export interface SigningFieldDef { /** 稳定字段键:与 HIS / 患者快照取值一一对应,不随占位符显示文字变化 */ @@ -242,6 +257,8 @@ export interface SigningFieldDef { multi?: boolean /** 取值等于该字符串时视为异常项(如风险筛查选了"是") */ risk?: string + /** 患者签名位在服务端生成 PDF 中的位置,单位为 PDF point。 */ + pdfPosition?: SigningPdfPosition } /** @@ -366,6 +383,11 @@ export interface CreateSigningTaskInput { documentContentHtml?: string /** 由文书模板带出的「需科室确认」标记;真实接口不提交(后端按 templateVersionId 取)。 */ needConfirm?: boolean + staffAnswers?: Record + signerType?: 'SELF' | 'FAMILY' + signerName?: string + signerRelation?: string + idempotencyKey?: string } /** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */ @@ -375,6 +397,10 @@ export interface CreateSigningTaskRequest { templateVersionId: string signMethod: BackendSigningMethod idempotencyKey?: string + staffAnswers?: Record + signerType?: 'SELF' | 'FAMILY' + signerName?: string + signerRelation?: string } export interface SigningApiOptions { @@ -460,6 +486,7 @@ export interface AvailableTemplateVersionResponseDto { effectiveAt: string /** 患者签署后是否需要科室复核签章;后端补齐后前端无需改动即可带出。 */ needConfirm?: boolean | null + formFields?: SigningFormField[] | null } export interface SignTaskPatientSnapshotDto { @@ -512,6 +539,7 @@ export interface SignTaskResponseDto { * 后端补齐后前端无需改动即可显示;当前取不到时为 undefined。 */ signerType?: string | null + signerName?: string | null signerRelation?: string | null /** 患者签署后是否需要科室复核签章(由文书模板标记带出)。 */ needConfirm?: boolean | null diff --git a/clinical-web/src/components/signing/NewSigningTaskDialog.vue b/clinical-web/src/components/signing/NewSigningTaskDialog.vue index 0bb4ddf..4ef549a 100644 --- a/clinical-web/src/components/signing/NewSigningTaskDialog.vue +++ b/clinical-web/src/components/signing/NewSigningTaskDialog.vue @@ -49,10 +49,18 @@ const { selectMethod, selectTemplate, selectVisit, + selectedTemplate, selectedTemplateId, selectedVisit, selectedVisitId, smsDestination, + staffAnswers, + manualFormFields, + signerType, + signerName, + signerRelation, + staffAnswerHasOption, + toggleStaffAnswerOption, submit: submitForm, submitting, suggestionOpen, @@ -128,7 +136,11 @@ async function submitTask(keepOpen: boolean) { emit('created', task) const smsMessage = - task.method === 'sms' ? (isSigningMockEnabled ? ',已生成短信投递意图' : ',短信已发送') : '' + task.method === 'sms' + ? isSigningMockEnabled + ? ',已生成短信投递意图' + : ',短信投递已排队' + : '' ElMessage.success(`已创建 1 个签署任务${smsMessage}`) if (keepOpen) { @@ -140,8 +152,8 @@ async function submitTask(keepOpen: boolean) { } emit('close') - } catch { - ElMessage.error('签署任务创建失败,请稍后重试') + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '签署任务创建失败,请稍后重试') } } @@ -404,7 +416,10 @@ watch( type="button" class="method-option" :class="{ 'method-option--active': method === option.key }" - :disabled="!isMethodAllowed(option.key)" + :disabled=" + !isMethodAllowed(option.key) || + (!isSigningMockEnabled && (option.key === 'wx' || option.key === 'ipad')) + " @click="selectMethod(option.key)" > {{ option.icon }} @@ -445,9 +460,88 @@ watch( +

+ 当前后端尚未接入该渠道的投递或设备适配,请选择短信或已配置的现场手写板。 +

+ +
+
+ E + 签署人和文书填写项 + 按已选模板采集,提交后固化到签署任务 +
+
+
+ 实际签署人 + + + +

{{ errors.signer }}

+
+ +
+ +

{{ errors.staffAnswers }}

+
+

+ 当前模板没有需要医护端填写的正文项;患者签名和签署日期将在签署环节生成。 +

+
+