feat(签署工作台): 补齐短信/公众号发送弹窗与 Pad 定位签名位流程
This commit is contained in:
@@ -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):
|
||||
* 编辑器标记与这里识别必须用同一份字符集,故不再本地声明。
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user