feat(线上签署): 模拟器支持短信/公众号双入口并补齐身份校验与完整文书展示

This commit is contained in:
yelan
2026-09-27 17:07:41 +08:00
parent ea5a248a0c
commit c6262726d7
2 changed files with 310 additions and 27 deletions
@@ -1,16 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' /**
* 患者签署页(原型两套:短信走 `#mdPhone`,公众号走 `#mdWxPt`)。
*
* 为什么合成一个组件而不是两个:手机壳、状态栏、页头页脚、手写板这几层完全一样,
* 差别只在**入口形态**与**看到的内容**,所以用 `variant` 分开表达:
* - `sms`(短信 H5):短信气泡 → **身份校验**(就诊号 + 姓名)→ 文书摘要 + 手写签名;
* - `wx`(公众号):微信会话(服务通知)→ **完整文书正文** + 手写签名。
*
* 两处差异是原型就有的,不是实现偷懒:短信是「凭链接进来、要先核对身份」,
* 公众号是「从医院服务号点进来、会话里已经带了上下文」。
*/
import { computed, ref, watch } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types' import type { SigningTaskRecord } from '@/api/workbench/types'
import DocumentPaper from '@/components/document/DocumentPaper.vue'
import type { SigningDocumentData } from '@/utils/signing-document'
import SignatureCanvas from './SignatureCanvas.vue' import SignatureCanvas from './SignatureCanvas.vue'
type OnlineStage = 'message' | 'signing' type OnlineStage = 'message' | 'verify' | 'signing'
const props = defineProps<{ const props = withDefaults(
visible: boolean defineProps<{
task: SigningTaskRecord | null visible: boolean
}>() task: SigningTaskRecord | null
/** `sms` = 短信 H5 页(原型 #mdPhone);`wx` = 公众号页(原型 #mdWxPt) */
variant?: 'sms' | 'wx'
}>(),
{ variant: 'sms' },
)
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
@@ -22,6 +40,8 @@ const stage = ref<OnlineStage>('message')
const signatureDataUrl = ref<string | null>(null) const signatureDataUrl = ref<string | null>(null)
const signatureCanvas = ref<{ clear: () => void } | null>(null) const signatureCanvas = ref<{ clear: () => void } | null>(null)
const isWx = computed(() => props.variant === 'wx')
watch( watch(
() => props.visible, () => props.visible,
(visible) => { (visible) => {
@@ -32,7 +52,59 @@ watch(
}, },
) )
function openLink() { /** 弹窗标题:原型两个弹窗的页头文案 */
const dialogTitle = computed(() =>
isWx.value ? '公众号签署 · 患者微信端' : '线上签署 · 患者移动端 H5',
)
/** 手机页头文案:短信三态、公众号两态(原型 phTitle / wxpTitle) */
const phoneTitle = computed(() => {
if (stage.value === 'verify') {
return '身份校验'
}
if (stage.value === 'signing') {
return '患者签署'
}
return isWx.value ? '微信' : '短信'
})
/** 页脚主按钮(原型 phGo / wxpGo) */
const primaryLabel = computed(() => {
if (stage.value === 'message') {
return isWx.value ? '打开签署页' : '打开签署链接'
}
return isWx.value ? '等待患者操作…' : '等待患者校验…'
})
/** 入口态页脚提示(原型两处写法不同) */
const entryHint = computed(() => (isWx.value ? '↑ 患者收到的公众号服务通知' : '↑ 患者收到短信'))
/** 文书回填上下文:与工作台详情同口径,HIS 字段才填得上 */
const documentData = computed<SigningDocumentData>(() => ({
patientName: props.task?.patientName,
sex: props.task?.sex,
age: props.task?.age,
visitNo: props.task?.visitNo,
visitDate: props.task?.visitDate,
signedAt: props.task?.signedAt,
signatureDataUrl: props.task?.signatureDataUrl,
selectedItems: props.task?.selectedItems,
fieldAnswers: props.task?.fieldAnswers,
}))
function advance() {
if (stage.value !== 'message') {
return
}
// 短信要先过身份校验;公众号直接从会话进签署页(原型 phOpen / wxPtOpen)
stage.value = isWx.value ? 'signing' : 'verify'
}
function startSigning() {
stage.value = 'signing' stage.value = 'signing'
} }
@@ -58,7 +130,7 @@ function clearSignature() {
aria-labelledby="online-title" aria-labelledby="online-title"
> >
<header class="dialog-header"> <header class="dialog-header">
<strong id="online-title">线上签署 · 患者手机模拟</strong> <strong id="online-title">{{ dialogTitle }}</strong>
<span>{{ task.patientName }} · {{ task.documentName }}</span> <span>{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')"> <button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
× ×
@@ -69,27 +141,75 @@ function clearSignature() {
<div class="phone-frame"> <div class="phone-frame">
<div class="phone-screen"> <div class="phone-screen">
<div class="phone-status"><span>9:41</span><span>5G ▮▮▮</span></div> <div class="phone-status"><span>9:41</span><span>5G ▮▮▮</span></div>
<div class="phone-header">{{ stage === 'message' ? '短信' : '患者签署' }}</div> <div class="phone-header" :class="{ 'is-wx': isWx && stage === 'message' }">
{{ phoneTitle }}
</div>
<div class="phone-body"> <div class="phone-body">
<!-- 入口态:短信气泡 / 微信会话 -->
<template v-if="stage === 'message'"> <template v-if="stage === 'message'">
<div v-if="isWx" class="wx-chat-head">
<span class="wx-avatar" aria-hidden="true">医</span>
<div class="wx-account">
<strong>【xx医院】</strong>
<span>服务通知</span>
</div>
</div>
<div class="message-bubble"> <div class="message-bubble">
<strong>【xx医院】</strong> <strong>【xx医院】</strong>
<p> <p v-if="isWx">
尊敬的{{ task.patientName }},您有一份《{{ {{ task.patientName }},您有一份《{{ task.documentName }}》待签署,
task.documentName 请点击进入签署页完成知情同意书签署。
}}》待签署,请点击下方链接完成签署,链接 48 小时内有效。
</p> </p>
<span class="sign-link">https://s.hosp.cn/s/{{ task.id }}</span> <p v-else>
尊敬的{{ task.patientName }},您有一份《{{ task.documentName }}》待签署,
请点击下方链接完成签署,链接 48 小时内有效。
</p>
<span class="sign-link">
{{ isWx ? `mpsign.hosp.cn/wx/${task.id}` : `https://s.hosp.cn/s/${task.id}` }}
</span>
</div> </div>
</template> </template>
<!-- 短信独有:身份校验(原型 phRender 的 phStage===1) -->
<template v-else-if="stage === 'verify'">
<div class="verify-card">
<strong>🔐 签署前身份校验</strong>
<p class="verify-tip">为保障签署安全,请核对以下由医院短信通知带出的信息:</p>
<div class="verify-row">
<span>就诊号<em>(已带出)</em></span>
<b class="is-mono">{{ task.visitNo }}</b>
</div>
<div class="verify-row">
<span>患者姓名</span>
<b>{{ task.patientName }}</b>
</div>
<p class="verify-ok">
校验通过:就诊号与短信发送对象一致。
<span class="verify-note"
>(证件后四位校验需要后端提供证件号码,本期未接入)</span
>
</p>
</div>
</template>
<!-- 患者签署 -->
<template v-else> <template v-else>
<div class="mobile-document"> <div class="mobile-document">
<strong>{{ task.documentName }}</strong> <strong>{{ task.documentName }}</strong>
<span>{{ task.patientName }} · {{ task.patientId }}</span> <span>{{ task.patientName }} · {{ task.patientId }}</span>
<p> <span v-if="task.visitNo">就诊号 {{ task.visitNo }}</span>
本人已充分理解本检查/治疗的医疗风险、替代方案及可能的不良后果,自愿接受并签署确认。签名图片与文档原件一并存档。
</p>
</div> </div>
<!-- 公众号页把**完整文书**摆出来(原型 #mdWxPt 里就是整篇 docBody) -->
<div v-if="isWx" class="mobile-paper">
<DocumentPaper
:content-html="task.documentContentHtml ?? ''"
:data="documentData"
:interactive="false"
/>
</div>
<p v-else class="mobile-summary">
本人已充分理解本检查/治疗的医疗风险、替代方案及可能的不良后果,自愿接受并签署确认。签名图片与文档原件一并存档。
</p>
<div class="mobile-signature"> <div class="mobile-signature">
<span>请手写签名:</span> <span>请手写签名:</span>
<SignatureCanvas <SignatureCanvas
@@ -101,6 +221,7 @@ function clearSignature() {
</div> </div>
</template> </template>
</div> </div>
<div v-if="stage === 'signing'" class="phone-footer"> <div v-if="stage === 'signing'" class="phone-footer">
<button <button
type="button" type="button"
@@ -109,21 +230,47 @@ function clearSignature() {
> >
清空 清空
</button> </button>
<button type="button" class="phone-button" @click="complete">确认签署</button> <button
type="button"
class="phone-button"
:class="{ 'is-wx': isWx }"
@click="complete"
>
确认签署
</button>
</div> </div>
<div v-else class="phone-footer phone-footer--hint">↑ 患者收到短信</div> <div v-else class="phone-footer phone-footer--hint">{{ entryHint }}</div>
</div> </div>
</div> </div>
</div> </div>
<footer class="dialog-footer"> <footer class="dialog-footer">
<span>模拟患者收到短信 → 打开链接 → 查看文档 → 手写签署的全过程</span> <span class="footer-note">
{{
isWx
? '模拟患者收到服务通知 → 打开签署页 → 查看文书 → 手写签署'
: '模拟患者收到短信 → 打开链接 → 身份校验 → 手写签署'
}}
</span>
<span class="footer-spacer" /> <span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="emit('resend')"> <button type="button" class="action-button action-button--ghost" @click="emit('resend')">
重发短信 {{ isWx ? '重发推送' : '重发短信' }}
</button> </button>
<button v-if="stage === 'message'" type="button" class="action-button" @click="openLink"> <button
模拟打开链接 v-if="stage === 'verify'"
type="button"
class="action-button"
@click="startSigning"
>
身份校验通过 · 进入签署
</button>
<button
v-else-if="stage === 'message'"
type="button"
class="action-button"
@click="advance"
>
{{ primaryLabel }}
</button> </button>
</footer> </footer>
</section> </section>
@@ -226,6 +373,11 @@ function clearSignature() {
background: var(--brand); background: var(--brand);
} }
/* 微信会话那一屏用微信绿,一眼能看出不是短信 */
.phone-header.is-wx {
background: #07c160;
}
.phone-body { .phone-body {
flex: 1; flex: 1;
padding: 12px; padding: 12px;
@@ -234,7 +386,9 @@ function clearSignature() {
.message-bubble, .message-bubble,
.mobile-document, .mobile-document,
.mobile-signature { .mobile-signature,
.verify-card,
.wx-chat-head {
padding: 11px 13px; padding: 11px 13px;
background: #fff; background: #fff;
border-radius: 10px; border-radius: 10px;
@@ -258,6 +412,100 @@ function clearSignature() {
word-break: break-all; word-break: break-all;
} }
.wx-chat-head {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
background: #ededed;
box-shadow: none;
}
.wx-avatar {
display: inline-flex;
flex: none;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
background: #07c160;
border-radius: 5px;
}
.wx-account {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
}
.wx-account strong {
font-size: 12.5px;
}
.wx-account span {
color: var(--mut);
font-size: 11.5px;
}
.verify-card {
color: var(--ink);
font-size: 12px;
line-height: 1.8;
}
.verify-card strong {
font-size: 14px;
}
.verify-tip {
margin: 8px 0;
color: var(--mut);
font-size: 11.5px;
}
.verify-row {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 8px;
}
.verify-row span {
color: var(--mut);
font-size: 11.5px;
}
.verify-row em {
font-style: normal;
opacity: 0.75;
}
.verify-row b {
font-size: 13px;
}
.verify-ok {
padding: 7px 10px;
margin: 10px 0 0;
color: var(--brand-d);
font-size: 11.5px;
background: var(--brand-l);
border-radius: 6px;
}
.verify-note {
display: block;
margin-top: 2px;
opacity: 0.8;
}
.is-mono {
font-family: Consolas, 'Courier New', monospace;
}
.mobile-document { .mobile-document {
color: var(--ink); color: var(--ink);
font-size: 12px; font-size: 12px;
@@ -279,8 +527,30 @@ function clearSignature() {
font-size: 11.5px; font-size: 11.5px;
} }
.mobile-document p { .mobile-summary {
margin: 0; padding: 11px 13px;
margin: 10px 0 0;
color: var(--ink);
font-size: 12px;
line-height: 1.75;
background: #fff;
border-radius: 10px;
box-shadow: var(--sh);
}
/* 公众号页里的整篇文书:手机壳里当然不是一页 A4,把 A4 规格的变量就地压掉 */
.mobile-paper {
margin-top: 10px;
}
.mobile-paper :deep(.doc-paper) {
--doc-paper-min-h: 0px;
--doc-paper-pad-x: 14px;
--doc-paper-pad-y: 12px;
max-width: none;
font-size: 12px;
box-shadow: var(--sh);
} }
.mobile-signature { .mobile-signature {
@@ -323,6 +593,11 @@ function clearSignature() {
border-radius: 22px; border-radius: 22px;
} }
/* 公众号页的确认按钮是微信绿(原型 wxpFoot 里写死 #07c160) */
.phone-button.is-wx {
background: #07c160;
}
.phone-button--light { .phone-button--light {
color: var(--mut); color: var(--mut);
background: #eef2f4; background: #eef2f4;
@@ -340,6 +615,12 @@ function clearSignature() {
border-radius: 0 0 14px 14px; border-radius: 0 0 14px 14px;
} }
.footer-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-spacer { .footer-spacer {
flex: 1; flex: 1;
} }
@@ -984,8 +984,9 @@ async function confirmVoidTask(reason: string) {
} }
} }
/** 患者页里的「重发」:短信重发短信、公众号重发推送(两个动作后端也不同) */
async function handleOnlineResend() { async function handleOnlineResend() {
await handleTaskAction('resend-sms') await handleTaskAction(selectedTask.value?.method === 'wx' ? 'resend-wx-push' : 'resend-sms')
} }
/** /**
@@ -1139,6 +1140,7 @@ onMounted(() => {
<OnlineSigningDialog <OnlineSigningDialog
:visible="onlineDialogVisible" :visible="onlineDialogVisible"
:task="selectedTask" :task="selectedTask"
:variant="selectedTask?.method === 'wx' ? 'wx' : 'sms'"
@close="onlineDialogVisible = false" @close="onlineDialogVisible = false"
@complete="completeOnlineSignature" @complete="completeOnlineSignature"
@resend="handleOnlineResend" @resend="handleOnlineResend"