feat(签署工作台): 补齐短信/公众号发送弹窗与 Pad 定位签名位流程

This commit is contained in:
yelan
2026-09-27 16:50:13 +08:00
parent 39d27ae5e7
commit ea5a248a0c
9 changed files with 981 additions and 20 deletions
+113
View File
@@ -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<SigningTaskRecord | null> {
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<SigningTaskRecord | null> {
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<SigningTaskRecord | null> {
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<SigningTaskRecord | null> {
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,
@@ -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 {
@@ -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):
* 编辑器标记与这里识别必须用同一份字符集,故不再本地声明。
+11
View File
@@ -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: '公众号签署',
},
]
@@ -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<HTMLElement>('[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 })
</script>
<template>
@@ -128,7 +184,9 @@ defineExpose({ getDocumentElement })
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
<template v-if="task.status === 'pending'">
<!-- 主按钮 = 当前方式的签署入口(原型:手写板/Pad 走现场设备,短信/公众号走线上页面) -->
<!-- 主按钮 = 当前方式的签署入口(原型 wbActs 的四个分支):
手写板 → 直接开签名板;Pad → 把文书滚到签名位等高亮;短信 → 开「发送签署短信」;
公众号 → 开「消息推送」 -->
<template v-if="isOnsiteMethod(task.method)">
<template v-if="allowLocalSigning">
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
@@ -152,8 +210,12 @@ defineExpose({ getDocumentElement })
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
</template>
</template>
<!-- 线上方式:消息已经发出去了,此刻等的是患者(原型 `.tg-wait`)。
所以主按钮不是「再发一次」,而是「查看患者那一页」;
再加一个演示用的「模拟患者完成签署」——
演示环境里没人真的去点短信链接,不给这个入口整条链路就走不完 -->
<template v-else>
<!-- 线上方式先给等待态:这条任务此刻等的是患者,不是操作人 -->
<span class="action-wait">{{ waitingHint(task) }}</span>
<button
v-if="allowLocalSigning"
@@ -161,31 +223,40 @@ defineExpose({ getDocumentElement })
class="action-button"
@click="emit('action', 'online-sign')"
>
查看{{ readSigningMethod(task.method).short }}签署
{{ viewPatientPageLabel(task.method) }}
</button>
<span v-else class="action-hint">线上签署页面接口待接入</span>
<button
v-if="allowLocalSigning"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'simulate-patient-sign')"
>
✓ 模拟患者完成签署(演示)
</button>
<button
v-if="canDeliveryWrite"
type="button"
class="action-button action-button--ghost"
@click="emit('action', task.method === 'wx' ? 'resend-wx-push' : 'resend-sms')"
>
{{ task.method === 'wx' ? '重发推送' : '重发短信' }}
</button>
</template>
<!-- 其余三种方式:一键转换(原型四种方式互为切换入口) -->
<!-- 其余三种方式:**直接打开各自的流程**(原型四个按钮互为入口,不是「只切方式」)。
短信按钮先弹「发送签署短信」、公众号按钮先弹「消息推送」,
发送/推送成功后才把任务切成那个方式 —— 与原型 openSmsFlow / openWxPush 一致;
Pad 按钮 = 切换方式 + 把文书滚到签名位(原型 switchToIpad) -->
<button
v-for="target in alternateMethods(task.method)"
:key="target"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'switch-method', target)"
@click="emit('action', entryActionFor(target))"
>
{{ readSigningMethod(target).short }}签署
{{ readSigningMethod(target).buttonLabel }}
</button>
<button
v-if="canDeliveryWrite && task.method === 'sms'"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'resend-sms')"
>
重发短信
</button>
<span v-else-if="task.method === 'wx'" class="action-hint">公众号推送接口待接入</span>
</template>
<!-- 待科室确认:患者已签完,此刻要动的是科室那一道章(原型 confirmDept 的动作组)。
@@ -0,0 +1,295 @@
<script setup lang="ts">
/**
* 「发送签署短信」弹窗(原型 V10.2 的 `#mdSmsFlow`)。
*
* 为什么要有这一步:短信这条链路的第一步是**发送** —— 先把号码确认对、把链接发出去,
* 任务才进入「等待患者签署」。少了它,「短信签署」在操作人眼里和「手写板签署」没区别。
*
* 手机号按原型带入脱敏值:能核对是不是这个号,又不把完整号码摆在屏幕上;
* 不动它就是「发给登记号码」(后端按任务解析),要改就整串重填、按 11 位校验。
*/
import { computed, ref, watch } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
}>()
const emit = defineEmits<{
close: []
/** mobile 为 null 表示「用登记号码发」,不改号码 */
send: [mobile: string | null]
}>()
/** 输入框里的值:默认是登记号码的脱敏写法 */
const inputValue = ref('')
/** 登记号码的脱敏值(判定「有没有改过」的基准) */
const registeredMasked = ref('')
const error = ref('')
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
const masked = props.task?.smsMobileMasked?.trim() ?? ''
registeredMasked.value = masked
inputValue.value = masked
error.value = ''
},
)
const unchanged = computed(() => inputValue.value.trim() === registeredMasked.value)
function submit() {
const raw = inputValue.value.trim()
if (!raw) {
error.value = '请填写患者手机号'
return
}
if (unchanged.value) {
// 登记号码本来就是脱敏的,前端拿不到完整值 —— 直接交给后端按任务解析
emit('send', null)
return
}
if (!/^1[3-9]\d{9}$/.test(raw)) {
error.value = '请输入正确的 11 位手机号'
return
}
emit('send', raw)
}
</script>
<template>
<Teleport to="body">
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
<section class="dialog-card" role="dialog" aria-modal="true" aria-labelledby="sms-flow-title">
<header class="dialog-header">
<strong id="sms-flow-title">发送签署短信</strong>
<span class="dialog-subtitle">{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<div class="field-grid">
<label class="field">
<span>患者姓名</span>
<input :value="task.patientName" disabled />
</label>
<label class="field">
<span>就诊号</span>
<input class="is-mono" :value="task.visitNo" disabled />
</label>
<label class="field field--wide">
<span>签署文档</span>
<input :value="task.documentName" disabled />
</label>
<label class="field field--wide">
<span>患者手机号(可修改,中间 4 位隐私展示)</span>
<input
v-model="inputValue"
maxlength="13"
placeholder="请输入 11 位手机号"
aria-label="患者手机号"
@input="error = ''"
/>
</label>
</div>
<p v-if="error" class="field-error">{{ error }}</p>
<p v-else class="field-hint">
不改号码即发送到登记号码;发送后任务进入「等待患者签署」,链接 48 小时内有效。
</p>
</div>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
取消
</button>
<button type="button" class="action-button" @click="submit">📨 发送</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
display: flex;
width: 520px;
max-width: 96vw;
max-height: calc(100vh - 32px);
flex-direction: column;
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.dialog-header {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
font-size: 15px;
}
.dialog-subtitle {
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-close {
flex-shrink: 0;
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
flex: 1;
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
}
.field--wide {
grid-column: 1 / 3;
}
.field span {
color: var(--mut);
font-size: 12px;
}
.field input {
width: 100%;
padding: 7px 10px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.field input:disabled {
color: var(--mut);
background: #f3f7f9;
}
.field input:focus {
border-color: var(--brand);
outline: none;
}
.is-mono {
font-family: Consolas, 'Courier New', monospace;
}
.field-hint,
.field-error {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.7;
}
.field-hint {
color: var(--mut);
}
.field-error {
color: var(--err);
}
.dialog-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.footer-spacer {
flex: 1;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style>
@@ -0,0 +1,254 @@
<script setup lang="ts">
/**
* 「微信公众号签署 · 消息推送」弹窗(原型 V10.2 的 `#mdWxPush`)。
*
* 与短信那条链路对称:这一步是**推送**,确认后患者才会在微信里收到服务通知,
* 点进子域名页面看文书并手写签署。弹窗里先把「患者将收到什么」原样摆出来
* (服务通知卡片 + 落点 URL),推送前能核对收件人和文书对不对。
*/
import type { SigningTaskRecord } from '@/api/workbench/types'
defineProps<{
visible: boolean
task: SigningTaskRecord | null
}>()
const emit = defineEmits<{
close: []
push: []
}>()
</script>
<template>
<Teleport to="body">
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
<section class="dialog-card" role="dialog" aria-modal="true" aria-labelledby="wx-push-title">
<header class="dialog-header">
<strong id="wx-push-title">微信公众号签署 · 消息推送</strong>
<span class="dialog-subtitle">{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<p class="dialog-note">
确认后,公众号向患者推送服务通知,患者点击消息进入签署子域名页面,
查看知情同意书并完成手写签署。
</p>
<div class="wx-card">
<div class="wx-card__head">
<span class="wx-avatar" aria-hidden="true">医</span>
<div class="wx-account">
<strong>【xx医院】</strong>
<span>服务通知</span>
</div>
<span class="wx-time">刚刚</span>
</div>
<div class="wx-card__body">
<strong>签署提醒:{{ task.documentName }}</strong>
<span>患者:{{ task.patientName }}({{ task.patientId }})</span>
<span>发起科室:{{ task.department }} · 就诊号 {{ task.visitNo }}</span>
<span class="wx-link">mpsign.hosp.cn/wx/{{ task.id }}</span>
</div>
</div>
</div>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
取消
</button>
<button type="button" class="action-button" @click="emit('push')">确认推送</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
display: flex;
width: 540px;
max-width: 96vw;
max-height: calc(100vh - 32px);
flex-direction: column;
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: dialog-pop 0.18s ease;
}
.dialog-header {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
font-size: 15px;
}
.dialog-subtitle {
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-close {
flex-shrink: 0;
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
flex: 1;
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
}
.dialog-note {
margin: 0 0 12px;
color: var(--mut);
font-size: 12.5px;
line-height: 1.8;
}
.wx-card {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: var(--sh);
}
.wx-card__head {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 14px;
background: #f7f8fa;
border-bottom: 1px solid var(--line);
}
.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,
.wx-time {
color: var(--mut);
font-size: 11.5px;
}
.wx-card__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: #fff;
}
.wx-card__body strong {
font-size: 13px;
}
.wx-card__body span {
color: var(--mut);
font-size: 12px;
}
.wx-link {
margin-top: 4px;
color: var(--brand) !important;
word-break: break-all;
}
.dialog-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.footer-spacer {
flex: 1;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
@keyframes dialog-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute } from 'vue-router'
@@ -18,8 +18,12 @@ import {
getSigningTasks,
getSigningTemplates,
isSigningMockEnabled,
pushSigningWxMessage,
reopenSigningTask,
resendSigningSms,
resendSigningWxMessage,
sendSigningSms,
simulatePatientSigning,
updateSigningTaskMethod,
voidSigningTask,
} from '@/api/workbench/signing'
@@ -48,7 +52,9 @@ import SignedDocumentDialog from './components/SignedDocumentDialog.vue'
import SigningFilterBar from './components/SigningFilterBar.vue'
import SigningTaskDetail from './components/SigningTaskDetail.vue'
import SigningTaskList from './components/SigningTaskList.vue'
import SmsSendDialog from './components/SmsSendDialog.vue'
import VoidTaskDialog from './components/VoidTaskDialog.vue'
import WxPushDialog from './components/WxPushDialog.vue'
import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from './types'
const route = useRoute()
@@ -111,6 +117,10 @@ const departmentIds = ref<Record<string, string>>({})
const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false)
/** 「发送签署短信」弹窗(原型 #mdSmsFlow) */
const smsSendVisible = ref(false)
/** 「公众号消息推送」弹窗(原型 #mdWxPush) */
const wxPushVisible = ref(false)
const voidDialogVisible = ref(false)
/** 已签署文书的在线预览弹窗(原型 openPdfView) */
const signedPreviewVisible = ref(false)
@@ -142,6 +152,26 @@ const canViewEvents = computed(() => canRead('sign:task:event:query'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
/**
* 「投递类」动作:签署本身、方式切换、签署消息的发送与重发。
*
* 它们动的是对外可见的东西(患者会收到短信 / 推送,或任务状态直接变),
* 与「创建签署会话」同级 —— 都要 `sign:delivery:write`。
* 归到一处是因为原来逐个 `action === 'x' && ...` 地列,加一个方式就漏一个
* (漏掉的表现是「切到 Pad 就提示没有权限」)。
*/
const DELIVERY_ACTIONS: SigningTaskAction[] = [
'pad-sign',
'online-sign',
'switch-method',
'switch-to-ipad',
'send-sms',
'send-wx-push',
'simulate-patient-sign',
'resend-sms',
'resend-wx-push',
]
/**
* 状态筛选的取值:原型 `#fStat` 只有 6 项
* (全部 / 待签署 / 待科室确认 / 已签署 / 已超时 / 已作废)。
@@ -501,7 +531,7 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
const allowed =
isSigningMockEnabled ||
(action === 'create-pad-session' && canDeliveryWrite.value) ||
(action === 'resend-sms' && canDeliveryWrite.value) ||
(DELIVERY_ACTIONS.includes(action) && canDeliveryWrite.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
(action === 'confirm-dept' && canConfirmTask.value) ||
@@ -524,6 +554,22 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
return
}
/**
* 原型把两种现场方式分得很清楚:
* - 手写板 → `openPad()`,直接开签名板;
* - Pad → `openIpad()`,**不弹窗**,只把文书滚到第一个签名位并高亮,
* 提示「点击签名框弹出签名板」,由操作人自己点那个框。
* 差别就在这里:一个直接签,一个是「在文书上找到该签的地方」再签。
*/
if (task.method === 'ipad') {
if (taskDetailRef.value?.locateSignatureSlot()) {
ElMessage.info('已下拉到签名位置,点击签名框弹出签名板完成签署')
} else {
ElMessage.error('当前文书未找到签名位置')
}
return
}
if (!(await assertSigningReady())) {
return
}
@@ -532,6 +578,81 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
return
}
// 短信 / 公众号:先开「发送」弹窗(原型 openSmsFlow / openWxPush)。
// 发送成功才算把任务切成那个方式 —— 见 handleSmsSent / handleWxPushed
if (action === 'send-sms') {
if (!isSigningMockEnabled) {
ElMessage.info('发送签署短信接口待接入')
return
}
smsSendVisible.value = true
return
}
if (action === 'send-wx-push') {
if (!isSigningMockEnabled) {
ElMessage.info('公众号签署推送接口待接入')
return
}
wxPushVisible.value = true
return
}
// 演示入口:没人真的去点短信链接 / 打开公众号消息,不给它整条链路走不完
if (action === 'simulate-patient-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('演示入口,真实模式请由患者在签署页完成')
return
}
try {
const updated = await simulatePatientSigning(task.id)
await refreshTask(updated, signingCompletedMessage(updated))
} catch {
ElMessage.error('模拟患者签署失败,请稍后重试')
}
return
}
if (action === 'resend-wx-push') {
if (!isSigningMockEnabled) {
ElMessage.info('公众号签署推送接口待接入')
return
}
try {
await refreshTask(await resendSigningWxMessage(task.id), '公众号签署消息已重新推送')
} catch {
ElMessage.error('重发推送失败,请稍后重试')
}
return
}
// 原型 switchToIpad:先切成 Pad 方式,再定位到签名位(两步是一件事)
if (action === 'switch-to-ipad') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
try {
await refreshTask(await updateSigningTaskMethod(task.id, 'ipad'), '已切换为 📲 Pad 签署')
await nextTick()
if (taskDetailRef.value?.locateSignatureSlot()) {
ElMessage.info('已下拉到签名位置,点击签名框弹出签名板完成签署')
}
} catch {
ElMessage.error('任务操作失败,请稍后重试')
}
return
}
if (action === 'create-pad-session') {
padSessionLoading.value = true
@@ -867,6 +988,46 @@ async function handleOnlineResend() {
await handleTaskAction('resend-sms')
}
/**
* 发送签署短信成功(原型 `sendSmsFlow`):方式切换在这里完成 ——
* 发送前那条任务可能还是手写板 / Pad,发送后它才是一条「等待患者签署」的短信任务。
*/
async function handleSmsSent(mobile: string | null) {
const task = selectedTask.value
if (!task) {
return
}
try {
const updated = await sendSigningSms(task.id, mobile)
smsSendVisible.value = false
await refreshTask(
updated,
`签署短信已发送${updated?.smsMobileMasked ? `至 ${updated.smsMobileMasked}` : ''},等待患者签署`,
)
} catch {
ElMessage.error('发送签署短信失败,请稍后重试')
}
}
/** 确认推送公众号签署消息(原型 `sendWxPush`):方式同样在这一步切成 wx */
async function handleWxPushed() {
const task = selectedTask.value
if (!task) {
return
}
try {
const updated = await pushSigningWxMessage(task.id)
wxPushVisible.value = false
await refreshTask(updated, '公众号签署消息已推送给患者,等待患者签署')
} catch {
ElMessage.error('公众号推送失败,请稍后重试')
}
}
async function initializePage() {
await appStore.loadCampuses()
syncFilterFromRoute()
@@ -982,6 +1143,19 @@ onMounted(() => {
@complete="completeOnlineSignature"
@resend="handleOnlineResend"
/>
<!-- 短信 / 公众号链路的**第一步**:先把消息发出去(原型 #mdSmsFlow / #mdWxPush) -->
<SmsSendDialog
:visible="smsSendVisible"
:task="selectedTask"
@close="smsSendVisible = false"
@send="handleSmsSent"
/>
<WxPushDialog
:visible="wxPushVisible"
:task="selectedTask"
@close="wxPushVisible = false"
@push="handleWxPushed"
/>
<SignedDocumentDialog
:visible="signedPreviewVisible"
:task="selectedTask"
@@ -45,6 +45,19 @@ export type SigningTaskAction =
| 'pad-sign'
| 'create-pad-session'
| 'online-sign'
/**
* 切到 Pad 方式并把文书滚到第一个签名位(原型 `switchToIpad`)。
* 与 `pad-sign` 分开:后者是「按当前方式签」,这个是「先换成 Pad 再定位」。
*/
| 'switch-to-ipad'
/** 打开「发送签署短信」弹窗(原型 `openSmsFlow`) */
| 'send-sms'
/** 打开「微信公众号签署 · 消息推送」弹窗(原型 `openWxPush`) */
| 'send-wx-push'
/** 模拟患者完成签署(演示;原型 `smsSimulate` / `wxSimulate`) */
| 'simulate-patient-sign'
/** 重发公众号推送(原型 `wxResend`) */
| 'resend-wx-push'
| 'resend-sms'
| 'switch-method'
| 'reopen'