From ea5a248a0c35f6db0594d6591ab90973c4a1ead2 Mon Sep 17 00:00:00 2001 From: yelan Date: Sun, 27 Sep 2026 16:50:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AD=BE=E7=BD=B2=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0):=20=E8=A1=A5=E9=BD=90=E7=9F=AD=E4=BF=A1/=E5=85=AC?= =?UTF-8?q?=E4=BC=97=E5=8F=B7=E5=8F=91=E9=80=81=E5=BC=B9=E7=AA=97=E4=B8=8E?= =?UTF-8?q?=20Pad=20=E5=AE=9A=E4=BD=8D=E7=AD=BE=E5=90=8D=E4=BD=8D=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/src/api/workbench/signing.ts | 113 +++++++ clinical-web/src/styles/signing-document.css | 21 ++ clinical-web/src/utils/signing-document.ts | 9 + clinical-web/src/utils/signing-method.ts | 11 + .../signing/components/SigningTaskDetail.vue | 107 +++++-- .../signing/components/SmsSendDialog.vue | 295 ++++++++++++++++++ .../signing/components/WxPushDialog.vue | 254 +++++++++++++++ .../src/views/workbench/signing/index.vue | 178 ++++++++++- .../src/views/workbench/signing/types.ts | 13 + 9 files changed, 981 insertions(+), 20 deletions(-) create mode 100644 clinical-web/src/views/workbench/signing/components/SmsSendDialog.vue create mode 100644 clinical-web/src/views/workbench/signing/components/WxPushDialog.vue diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 5cddf65..1769ff5 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -1263,6 +1263,119 @@ export function updateSigningTaskMethod( return Promise.resolve(cloneTask(task)) } +/** 手机号脱敏:与后端 `smsMobileMasked` 同口径(保留前 3 后 4) */ +function maskMobile(mobile: string): string { + const digits = mobile.replace(/\D/g, '') + + return digits.length === 11 ? `${digits.slice(0, 3)}****${digits.slice(-4)}` : mobile +} + +/** + * 演示模式:发送签署短信(原型 `sendSmsFlow`)。 + * + * 与「重发短信」的区别:这一步会把任务**切成短信方式**并记下发送号码 —— + * 所以从别的入口点「短信签署」时,方式切换是在这里完成的, + * 不是先把方式改掉再发(原型也是 `sendSmsFlow` 里才 `smsTask.method='sms'`)。 + * 号码只留脱敏值,详情里的等待提示直接用它;真实模式后端没有这个接口。 + */ +export function sendSigningSms( + id: string, + mobile: string | null, +): Promise { + if (!useMockData) { + throw new Error('当前后端未提供发送签署短信接口') + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.method = 'sms' + + // mobile 为 null = 发到登记号码,那就保留原有的脱敏值不动 + if (mobile) { + task.smsMobileMasked = maskMobile(mobile) + } + + task.updatedAt = formatMockDate() + persistMockRecords() + return Promise.resolve(cloneTask(task)) +} + +/** + * 演示模式:推送公众号签署消息(原型 `sendWxPush`)。 + * 与短信那条同理 —— 方式切换发生在推送这一步。 + */ +export function pushSigningWxMessage(id: string): Promise { + if (!useMockData) { + throw new Error('当前后端未提供公众号签署推送接口') + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.method = 'wx' + task.updatedAt = formatMockDate() + persistMockRecords() + return Promise.resolve(cloneTask(task)) +} + +/** + * 演示模式:重发公众号推送(原型 `wxResend`)。 + * 只刷新时间,不改方式 —— 方式在第一次推送时就定了。 + */ +export function resendSigningWxMessage(id: string): Promise { + if (!useMockData) { + throw new Error('当前后端未提供公众号签署推送接口') + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.updatedAt = formatMockDate() + persistMockRecords() + return Promise.resolve(cloneTask(task)) +} + +/** + * 演示模式:模拟患者完成签署(原型 `smsSmsDone` / `wxDone`)。 + * + * 演示环境里没人真的去点短信链接 / 打开公众号消息,不给这个入口整条链路走不完。 + * 落哪个状态与真实签署同口径(`needConfirm` → `confirming`), + * 签名用演示图 —— 但**必须留痕**,所以走的是与真实签署同一条写库路径。 + */ +export function simulatePatientSigning(id: string): Promise { + if (!useMockData) { + throw new Error('演示入口,真实模式请由患者在签署页完成') + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + if (task.status !== 'pending') { + throw new Error('只有「待签署」的任务可以模拟患者签署') + } + + task.status = task.needConfirm ? 'confirming' : 'signed' + task.signatureDataUrl = DEMO_SIGNATURE_DATA_URL + task.signedAt = formatMockDate() + task.updatedAt = task.signedAt + task.fieldAnswers = { ...(task.fieldAnswers ?? {}), signature: DEMO_SIGNATURE_DATA_URL } + persistMockRecords() + return Promise.resolve(cloneTask(task)) +} + export function completeSigningTask( id: string, signatureDataUrl: string, diff --git a/clinical-web/src/styles/signing-document.css b/clinical-web/src/styles/signing-document.css index 0fe749a..d910cf8 100644 --- a/clinical-web/src/styles/signing-document.css +++ b/clinical-web/src/styles/signing-document.css @@ -156,6 +156,27 @@ border-color: #efb9bb; } +/* 「📲 Pad 签署」定位到签名位时的高亮(原型 .sig-locate): + 虚线描边 + 三下脉冲,提示操作人「点这个签名框就能签」。 + 类名与时长在 utils/signing-document.ts(SIGN_FIELD_LOCATE_CLASS / _MS),别在这里另写。 */ +.docfld.sign-field--locate { + outline: 2.5px dashed #2b7bd4; + outline-offset: 4px; + border-radius: 4px; + animation: sig-pulse 1.1s ease-in-out 3; +} + +@keyframes sig-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgb(43 123 212 / 0%); + } + + 50% { + box-shadow: 0 0 0 8px rgb(43 123 212 / 22%); + } +} + /* ============ 选项:☑ / ☐ ============ */ .docopt { diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index 6047bd4..0d04e21 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -119,6 +119,15 @@ export const SIGNATURE_ACTION_SELECTOR = `[${FIELD_ACTION_ATTR}="${SIGNATURE_ACT */ export const OPTION_TICK_SELECTOR = `.${OPTION_CLASS}.${OPTION_TICK_CLASS}` +/** + * 「定位到签名位」的高亮类名(原型 `.sig-locate`)与持续时间。 + * + * 原型 `openIpad()` 用 4200ms:滚动到签名位后闪一下,提示操作人「点这个签名框就能签」。 + * 类名与时长都放这里,样式在 styles/signing-document.css —— 组件里别再各写一份。 + */ +export const SIGN_FIELD_LOCATE_CLASS = 'sign-field--locate' +export const SIGN_FIELD_LOCATE_MS = 4200 + /** * 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields): * 编辑器标记与这里识别必须用同一份字符集,故不再本地声明。 diff --git a/clinical-web/src/utils/signing-method.ts b/clinical-web/src/utils/signing-method.ts index 974c1b5..4104d92 100644 --- a/clinical-web/src/utils/signing-method.ts +++ b/clinical-web/src/utils/signing-method.ts @@ -28,6 +28,13 @@ export interface SigningMethodDescriptor { * 拼接会在 Pad 上丢空格,与原型不一致(报表维度那一行会变成「📲 Pad签署」)。 */ label: string + /** + * 任务详情动作栏的按钮文案(原型 `wbActs`)。 + * + * 原型里**主按钮带图标、其余三个不带**:`✍️ 手写板签署` / `短信签署` / `Pad 签署` / `公众号签署`。 + * 也不能拿 short 拼 —— `Pad` 是拉丁词,与「签署」之间**有空格**(同 `label` 那条)。 + */ + buttonLabel: string } /** 顺序与原型 #mdNew 的 D 区一致:现场两种在前,线上两种在后 */ @@ -39,6 +46,7 @@ export const SIGNING_METHODS: readonly SigningMethodDescriptor[] = [ filterLabel: '手写板', icon: '✍️', label: '✍️ 手写板签署', + buttonLabel: '手写板签署', }, { key: 'sms', @@ -47,6 +55,7 @@ export const SIGNING_METHODS: readonly SigningMethodDescriptor[] = [ filterLabel: '短信线上', icon: '📱', label: '📱 短信签署', + buttonLabel: '短信签署', }, { key: 'ipad', @@ -55,6 +64,7 @@ export const SIGNING_METHODS: readonly SigningMethodDescriptor[] = [ filterLabel: 'Pad', icon: '📲', label: '📲 Pad 签署', + buttonLabel: 'Pad 签署', }, { key: 'wx', @@ -63,6 +73,7 @@ export const SIGNING_METHODS: readonly SigningMethodDescriptor[] = [ filterLabel: '公众号', icon: '💬', label: '💬 公众号签署', + buttonLabel: '公众号签署', }, ] diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index 85cd6e4..92dae61 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -8,7 +8,12 @@ import type { SigningTaskEvent, SigningTaskRecord, } from '@/api/workbench/types' -import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document' +import { + SIGN_FIELD_LOCATE_CLASS, + SIGN_FIELD_LOCATE_MS, + type SigningFieldProgress, + type SigningSignatureRequest, +} from '@/utils/signing-document' import { formatSigningMethodTitle, readSigningMethod } from '@/utils/signing-method' import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue' @@ -81,6 +86,30 @@ function alternateMethods(current: SigningMethod): SigningMethod[] { return ALTERNATE_METHODS.filter((item) => item !== current) } +/** + * 「其余三种方式」按钮各自打开哪个流程(原型 wbActs 里四个按钮的 onclick)。 + * + * 原型这四个按钮**互为入口**:点「短信签署」是弹「发送签署短信」、 + * 点「公众号签署」是弹「消息推送」、点「Pad 签署」是切换方式并把文书滚到签名位 —— + * 都不是「只把方式改一下」。 + */ +function entryActionFor(target: SigningMethod): SigningTaskAction { + if (target === 'pad') { + return 'pad-sign' + } + + if (target === 'ipad') { + return 'switch-to-ipad' + } + + return target === 'wx' ? 'send-wx-push' : 'send-sms' +} + +/** 「查看患者签署页」的按钮文案:原型两处写法不同(短信写 H5、公众号写患者签署页),照抄 */ +function viewPatientPageLabel(method: SigningMethod): string { + return method === 'wx' ? '查看患者签署页(公众号)' : '查看线上签署(H5)' +} + /** * 现场设备签署(手写板 / Pad)走本地采集入口;线上(短信 / 公众号)走患者端页面。 * 这只是**入口形态**的分组,不是取值归并 —— 按钮文案、状态标签仍按具体取值区分。 @@ -113,7 +142,34 @@ function getDocumentElement() { return documentRef.value?.getDocumentElement() ?? null } -defineExpose({ getDocumentElement }) +/** + * 「📲 Pad 签署」:把文书滚到第一个签名位并高亮(原型 `openIpad`)。 + * + * 原型这一步**不弹签名板** —— 它只把签名位滚到眼前、闪一下、提示「点击签名框弹出签名板」, + * 由操作人自己在文书上点那个签名位。这样「Pad 签署」与「手写板签署」的差别才看得出来: + * 前者是在文书上定位后现场签,后者是直接开签名板。 + * + * 返回是否找到了签名位(没找到由父级提示,原型也是 toast 报错)。 + */ +function locateSignatureSlot(): boolean { + const root = documentRef.value?.getDocumentElement() + const slot = root?.querySelector('[data-sign-kind="signature"]') + + if (!slot) { + return false + } + + slot.scrollIntoView({ behavior: 'smooth', block: 'center' }) + // 重放动画:先摘类、强制 reflow、再加回去(原型用同一手法) + slot.classList.remove(SIGN_FIELD_LOCATE_CLASS) + void slot.offsetWidth + slot.classList.add(SIGN_FIELD_LOCATE_CLASS) + window.setTimeout(() => slot.classList.remove(SIGN_FIELD_LOCATE_CLASS), SIGN_FIELD_LOCATE_MS) + + return true +} + +defineExpose({ getDocumentElement, locateSignatureSlot })