feat(患者签署): 接入签署上下文接口并补齐文书渲染与手写签名链路
This commit is contained in:
@@ -100,7 +100,7 @@ defineExpose({ getDocumentElement })
|
|||||||
<template>
|
<template>
|
||||||
<article class="sign-doc">
|
<article class="sign-doc">
|
||||||
<div class="sign-doc-banner">
|
<div class="sign-doc-banner">
|
||||||
<span>已按任务固化的文书版本渲染(填写项已按患者信息预填)</span>
|
<span></span>
|
||||||
<span class="sign-doc-state" :class="{ 'is-void': task.status === 'void' }">
|
<span class="sign-doc-state" :class="{ 'is-void': task.status === 'void' }">
|
||||||
{{ signatureStateLabel }}
|
{{ signatureStateLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
SignatureUploadInput,
|
SignatureUploadInput,
|
||||||
SignatureUploadResponse,
|
SignatureUploadResponse,
|
||||||
|
SigningContextResponse,
|
||||||
TokenConsumeRequest,
|
TokenConsumeRequest,
|
||||||
TokenConsumeResponse,
|
TokenConsumeResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -19,6 +20,21 @@ export function consumeSigningToken(payload: TokenConsumeRequest): Promise<Token
|
|||||||
.then(unwrapApiResponse)
|
.then(unwrapApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本次签署的完整上下文:患者信息 / 签署人 / 文书正文 / 医生已填结果。
|
||||||
|
*
|
||||||
|
* 契约见 `.workbuddy-ai/患者端H5真实接入方案-2026-09-28.md` 第三节。
|
||||||
|
* **后端就绪前这个调用会失败**,所以调用方必须能优雅降级 ——
|
||||||
|
* 拿不到正文就不能放行签署(见 `useSignFlow` 的 `documentError`)。
|
||||||
|
*/
|
||||||
|
export function fetchSigningContext(token: string): Promise<SigningContextResponse> {
|
||||||
|
return request
|
||||||
|
.get<ApiResponse<SigningContextResponse>>('/v1/sign-deliveries/token/context', {
|
||||||
|
params: { token },
|
||||||
|
})
|
||||||
|
.then(unwrapApiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
function createIdempotencyKey(value?: string) {
|
function createIdempotencyKey(value?: string) {
|
||||||
if (value) {
|
if (value) {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ export interface ApiResponse<T> {
|
|||||||
timestamp?: string
|
timestamp?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SignDeliveryChannel = 'PAD' | 'SMS'
|
/**
|
||||||
|
* 投递渠道。
|
||||||
|
*
|
||||||
|
* - `PAD`:现场手写板,设备上跑的就是本 H5;
|
||||||
|
* - `SMS`:短信链接;
|
||||||
|
* - `WX`:微信公众号(2026-09-28 补 —— 原契约只有 PAD / SMS,公众号一直没落点)。
|
||||||
|
*
|
||||||
|
* 注意 **Pad 移动端签署不走这里**:那是在医护端工作台里用平板现场签的,不产生投递。
|
||||||
|
*/
|
||||||
|
export type SignDeliveryChannel = 'PAD' | 'SMS' | 'WX'
|
||||||
|
|
||||||
export interface TokenConsumeRequest {
|
export interface TokenConsumeRequest {
|
||||||
token: string
|
token: string
|
||||||
|
/** 允许不传:渠道由 token 决定,前端不该替后端写死一个 */
|
||||||
channel?: SignDeliveryChannel
|
channel?: SignDeliveryChannel
|
||||||
taskId?: string
|
taskId?: string
|
||||||
}
|
}
|
||||||
@@ -23,6 +33,82 @@ export interface TokenConsumeResponse {
|
|||||||
timestamp?: string | null
|
timestamp?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 患者端看到的任务状态。不是 `WAITING_SIGN` 时不该放行签署 */
|
||||||
|
export type SigningTaskSignStatus =
|
||||||
|
| 'WAITING_SIGN'
|
||||||
|
| 'SIGNED'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| 'VOIDED'
|
||||||
|
| 'FAILED'
|
||||||
|
| string
|
||||||
|
|
||||||
|
export interface SigningContextPatient {
|
||||||
|
name?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
age?: number | null
|
||||||
|
visitNo?: string | null
|
||||||
|
department?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 签署人信息。按业务口径由医生在医护端填好,患者端只展示、不修改 */
|
||||||
|
export interface SigningContextSigner {
|
||||||
|
/** `SELF` = 本人;`FAMILY` = 家属代签 */
|
||||||
|
type?: string | null
|
||||||
|
name?: string | null
|
||||||
|
relation?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningContextTemplate {
|
||||||
|
name?: string | null
|
||||||
|
category?: string | null
|
||||||
|
versionNo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningContextDocument {
|
||||||
|
contentHtml?: string | null
|
||||||
|
contentSha256?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningContextFieldOption {
|
||||||
|
value: string
|
||||||
|
label?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningContextField {
|
||||||
|
id: string
|
||||||
|
label?: string | null
|
||||||
|
kind?: string | null
|
||||||
|
required?: boolean | null
|
||||||
|
options?: SigningContextFieldOption[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 患者端读取本次签署的完整上下文。
|
||||||
|
*
|
||||||
|
* 与 `token/consume` 的职责**分开**:consume 是「消费一次」换投递凭证,
|
||||||
|
* 这个接口要能**重复调用**(患者刷新、返回上一步都要能重进)。
|
||||||
|
*/
|
||||||
|
export interface SigningContextResponse {
|
||||||
|
taskId: string
|
||||||
|
deliveryId?: string | null
|
||||||
|
channel?: SignDeliveryChannel | null
|
||||||
|
status?: SigningTaskSignStatus | null
|
||||||
|
expiresAt?: string | null
|
||||||
|
patient?: SigningContextPatient | null
|
||||||
|
signer?: SigningContextSigner | null
|
||||||
|
template?: SigningContextTemplate | null
|
||||||
|
document?: SigningContextDocument | null
|
||||||
|
/**
|
||||||
|
* 医生已填的结果(含医师签名图),患者端**只读**渲染进正文占位符。
|
||||||
|
*
|
||||||
|
* 刻意与 `document.contentHtml` 分开:医生填了什么要能单独追溯,
|
||||||
|
* 烙进正文 HTML 就查不出「谁在什么时候填了什么」。
|
||||||
|
*/
|
||||||
|
staffAnswers?: Record<string, string> | null
|
||||||
|
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||||
|
fields?: SigningContextField[] | null
|
||||||
|
}
|
||||||
|
|
||||||
export type SignatureUploadStatus =
|
export type SignatureUploadStatus =
|
||||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string
|
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 患者端文书纸。
|
||||||
|
*
|
||||||
|
* 与 clinical-web 的 `components/document/DocumentPaper.vue` 是**两份独立实现**:
|
||||||
|
* 那边排的是 A4 纸(打印与 PDF 导出要对齐),这边排的是手机屏(大字、宽行距、可滚动),
|
||||||
|
* 排版目标不同,硬复用只会两头都别扭。
|
||||||
|
*
|
||||||
|
* 真正共用的是**占位符约定**(`<span data-field data-kind data-label>`),
|
||||||
|
* 由 clinical-web 的 `utils/signing-fields.ts` 写入正文 —— 改那边要同步看这里。
|
||||||
|
*
|
||||||
|
* 填充口径(本次业务口径:医生先填好,患者只签自己的名):
|
||||||
|
* - 医生已填的字段(勾选项 / 文本 / 医师签名)→ **只读**渲染;
|
||||||
|
* - 患者自己的签名位(`id === patientFieldId`)→ 未签时显示「待患者签名」占位。
|
||||||
|
*/
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 文书正文 HTML(含占位符),由 context 接口返回 */
|
||||||
|
contentHtml: string
|
||||||
|
/** 医生已填的结果:键是字段 id,值是文本或签名图地址 */
|
||||||
|
answers?: Record<string, string>
|
||||||
|
/** 患者签名图。签署完成后回填到患者签名位 */
|
||||||
|
patientSignature?: string | null
|
||||||
|
/** 患者签名位的字段 id(与 clinical-web 的 PRIMARY_SIGNATURE_FIELD_ID 同口径) */
|
||||||
|
patientFieldId?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
answers: () => ({}),
|
||||||
|
patientSignature: null,
|
||||||
|
patientFieldId: 'signature',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const paperRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/** 勾选值判定:空串 / '0' / 'false' / 'off' 都算没勾 */
|
||||||
|
function isChecked(value: string) {
|
||||||
|
const raw = value.trim().toLowerCase()
|
||||||
|
|
||||||
|
return raw !== '' && raw !== '0' && raw !== 'false' && raw !== 'off'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 看起来像图片地址的值一律当签名图处理(后端给 dataURL 或 URL 都行) */
|
||||||
|
function isImageValue(value: string) {
|
||||||
|
return /^(data:image\/|https?:\/\/|\/)/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSignatureImage(node: HTMLElement, src: string, alt: string) {
|
||||||
|
node.textContent = ''
|
||||||
|
const image = document.createElement('img')
|
||||||
|
image.className = 'field-signature'
|
||||||
|
image.alt = alt
|
||||||
|
image.src = src
|
||||||
|
node.appendChild(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把结果填进正文占位符。
|
||||||
|
*
|
||||||
|
* 直接在 DOM 上改,而不是在模板里拼 HTML:占位符散落在正文任意位置
|
||||||
|
* (表格单元格里、段落中间都有),`v-html` 渲染完再按 `data-field` 定位是唯一可靠的做法。
|
||||||
|
*/
|
||||||
|
function paint() {
|
||||||
|
const root = paperRef.value
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll<HTMLElement>('[data-field]').forEach((node) => {
|
||||||
|
const id = node.getAttribute('data-field')?.trim() ?? ''
|
||||||
|
const kind = node.getAttribute('data-kind')?.trim() ?? 'text'
|
||||||
|
const label = (node.getAttribute('data-label') ?? node.textContent ?? '').trim()
|
||||||
|
|
||||||
|
node.classList.remove('is-filled', 'is-empty', 'is-patient-sign')
|
||||||
|
|
||||||
|
// ① 患者自己的签名位:没签就如实显示「待患者签名」,不拿模板标签冒充
|
||||||
|
if (id && id === props.patientFieldId) {
|
||||||
|
node.classList.add('is-patient-sign')
|
||||||
|
|
||||||
|
if (props.patientSignature) {
|
||||||
|
node.classList.add('is-filled')
|
||||||
|
appendSignatureImage(node, props.patientSignature, label || '患者签名')
|
||||||
|
} else {
|
||||||
|
node.classList.add('is-empty')
|
||||||
|
node.textContent = '待患者签名'
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = (props.answers?.[id] ?? '').trim()
|
||||||
|
|
||||||
|
// ② 没有值:保留模板里的标签文字,只标记成空
|
||||||
|
if (!value) {
|
||||||
|
node.classList.add('is-empty')
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
node.textContent = label
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.classList.add('is-filled')
|
||||||
|
|
||||||
|
// ③ 签名图(医师签名等)
|
||||||
|
if (kind === 'signature' || isImageValue(value)) {
|
||||||
|
appendSignatureImage(node, value, label || '签名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 勾选类:单选一律显示已勾,多选/复选按值判
|
||||||
|
if (kind === 'checkbox' || kind === 'choice') {
|
||||||
|
const checked = kind === 'choice' ? true : isChecked(value)
|
||||||
|
// 值是 1/0 这类占位时回落到标签文字,避免正文里出现一个孤零零的「1」
|
||||||
|
const text = value === '1' || value === '0' ? label || value : value
|
||||||
|
|
||||||
|
node.textContent = `${checked ? '☑' : '☐'} ${text}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 其余(text / date)按文本显示
|
||||||
|
node.textContent = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void nextTick(paint)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.contentHtml, props.answers, props.patientSignature],
|
||||||
|
() => {
|
||||||
|
void nextTick(paint)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
正文来自医院自己的文书模板(经后端存储下发),不是用户输入,因此这里用 v-html 渲染。
|
||||||
|
患者端不解析、不执行其中任何脚本 —— 后端下发前应已做清洗。
|
||||||
|
-->
|
||||||
|
<article ref="paperRef" class="patient-paper" v-html="contentHtml" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.patient-paper {
|
||||||
|
padding: 18px 16px 22px;
|
||||||
|
color: #173b56;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.9;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 12px 30px rgb(33 79 92 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 正文是 v-html 插进来的,不带 scoped 属性,必须用 :deep() 才命中 */
|
||||||
|
.patient-paper :deep(h2) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(h3) {
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(p) {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(ol),
|
||||||
|
.patient-paper :deep(ul) {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(li) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(td),
|
||||||
|
.patient-paper :deep(th) {
|
||||||
|
padding: 8px 10px;
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid #dbe7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 占位符:没值显示成浅色待填,有值显示成加粗的实填内容 */
|
||||||
|
.patient-paper :deep([data-field]) {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep([data-field].is-empty) {
|
||||||
|
color: #9aabb3;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep([data-field].is-filled) {
|
||||||
|
color: #0f5d75;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 患者签名位:给一条虚线底边,让患者一眼看出该在这儿签 */
|
||||||
|
.patient-paper :deep([data-field].is-patient-sign) {
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-bottom: 1px dashed #b9d8d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-paper :deep(.field-signature) {
|
||||||
|
display: block;
|
||||||
|
max-height: 90px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,44 +1,111 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
/**
|
||||||
|
* 手写签名画布(患者端)。
|
||||||
|
*
|
||||||
|
* 与 clinical-web 的 `components/SignatureCanvas.vue` 是**两份独立实现**(两个应用不共享代码),
|
||||||
|
* 但**事件契约必须一致**:都发 `update:dataUrl`,调用方写法才不用记两套。
|
||||||
|
*
|
||||||
|
* 2026-09-28 修:这份原先只有 `defineExpose`、**没有 `defineEmits`**,
|
||||||
|
* 而 `SignerView` 一直在监听 `@update:data-url` —— 事件从不派发,
|
||||||
|
* `signatureDataUrl` 恒为空串,真实模式提交必然报「未读取到签名图片」。
|
||||||
|
* 同时修掉三处会直接毁掉签名手感的问题:
|
||||||
|
* ① `pointerleave` 断笔(手指划到画布边缘外就断,长横画被切成两段);
|
||||||
|
* ② resize 无笔迹保护(手机键盘弹出 / 旋转就清空已签内容);
|
||||||
|
* ③ `devicePixelRatio` 未设上限(高分屏画布过大,笔迹发虚且卡顿)。
|
||||||
|
*/
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 画布高度(CSS 像素)。患者端建议不低于 180px —— 太小写不下完整姓名 */
|
||||||
|
height?: number
|
||||||
|
placeholder?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
height: 180,
|
||||||
|
placeholder: '请 在 此 签 名',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:dataUrl': [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
const hasSignature = ref(false)
|
/** 有没有落过笔。用于占位文案与「签名为空」的前置校验 */
|
||||||
|
const hasInk = ref(false)
|
||||||
|
|
||||||
let drawingContext: CanvasRenderingContext2D | null = null
|
let context: CanvasRenderingContext2D | null = null
|
||||||
|
let pixelRatio = 1
|
||||||
let drawing = false
|
let drawing = false
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
function resizeCanvas() {
|
/**
|
||||||
|
* 按当前尺寸重建画布。
|
||||||
|
*
|
||||||
|
* 重建前先把已有笔迹拷到离屏画布,重建后原样画回去 —— 手机键盘弹出、
|
||||||
|
* 横竖屏切换都会触发 resize,不这么做患者会看到刚签好的名字凭空消失。
|
||||||
|
*/
|
||||||
|
function setupCanvas() {
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
const ratio = Math.max(window.devicePixelRatio || 1, 1)
|
const nextRatio = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
canvas.width = Math.floor(rect.width * ratio)
|
const nextWidth = Math.max(Math.round(rect.width * nextRatio), 1)
|
||||||
canvas.height = Math.floor(rect.height * ratio)
|
const nextHeight = Math.max(Math.round(props.height * nextRatio), 1)
|
||||||
|
|
||||||
const context = canvas.getContext('2d')
|
// 尺寸没变就别动画布:重建一次就丢一次笔迹,而 resize 触发得非常频繁
|
||||||
if (!context) {
|
if (canvas.width === nextWidth && canvas.height === nextHeight && context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context.setTransform(ratio, 0, 0, ratio, 0, 0)
|
let snapshot: HTMLCanvasElement | null = null
|
||||||
context.lineCap = 'round'
|
|
||||||
context.lineJoin = 'round'
|
if (hasInk.value && canvas.width > 0 && canvas.height > 0) {
|
||||||
context.lineWidth = 2.4
|
snapshot = document.createElement('canvas')
|
||||||
context.strokeStyle = '#173b56'
|
snapshot.width = canvas.width
|
||||||
drawingContext = context
|
snapshot.height = canvas.height
|
||||||
hasSignature.value = false
|
snapshot.getContext('2d')?.drawImage(canvas, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pixelRatio = nextRatio
|
||||||
|
canvas.width = nextWidth
|
||||||
|
canvas.height = nextHeight
|
||||||
|
|
||||||
|
const nextContext = canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!nextContext) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)
|
||||||
|
nextContext.lineCap = 'round'
|
||||||
|
nextContext.lineJoin = 'round'
|
||||||
|
nextContext.lineWidth = 2.4
|
||||||
|
nextContext.strokeStyle = '#173b56'
|
||||||
|
context = nextContext
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
nextContext.drawImage(snapshot, 0, 0, rect.width, props.height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInk.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPoint(event: PointerEvent) {
|
function getPoint(event: PointerEvent) {
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: event.clientX - rect.left,
|
x: event.clientX - rect.left,
|
||||||
y: event.clientY - rect.top,
|
y: event.clientY - rect.top,
|
||||||
@@ -47,66 +114,100 @@ function getPoint(event: PointerEvent) {
|
|||||||
|
|
||||||
function startDrawing(event: PointerEvent) {
|
function startDrawing(event: PointerEvent) {
|
||||||
const point = getPoint(event)
|
const point = getPoint(event)
|
||||||
if (!drawingContext || !point) {
|
|
||||||
|
if (!context || !point) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
|
|
||||||
|
// 指针捕获:手指划出画布边界后事件仍派发到这里,不必再挂 pointerleave,
|
||||||
|
// 也就不会出现「划到边缘就断笔」
|
||||||
canvas?.setPointerCapture(event.pointerId)
|
canvas?.setPointerCapture(event.pointerId)
|
||||||
drawing = true
|
drawing = true
|
||||||
drawingContext.beginPath()
|
context.beginPath()
|
||||||
drawingContext.moveTo(point.x, point.y)
|
context.moveTo(point.x, point.y)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw(event: PointerEvent) {
|
function draw(event: PointerEvent) {
|
||||||
if (!drawing || !drawingContext) {
|
if (!drawing || !context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = getPoint(event)
|
const point = getPoint(event)
|
||||||
|
|
||||||
if (!point) {
|
if (!point) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
drawingContext.lineTo(point.x, point.y)
|
context.lineTo(point.x, point.y)
|
||||||
drawingContext.stroke()
|
context.stroke()
|
||||||
hasSignature.value = true
|
hasInk.value = true
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopDrawing(event: PointerEvent) {
|
function stopDrawing(event: PointerEvent) {
|
||||||
|
if (!drawing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drawing = false
|
||||||
|
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (drawing && canvas?.hasPointerCapture(event.pointerId)) {
|
|
||||||
|
if (canvas?.hasPointerCapture(event.pointerId)) {
|
||||||
canvas.releasePointerCapture(event.pointerId)
|
canvas.releasePointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
drawing = false
|
|
||||||
|
// 抬笔即上报当前签名;没落笔就报 null,让调用方拿到明确的「空」
|
||||||
|
emit('update:dataUrl', hasInk.value ? getDataUrl() : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataUrl() {
|
||||||
|
return canvasRef.value?.toDataURL('image/png') ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSignature() {
|
function clearSignature() {
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
if (!canvas || !drawingContext) {
|
|
||||||
|
if (!canvas || !context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
drawingContext.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)
|
context.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)
|
||||||
hasSignature.value = false
|
hasInk.value = false
|
||||||
|
emit('update:dataUrl', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmpty() {
|
function isEmpty() {
|
||||||
return !hasSignature.value
|
return !hasInk.value
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
defineExpose({ clearSignature, isEmpty, getDataUrl })
|
||||||
resizeCanvas()
|
|
||||||
window.addEventListener('resize', resizeCanvas)
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
setupCanvas()
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && canvasRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
setupCanvas()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(canvasRef.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.height,
|
||||||
|
() => {
|
||||||
|
void nextTick(setupCanvas)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', resizeCanvas)
|
resizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ clearSignature, isEmpty })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -115,16 +216,19 @@ defineExpose({ clearSignature, isEmpty })
|
|||||||
<span>请在框内手写签名</span>
|
<span>请在框内手写签名</span>
|
||||||
<span class="signature-hint">签名需清晰完整</span>
|
<span class="signature-hint">签名需清晰完整</span>
|
||||||
</div>
|
</div>
|
||||||
<canvas
|
<div class="signature-field">
|
||||||
ref="canvasRef"
|
<canvas
|
||||||
class="signature-canvas"
|
ref="canvasRef"
|
||||||
aria-label="手写签名区域"
|
class="signature-canvas"
|
||||||
@pointerdown="startDrawing"
|
:style="{ height: `${height}px` }"
|
||||||
@pointermove="draw"
|
aria-label="手写签名区域"
|
||||||
@pointerup="stopDrawing"
|
@pointerdown="startDrawing"
|
||||||
@pointercancel="stopDrawing"
|
@pointermove="draw"
|
||||||
@pointerleave="stopDrawing"
|
@pointerup="stopDrawing"
|
||||||
/>
|
@pointercancel="stopDrawing"
|
||||||
|
/>
|
||||||
|
<span v-if="!hasInk" class="signature-placeholder">{{ placeholder }}</span>
|
||||||
|
</div>
|
||||||
<button class="clear-button" type="button" @click="clearSignature">清除重签</button>
|
<button class="clear-button" type="button" @click="clearSignature">清除重签</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,65 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
import { consumeSigningToken, uploadSigningSignature } from '@/api'
|
import { consumeSigningToken, fetchSigningContext, uploadSigningSignature } from '@/api'
|
||||||
import type { SignatureUploadResponse } from '@/api/types'
|
import type {
|
||||||
import type { SignFlowState } from '@/types/signing'
|
SignDeliveryChannel,
|
||||||
|
SignatureUploadResponse,
|
||||||
|
SigningContextResponse,
|
||||||
|
} from '@/api/types'
|
||||||
|
import { DEMO_DOCUMENT_HTML, DEMO_PATIENT_FIELD_ID, DEMO_STAFF_ANSWERS } from '@/mock/demo-document'
|
||||||
|
import type { SignerRelation, SignFlowState } from '@/types/signing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地会话(sessionStorage)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:`EntryView` 会在请求前把 `token` 从地址栏清掉(防外泄,必要),
|
||||||
|
* 但清掉之后患者只要**下拉刷新 / 切后台被系统回收 / 误触返回**,就再也进不来了 ——
|
||||||
|
* 表现成「链接失效」,而链接其实还是好的。
|
||||||
|
*
|
||||||
|
* 所以消费成功后把投递凭证与上下文缓存到 sessionStorage(关掉标签页即失效,
|
||||||
|
* 不写 localStorage 是为了不在这台设备上长期留患者信息)。
|
||||||
|
*/
|
||||||
|
const SESSION_KEY = 'medical-sign:patient-session'
|
||||||
|
|
||||||
|
/** 后端没给 `expiresAt` 时的兜底有效期,避免本地会话永不过期 */
|
||||||
|
const SESSION_FALLBACK_TTL_MS = 2 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface PersistedSession {
|
||||||
|
taskId: string
|
||||||
|
taskNo: string
|
||||||
|
deliveryId: string
|
||||||
|
uploadToken: string
|
||||||
|
channel: SignDeliveryChannel | ''
|
||||||
|
expiresAt: string | null
|
||||||
|
savedAt: number
|
||||||
|
/** 连上下文一起缓存:刷新后没有 token 可再用,重拉不了 */
|
||||||
|
context: SigningContextResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: SignFlowState = {
|
const initialState: SignFlowState = {
|
||||||
mode: 'mock',
|
mode: 'mock',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
taskStatus: '',
|
||||||
patientName: '张*',
|
patientName: '张*',
|
||||||
|
sex: '',
|
||||||
|
age: '',
|
||||||
visitNo: 'MZ20260827018',
|
visitNo: 'MZ20260827018',
|
||||||
|
department: '',
|
||||||
documentTitle: '儿科急诊特殊检查知情同意书',
|
documentTitle: '儿科急诊特殊检查知情同意书',
|
||||||
|
templateVersion: 'v1.0.0',
|
||||||
|
documentContentHtml: '',
|
||||||
|
contentSha256: '',
|
||||||
|
staffAnswers: {},
|
||||||
|
fields: [],
|
||||||
|
documentLoaded: false,
|
||||||
|
documentError: '',
|
||||||
taskNo: 'CT-20260827-001',
|
taskNo: 'CT-20260827-001',
|
||||||
taskId: '',
|
taskId: '',
|
||||||
deliveryId: '',
|
deliveryId: '',
|
||||||
uploadToken: '',
|
uploadToken: '',
|
||||||
channel: '',
|
channel: '',
|
||||||
consentAccepted: false,
|
consentAccepted: false,
|
||||||
|
signerType: '',
|
||||||
signerName: '',
|
signerName: '',
|
||||||
relation: '',
|
relation: '',
|
||||||
signatureCaptured: false,
|
signatureCaptured: false,
|
||||||
@@ -28,26 +72,263 @@ const initialState: SignFlowState = {
|
|||||||
|
|
||||||
export const signingState = reactive<SignFlowState>({ ...initialState })
|
export const signingState = reactive<SignFlowState>({ ...initialState })
|
||||||
|
|
||||||
|
/** 最近一次拿到的上下文,只用于写本地缓存 */
|
||||||
|
let lastContext: SigningContextResponse | null = null
|
||||||
|
/** 会话有效期:consume 不返回,由 context 补上 */
|
||||||
|
let sessionExpiresAt: string | null = null
|
||||||
|
|
||||||
export function resetSignFlow() {
|
export function resetSignFlow() {
|
||||||
Object.assign(signingState, {
|
Object.assign(signingState, {
|
||||||
...initialState,
|
...initialState,
|
||||||
|
staffAnswers: {},
|
||||||
|
fields: [],
|
||||||
uploadResult: null,
|
uploadResult: null,
|
||||||
})
|
})
|
||||||
|
lastContext = null
|
||||||
|
sessionExpiresAt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '签署链接验证失败,请联系现场医务人员。'
|
return error instanceof Error ? error.message : '签署链接验证失败,请联系现场医务人员。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 本地会话 ---------------- */
|
||||||
|
|
||||||
|
function readPersistedSession(): PersistedSession | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as PersistedSession
|
||||||
|
|
||||||
|
if (!parsed?.taskId || !parsed?.uploadToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSession() {
|
||||||
|
if (!signingState.taskId || !signingState.uploadToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: PersistedSession = {
|
||||||
|
taskId: signingState.taskId,
|
||||||
|
taskNo: signingState.taskNo,
|
||||||
|
deliveryId: signingState.deliveryId,
|
||||||
|
uploadToken: signingState.uploadToken,
|
||||||
|
channel: signingState.channel,
|
||||||
|
expiresAt: sessionExpiresAt,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
context: lastContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload))
|
||||||
|
} catch {
|
||||||
|
// 无痕模式 / 配额写满:存不下就不存,不影响本次签署
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPersistedSession() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(SESSION_KEY)
|
||||||
|
} catch {
|
||||||
|
// 同上
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionExpired(session: PersistedSession) {
|
||||||
|
const expiresAt = session.expiresAt ? new Date(session.expiresAt).getTime() : Number.NaN
|
||||||
|
|
||||||
|
if (!Number.isNaN(expiresAt)) {
|
||||||
|
return expiresAt <= Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.now() - session.savedAt > SESSION_FALLBACK_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用本地会话恢复现场。恢复不了返回 false,由调用方走「链接无效」分支 */
|
||||||
|
function restoreSession() {
|
||||||
|
const session = readPersistedSession()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSessionExpired(session)) {
|
||||||
|
clearPersistedSession()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
signingState.mode = 'real'
|
||||||
|
signingState.taskId = session.taskId
|
||||||
|
signingState.taskNo = session.taskNo || session.taskId
|
||||||
|
signingState.deliveryId = session.deliveryId
|
||||||
|
signingState.uploadToken = session.uploadToken
|
||||||
|
signingState.channel = session.channel
|
||||||
|
sessionExpiresAt = session.expiresAt
|
||||||
|
|
||||||
|
if (session.context) {
|
||||||
|
applyContext(session.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
signingState.status = 'ready'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 上下文 ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `context` 响应铺进页面状态。
|
||||||
|
*
|
||||||
|
* 逐字段判空再覆盖(而不是整体替换):后端可能只补了一部分字段,
|
||||||
|
* 缺的字段应该保留原值,不能把患者姓名冲成空串。
|
||||||
|
*/
|
||||||
|
function applyContext(context: SigningContextResponse) {
|
||||||
|
lastContext = context
|
||||||
|
|
||||||
|
const patient = context.patient ?? undefined
|
||||||
|
|
||||||
|
if (patient?.name?.trim()) {
|
||||||
|
signingState.patientName = patient.name.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patient?.sex) {
|
||||||
|
signingState.sex = patient.sex
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patient?.age !== null && patient?.age !== undefined) {
|
||||||
|
signingState.age = String(patient.age)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patient?.visitNo) {
|
||||||
|
signingState.visitNo = patient.visitNo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patient?.department) {
|
||||||
|
signingState.department = patient.department
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签署人由医生在医护端填好,患者端只展示 —— 取不到就退回患者本人
|
||||||
|
const signer = context.signer ?? undefined
|
||||||
|
|
||||||
|
if (signer?.type) {
|
||||||
|
signingState.signerType = signer.type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signer?.name?.trim()) {
|
||||||
|
signingState.signerName = signer.name.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signer?.relation) {
|
||||||
|
signingState.relation = signer.relation as SignerRelation
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = context.template ?? undefined
|
||||||
|
|
||||||
|
if (template?.name?.trim()) {
|
||||||
|
signingState.documentTitle = template.name.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template?.versionNo) {
|
||||||
|
signingState.templateVersion = template.versionNo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.document) {
|
||||||
|
signingState.documentContentHtml = context.document.contentHtml ?? ''
|
||||||
|
signingState.contentSha256 = context.document.contentSha256 ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.staffAnswers) {
|
||||||
|
signingState.staffAnswers = { ...context.staffAnswers }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.fields) {
|
||||||
|
signingState.fields = [...context.fields]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.status) {
|
||||||
|
signingState.taskStatus = context.status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.channel) {
|
||||||
|
signingState.channel = context.channel
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.deliveryId) {
|
||||||
|
signingState.deliveryId = context.deliveryId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.expiresAt) {
|
||||||
|
sessionExpiresAt = context.expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
signingState.documentLoaded = Boolean(signingState.documentContentHtml)
|
||||||
|
signingState.documentError = signingState.documentLoaded
|
||||||
|
? ''
|
||||||
|
: '暂未读取到本次签署的文书正文,无法展示正式文书。'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSigningContext(token: string) {
|
||||||
|
try {
|
||||||
|
applyContext(await fetchSigningContext(token))
|
||||||
|
} catch {
|
||||||
|
// 接口未接入 / 任务无效:如实告知,**不放行签署** ——
|
||||||
|
// 让患者在没看过正式文书的情况下签名,比签不了更严重
|
||||||
|
signingState.documentLoaded = false
|
||||||
|
signingState.documentError = '暂时无法读取本次签署的文书内容,请联系现场医务人员。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 演示模式的上下文。
|
||||||
|
*
|
||||||
|
* 与真实模式走**同一条渲染路径**(`DocumentPaper` 读的是同样的字段),
|
||||||
|
* 只是数据是内置的 —— 演示与真实共用一套代码,才不会出现
|
||||||
|
* 「演示模式下好好的、真实模式一片空白」这种最难查的偏差。
|
||||||
|
*/
|
||||||
|
function applyDemoContext() {
|
||||||
|
signingState.documentTitle = 'CT 增强扫描知情同意书'
|
||||||
|
signingState.templateVersion = 'v1.0.0'
|
||||||
|
signingState.documentContentHtml = DEMO_DOCUMENT_HTML
|
||||||
|
signingState.staffAnswers = { ...DEMO_STAFF_ANSWERS }
|
||||||
|
signingState.fields = [
|
||||||
|
{ id: DEMO_PATIENT_FIELD_ID, label: '患者签名', kind: 'signature', required: true },
|
||||||
|
]
|
||||||
|
signingState.documentLoaded = true
|
||||||
|
signingState.documentError = ''
|
||||||
|
signingState.taskStatus = 'WAITING_SIGN'
|
||||||
|
signingState.signerName = signingState.signerName || signingState.patientName
|
||||||
|
signingState.relation = signingState.relation || '本人'
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 流程 ---------------- */
|
||||||
|
|
||||||
export async function initializeSignFlow(token?: string) {
|
export async function initializeSignFlow(token?: string) {
|
||||||
resetSignFlow()
|
resetSignFlow()
|
||||||
|
|
||||||
const candidate = token?.trim()
|
const candidate = token?.trim()
|
||||||
|
|
||||||
|
// 地址栏没有 token 时先尝试恢复本地会话(刷新 / 切后台回来都会走到这里)
|
||||||
|
if (!candidate && restoreSession()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
|
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
|
||||||
|
|
||||||
signingState.mode = shouldUseRealFlow ? 'real' : 'mock'
|
signingState.mode = shouldUseRealFlow ? 'real' : 'mock'
|
||||||
|
|
||||||
if (!shouldUseRealFlow) {
|
if (!shouldUseRealFlow) {
|
||||||
|
// 演示模式也要给一份带占位符的正文,否则「医生填了什么、患者该签哪里」根本验不了
|
||||||
|
applyDemoContext()
|
||||||
signingState.status = 'ready'
|
signingState.status = 'ready'
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -61,17 +342,17 @@ export async function initializeSignFlow(token?: string) {
|
|||||||
signingState.status = 'loading'
|
signingState.status = 'loading'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await consumeSigningToken({ token: candidate, channel: 'SMS' })
|
const session = await consumeSigningToken({ token: candidate })
|
||||||
|
|
||||||
signingState.taskId = session.taskId
|
signingState.taskId = session.taskId
|
||||||
signingState.taskNo = session.taskId
|
signingState.taskNo = session.taskId
|
||||||
signingState.deliveryId = session.deliveryId
|
signingState.deliveryId = session.deliveryId
|
||||||
signingState.uploadToken = session.uploadToken
|
signingState.uploadToken = session.uploadToken
|
||||||
signingState.channel = session.channel
|
signingState.channel = session.channel
|
||||||
signingState.documentTitle = '待签署知情同意文书'
|
|
||||||
signingState.patientName = '患者信息待确认'
|
|
||||||
signingState.visitNo = ''
|
|
||||||
signingState.status = 'ready'
|
signingState.status = 'ready'
|
||||||
|
|
||||||
|
await loadSigningContext(candidate)
|
||||||
|
persistSession()
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signingState.status = 'error'
|
signingState.status = 'error'
|
||||||
@@ -82,8 +363,6 @@ export async function initializeSignFlow(token?: string) {
|
|||||||
|
|
||||||
export async function submitRealSignature(input: {
|
export async function submitRealSignature(input: {
|
||||||
file: Blob
|
file: Blob
|
||||||
signerName: string
|
|
||||||
relation: NonNullable<SignFlowState['relation']>
|
|
||||||
consentAccepted: boolean
|
consentAccepted: boolean
|
||||||
}): Promise<SignatureUploadResponse> {
|
}): Promise<SignatureUploadResponse> {
|
||||||
if (signingState.mode !== 'real') {
|
if (signingState.mode !== 'real') {
|
||||||
@@ -109,23 +388,24 @@ export async function submitRealSignature(input: {
|
|||||||
uploadToken: signingState.uploadToken,
|
uploadToken: signingState.uploadToken,
|
||||||
file: input.file,
|
file: input.file,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
signerName: input.signerName,
|
// 签署人信息由医生在医护端填好,患者端原样带上,不再自己填
|
||||||
relation: input.relation,
|
signerName: signingState.signerName,
|
||||||
|
signerType: signingState.signerType,
|
||||||
|
relation: signingState.relation,
|
||||||
consentAccepted: input.consentAccepted,
|
consentAccepted: input.consentAccepted,
|
||||||
signedAt: new Date().toISOString(),
|
signedAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
signingState.signerName = input.signerName
|
|
||||||
signingState.relation = input.relation
|
|
||||||
signingState.consentAccepted = input.consentAccepted
|
|
||||||
signingState.signatureCaptured = true
|
signingState.signatureCaptured = true
|
||||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||||
signingState.backendStatus = result.status
|
signingState.backendStatus = result.status
|
||||||
signingState.pipelineId = result.pipelineId
|
signingState.pipelineId = result.pipelineId
|
||||||
signingState.uploadResult = result
|
signingState.uploadResult = result
|
||||||
|
// 凭证用掉了就清掉:既避免重复提交,也不在这台设备上留着
|
||||||
signingState.uploadToken = ''
|
signingState.uploadToken = ''
|
||||||
signingState.status = 'submitted'
|
signingState.status = 'submitted'
|
||||||
|
clearPersistedSession()
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signingState.status = 'ready'
|
signingState.status = 'ready'
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 演示模式(`VITE_USE_MOCK=true`)用的文书正文与「医生已填」结果。
|
||||||
|
*
|
||||||
|
* 为什么不能继续用页面上写死的假段落:患者端现在要渲染**结构化占位符**
|
||||||
|
* (`<span data-field data-kind data-label>`),写死的纯文字演示不出
|
||||||
|
* 「医生填了什么、患者该签哪里」这条链路,等于演示模式验不了真实交互。
|
||||||
|
*
|
||||||
|
* 占位符的属性名与 clinical-web 的 `utils/signing-fields.ts` 保持一致:
|
||||||
|
* `data-field`(字段 id)/ `data-kind`(text|date|choice|checkbox|signature)/ `data-label`。
|
||||||
|
* 那边改名要同步改这里,否则演示模式会渲染成一片没有值的标签。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 患者签名位的字段 id,与 clinical-web 的 PRIMARY_SIGNATURE_FIELD_ID 同口径 */
|
||||||
|
export const DEMO_PATIENT_FIELD_ID = 'signature'
|
||||||
|
|
||||||
|
export const DEMO_DOCUMENT_HTML = [
|
||||||
|
'<h2>CT 增强扫描知情同意书</h2>',
|
||||||
|
'<table><tbody>',
|
||||||
|
'<tr><td>患者姓名</td><td><span data-field="patient" data-kind="text" data-label="患者姓名" data-required="true">患者姓名</span></td>',
|
||||||
|
'<td>性别</td><td><span data-field="sex" data-kind="text" data-label="性别">性别</span></td></tr>',
|
||||||
|
'<tr><td>年龄</td><td><span data-field="age" data-kind="text" data-label="年龄">年龄</span></td>',
|
||||||
|
'<td>就诊号</td><td><span data-field="visit" data-kind="text" data-label="就诊号">就诊号</span></td></tr>',
|
||||||
|
'<tr><td>检查项目</td><td colspan="3"><span data-field="examItem" data-kind="choice" data-label="检查项目" data-options="胸部增强CT|头颅增强CT|腹部增强CT">检查项目</span></td></tr>',
|
||||||
|
'</tbody></table>',
|
||||||
|
'<h3>一、检查说明</h3>',
|
||||||
|
'<p>医生已向患者或监护人说明本次 CT 增强扫描的目的、操作过程、使用对比剂可能出现的风险及替代方案。患者可就相关内容向医务人员提出问题。</p>',
|
||||||
|
'<h3>二、对比剂使用</h3>',
|
||||||
|
'<p>本次检查需静脉注射碘对比剂,可能引起温热感、恶心、皮疹等不适,极少数情况下可能出现严重过敏反应。</p>',
|
||||||
|
'<p><span data-field="allergy" data-kind="checkbox" data-label="无碘对比剂过敏史" data-options="无碘对比剂过敏史">无碘对比剂过敏史</span> '
|
||||||
|
+ '<span data-field="renalOk" data-kind="checkbox" data-label="肾功能正常" data-options="肾功能正常">肾功能正常</span></p>',
|
||||||
|
'<h3>三、患者知情同意</h3>',
|
||||||
|
'<p>本人已充分理解上述内容,自愿接受本次 CT 增强扫描,并同意使用碘对比剂。</p>',
|
||||||
|
'<table><tbody>',
|
||||||
|
'<tr><td>患者(或监护人)签名</td>'
|
||||||
|
+ '<td><span data-field="signature" data-kind="signature" data-label="患者签名" data-required="true">患者签名</span></td>',
|
||||||
|
'<td>签署日期</td><td><span data-field="signedDate" data-kind="date" data-label="签署日期" data-required="true">签署日期</span></td></tr>',
|
||||||
|
'<tr><td>告知医师签名</td>'
|
||||||
|
+ '<td><span data-field="doctor" data-kind="signature" data-label="告知医师签名" data-required="true">告知医师签名</span></td>',
|
||||||
|
'<td>科室</td><td><span data-field="department" data-kind="text" data-label="科室">科室</span></td></tr>',
|
||||||
|
'</tbody></table>',
|
||||||
|
'<p>本文书采用电子方式签署,签名图片与文档原件一并存档。</p>',
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 演示用的「医生已填」结果。
|
||||||
|
*
|
||||||
|
* 刻意**不含** `signature`(患者签名)—— 那一格要留给患者当场签,
|
||||||
|
* 提前填上会让演示看不出「待患者签名」这个关键状态。
|
||||||
|
*/
|
||||||
|
export const DEMO_STAFF_ANSWERS: Record<string, string> = {
|
||||||
|
patient: '张*',
|
||||||
|
sex: '男',
|
||||||
|
age: '42 岁',
|
||||||
|
visit: 'MZ20260827018',
|
||||||
|
examItem: '胸部增强CT',
|
||||||
|
allergy: '1',
|
||||||
|
renalOk: '1',
|
||||||
|
signedDate: '2026-08-27',
|
||||||
|
department: '儿科急诊',
|
||||||
|
// 演示用的医师签名图(一段 SVG 手写线)。用 dataURL 而不是外链,
|
||||||
|
// 演示时不依赖任何网络资源 —— 离线打开也能看到签名位被正确渲染
|
||||||
|
doctor:
|
||||||
|
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40">'
|
||||||
|
+ '<path d="M8 30 Q26 6 44 24 T78 18 Q96 12 112 26" stroke="%23173b56" stroke-width="2" fill="none" stroke-linecap="round"/></svg>',
|
||||||
|
}
|
||||||
@@ -389,6 +389,22 @@ button {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 占位文案要压在画布正中间,所以画布外面必须有一层定位容器 */
|
||||||
|
.signature-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
color: #c9d8de;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.signature-canvas {
|
.signature-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SignDeliveryChannel, SignatureUploadResponse } from '@/api/types'
|
import type { SignDeliveryChannel, SignatureUploadResponse, SigningContextField } from '@/api/types'
|
||||||
|
|
||||||
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
||||||
|
|
||||||
@@ -10,17 +10,47 @@ export type SignFlowStatus =
|
|||||||
export interface SignFlowState {
|
export interface SignFlowState {
|
||||||
mode: SignFlowMode
|
mode: SignFlowMode
|
||||||
status: SignFlowStatus
|
status: SignFlowStatus
|
||||||
|
|
||||||
|
/** 后端返回的任务状态(`WAITING_SIGN` / `SIGNED` / `EXPIRED` / `VOIDED` / `FAILED`) */
|
||||||
|
taskStatus: string
|
||||||
|
|
||||||
patientName: string
|
patientName: string
|
||||||
|
sex: string
|
||||||
|
age: string
|
||||||
visitNo: string
|
visitNo: string
|
||||||
|
department: string
|
||||||
|
|
||||||
documentTitle: string
|
documentTitle: string
|
||||||
|
templateVersion: string
|
||||||
|
/**
|
||||||
|
* 文书正文 HTML(含占位符)。真实模式下由 `context` 接口返回。
|
||||||
|
*
|
||||||
|
* 与 `staffAnswers` 分开:正文是模板固化的那份,医生填的内容另存一份 ——
|
||||||
|
* 合成一份就查不出「谁在什么时候填了什么」。
|
||||||
|
*/
|
||||||
|
documentContentHtml: string
|
||||||
|
contentSha256: string
|
||||||
|
/** 医生已填的结果(含医师签名图),患者端**只读**渲染 */
|
||||||
|
staffAnswers: Record<string, string>
|
||||||
|
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||||
|
fields: SigningContextField[]
|
||||||
|
/** 有没有真的拿到正文 —— 没拿到就不该让患者签 */
|
||||||
|
documentLoaded: boolean
|
||||||
|
documentError: string
|
||||||
|
|
||||||
taskNo: string
|
taskNo: string
|
||||||
taskId: string
|
taskId: string
|
||||||
deliveryId: string
|
deliveryId: string
|
||||||
uploadToken: string
|
uploadToken: string
|
||||||
channel: SignDeliveryChannel | ''
|
channel: SignDeliveryChannel | ''
|
||||||
|
|
||||||
consentAccepted: boolean
|
consentAccepted: boolean
|
||||||
|
|
||||||
|
/** 签署人:按业务口径由医生在医护端填好,患者端只展示 */
|
||||||
|
signerType: string
|
||||||
signerName: string
|
signerName: string
|
||||||
relation: SignerRelation | ''
|
relation: SignerRelation | ''
|
||||||
|
|
||||||
signatureCaptured: boolean
|
signatureCaptured: boolean
|
||||||
submittedAt: string
|
submittedAt: string
|
||||||
backendStatus: string
|
backendStatus: string
|
||||||
@@ -28,11 +58,3 @@ export interface SignFlowState {
|
|||||||
uploadResult: SignatureUploadResponse | null
|
uploadResult: SignatureUploadResponse | null
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const relationOptions: Array<{ value: SignerRelation; label: string }> = [
|
|
||||||
{ value: '本人', label: '本人' },
|
|
||||||
{ value: '父亲', label: '父亲' },
|
|
||||||
{ value: '母亲', label: '母亲' },
|
|
||||||
{ value: '其他监护人', label: '其他监护人' },
|
|
||||||
{ value: '其他', label: '其他' },
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文书阅读页。
|
||||||
|
*
|
||||||
|
* 换成真实文书渲染(`DocumentPaper`):正文与「医生已填」结果都来自 context 接口。
|
||||||
|
* 原来那三段写死的假段落删掉了 —— 假文字既验不了渲染路径,也会让人误以为文书就长这样。
|
||||||
|
*
|
||||||
|
* 保留「阅读」与「签署」分成两步:手机上长文档与签名区分页更清晰,
|
||||||
|
* 且「已阅读」与「已签名」是两个独立的留痕动作。
|
||||||
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import DocumentPaper from '@/components/DocumentPaper.vue'
|
||||||
import { signingState } from '@/composables/useSignFlow'
|
import { signingState } from '@/composables/useSignFlow'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -35,25 +45,10 @@ function continueToSigner() {
|
|||||||
<p>请完整阅读以下内容。如有疑问,请联系为您说明情况的医生。</p>
|
<p>请完整阅读以下内容。如有疑问,请联系为您说明情况的医生。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<article class="document-card">
|
<DocumentPaper
|
||||||
<div class="document-meta">
|
:content-html="signingState.documentContentHtml"
|
||||||
<span>患者:{{ signingState.patientName }}</span>
|
:answers="signingState.staffAnswers"
|
||||||
<span>版本:V1.0</span>
|
/>
|
||||||
</div>
|
|
||||||
<h2>一、检查/治疗说明</h2>
|
|
||||||
<p>
|
|
||||||
医生已向患者或监护人说明拟实施的检查、治疗目的、操作过程、可能风险及替代方案。患者或监护人可以就相关内容向医务人员提出问题。
|
|
||||||
</p>
|
|
||||||
<h2>二、可能风险</h2>
|
|
||||||
<p>
|
|
||||||
具体风险因患者病情、检查方式和治疗方案而异。请在充分了解相关情况后,结合医生建议决定是否同意。
|
|
||||||
</p>
|
|
||||||
<h2>三、患者权利</h2>
|
|
||||||
<p>
|
|
||||||
患者或监护人有权选择同意或拒绝本次检查/治疗。拒绝可能影响后续诊疗安排,相关情况将按医院流程记录。
|
|
||||||
</p>
|
|
||||||
<div class="document-placeholder">正式文书 PDF 将在接入后端后展示</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
<input v-model="hasRead" type="checkbox" />
|
<input v-model="hasRead" type="checkbox" />
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 签署入口页。
|
||||||
|
*
|
||||||
|
* 三件事:
|
||||||
|
* ① 从地址栏取一次性 token 并**立即从地址栏清掉**(防进入历史记录 / Referrer 外泄);
|
||||||
|
* ② 消费 token 换投递凭证,再拉一次上下文(患者信息 + 文书 + 医生已填结果);
|
||||||
|
* ③ 按渠道与任务状态决定展示什么:正常入口 / 终态(已签、过期、作废、失败)。
|
||||||
|
*
|
||||||
|
* **拿不到文书正文时不放行签署** —— 让患者在没看过正式文书的情况下签名,
|
||||||
|
* 比签不了更严重。这是原有实现就定下的保守口径,继续保留。
|
||||||
|
*
|
||||||
|
* 刷新不丢链接:token 清掉后靠 `useSignFlow` 的 sessionStorage 会话恢复,
|
||||||
|
* 不在这里重新拼 token。
|
||||||
|
*/
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -8,8 +22,91 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
const isRealFlow = computed(() => signingState.mode === 'real')
|
/** 渠道 → 入口态说明。短信要核对身份,微信与手写板都属于「已经知道你是谁」的现场 */
|
||||||
const canStartSigning = computed(() => signingState.status === 'ready')
|
const CHANNEL_ENTRY: Record<string, { label: string; note: string }> = {
|
||||||
|
SMS: {
|
||||||
|
label: '短信链接进入',
|
||||||
|
note: '为保障签署安全,请核对以下由医院短信通知带出的信息。',
|
||||||
|
},
|
||||||
|
WX: {
|
||||||
|
label: '公众号进入',
|
||||||
|
note: '已从医院服务号进入本次签署,核对信息后即可阅读文书。',
|
||||||
|
},
|
||||||
|
PAD: {
|
||||||
|
label: '现场设备进入',
|
||||||
|
note: '本设备为现场签署设备,核对信息后即可阅读文书。',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelEntry = computed(() => CHANNEL_ENTRY[signingState.channel] ?? CHANNEL_ENTRY.SMS)
|
||||||
|
|
||||||
|
const patientMeta = computed(() =>
|
||||||
|
[
|
||||||
|
signingState.sex,
|
||||||
|
signingState.age,
|
||||||
|
signingState.visitNo ? `就诊号 ${signingState.visitNo}` : '',
|
||||||
|
signingState.department,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
)
|
||||||
|
|
||||||
|
const signerLine = computed(() => {
|
||||||
|
const name = signingState.signerName || signingState.patientName
|
||||||
|
const relation = signingState.relation || '本人'
|
||||||
|
|
||||||
|
return `${name}(${relation})`
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 任务终态:不是「待签署」就不该让患者继续走流程 */
|
||||||
|
const terminal = computed(() => {
|
||||||
|
switch (signingState.taskStatus) {
|
||||||
|
case 'SIGNED':
|
||||||
|
return {
|
||||||
|
eyebrow: '已完成',
|
||||||
|
title: '本次签署已完成',
|
||||||
|
copy: signingState.submittedAt
|
||||||
|
? `签署时间:${signingState.submittedAt}。无需重复签署。`
|
||||||
|
: '本次文书已完成签署,无需重复签署。',
|
||||||
|
}
|
||||||
|
case 'EXPIRED':
|
||||||
|
return {
|
||||||
|
eyebrow: '链接已过期',
|
||||||
|
title: '签署链接已过期',
|
||||||
|
copy: '请勿重复尝试。请联系现场医务人员重新发起签署。',
|
||||||
|
}
|
||||||
|
case 'VOIDED':
|
||||||
|
return {
|
||||||
|
eyebrow: '已作废',
|
||||||
|
title: '本次签署任务已作废',
|
||||||
|
copy: '该任务已被医护端作废,如有疑问请联系现场医务人员。',
|
||||||
|
}
|
||||||
|
case 'FAILED':
|
||||||
|
return {
|
||||||
|
eyebrow: '处理失败',
|
||||||
|
title: '签署处理失败',
|
||||||
|
copy: '系统未能完成本次签署处理,请联系现场医务人员。',
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasError = computed(
|
||||||
|
() => signingState.status === 'error' || signingState.status === 'blocked',
|
||||||
|
)
|
||||||
|
|
||||||
|
const canStartSigning = computed(
|
||||||
|
() => signingState.status === 'ready' && !terminal.value && signingState.documentLoaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
function startSigning() {
|
||||||
|
if (!canStartSigning.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push('/consent')
|
||||||
|
}
|
||||||
|
|
||||||
function getQueryValue(value: unknown) {
|
function getQueryValue(value: unknown) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -41,14 +138,6 @@ async function initialize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSigning() {
|
|
||||||
if (!canStartSigning.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void router.push('/consent')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void initialize()
|
void initialize()
|
||||||
})
|
})
|
||||||
@@ -70,43 +159,34 @@ onMounted(() => {
|
|||||||
<p>请稍候,系统正在确认本次签署凭证。</p>
|
<p>请稍候,系统正在确认本次签署凭证。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="signingState.status === 'error' || signingState.status === 'blocked'">
|
<section v-else-if="hasError" class="mobile-state-card mobile-state-card--error">
|
||||||
<section class="mobile-state-card mobile-state-card--error">
|
<p class="eyebrow">链接不可用</p>
|
||||||
<p class="eyebrow">链接不可用</p>
|
<h1>无法继续签署</h1>
|
||||||
<h1>无法继续签署</h1>
|
<p>{{ signingState.errorMessage }}</p>
|
||||||
<p>{{ signingState.errorMessage }}</p>
|
<p class="secure-note">请不要重复尝试或转发链接,如有疑问请联系现场医务人员。</p>
|
||||||
<p class="secure-note">请不要重复尝试或转发链接,如有疑问请联系现场医务人员。</p>
|
</section>
|
||||||
</section>
|
|
||||||
</template>
|
<section v-else-if="terminal" class="mobile-state-card">
|
||||||
|
<p class="eyebrow">{{ terminal.eyebrow }}</p>
|
||||||
|
<h1>{{ terminal.title }}</h1>
|
||||||
|
<p>{{ terminal.copy }}</p>
|
||||||
|
<p class="secure-note">如有疑问请联系现场医务人员。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="mobile-hero">
|
<section class="mobile-hero">
|
||||||
<p class="eyebrow">待签署文书</p>
|
<p class="eyebrow">待签署文书</p>
|
||||||
<h1>{{ signingState.documentTitle }}</h1>
|
<h1>{{ signingState.documentTitle }}</h1>
|
||||||
<p v-if="isRealFlow">
|
<p>{{ channelEntry.note }}</p>
|
||||||
签署链接已验证。请先核对现场展示的正式文书内容,再继续确认签署人信息。
|
|
||||||
</p>
|
|
||||||
<p v-else>请确认以下任务信息,并在阅读文书后完成签署。</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="isRealFlow" class="mobile-card real-session-card">
|
<section class="mobile-card patient-card">
|
||||||
<div class="card-label">签署凭证</div>
|
<div class="card-label">{{ channelEntry.label }}</div>
|
||||||
<div class="real-session-row">
|
|
||||||
<span class="real-session-icon">✓</span>
|
|
||||||
<div>
|
|
||||||
<strong>一次性签署链接已验证</strong>
|
|
||||||
<span>任务编号:{{ signingState.taskNo }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else class="mobile-card patient-card">
|
|
||||||
<div class="card-label">患者信息</div>
|
|
||||||
<div class="patient-row">
|
<div class="patient-row">
|
||||||
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ signingState.patientName }}</strong>
|
<strong>{{ signingState.patientName }}</strong>
|
||||||
<span>{{ signingState.visitNo }}</span>
|
<span>{{ patientMeta }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
||||||
@@ -116,118 +196,27 @@ onMounted(() => {
|
|||||||
<div class="notice-icon">i</div>
|
<div class="notice-icon">i</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>请使用实际签署人的信息</strong>
|
<strong>请使用实际签署人的信息</strong>
|
||||||
<p>如果由家属或监护人代签,请在下一步选择与患者的关系。</p>
|
<p>
|
||||||
|
本次签署人:{{ signerLine }}。文书内容已由医务人员填写完成,如与实际不符请联系现场医务人员。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="isRealFlow" class="mobile-card integration-note">
|
<section v-if="!signingState.documentLoaded" class="mobile-card integration-note">
|
||||||
<strong>正式文书内容暂未由公开接口返回</strong>
|
<strong>暂未读取到正式文书</strong>
|
||||||
<p>
|
<p>{{ signingState.documentError }}</p>
|
||||||
当前后端公开 Token
|
|
||||||
接口只返回一次性上传凭证,没有提供患者端读取任务文书的接口。为避免在未阅读正式内容的情况下签署,本版本不会提交真实签名。
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="mobile-primary-button"
|
class="mobile-primary-button"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="isRealFlow"
|
:disabled="!canStartSigning"
|
||||||
@click="startSigning"
|
@click="startSigning"
|
||||||
>
|
>
|
||||||
{{ isRealFlow ? '等待文书接口接入' : '开始阅读并签署' }}
|
{{ signingState.documentLoaded ? '开始阅读并签署' : '等待文书接口接入' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mobile-state-card,
|
|
||||||
.integration-note,
|
|
||||||
.real-session-card {
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #e0eceb;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgb(255 255 255 / 86%);
|
|
||||||
box-shadow: 0 12px 30px rgb(33 79 92 / 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-state-card {
|
|
||||||
margin-top: 20vh;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-state-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: #173b56;
|
|
||||||
font-size: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-state-card p:not(.eyebrow) {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
color: #788f9d;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-state-card--error {
|
|
||||||
border-color: #f0d8d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.real-session-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.real-session-icon {
|
|
||||||
display: grid;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
color: #1f776f;
|
|
||||||
font-weight: 800;
|
|
||||||
background: #dff3ee;
|
|
||||||
border-radius: 12px;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.real-session-row strong,
|
|
||||||
.real-session-row span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.real-session-row strong {
|
|
||||||
color: #31576c;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.real-session-row div span {
|
|
||||||
margin-top: 5px;
|
|
||||||
color: #8aa0aa;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.integration-note {
|
|
||||||
margin-top: 14px;
|
|
||||||
background: #fffaf2;
|
|
||||||
border-color: #f0dfc1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.integration-note strong {
|
|
||||||
color: #876126;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.integration-note p {
|
|
||||||
margin: 7px 0 0;
|
|
||||||
color: #927b58;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-primary-button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
import { clearPersistedSession, resetSignFlow, signingState } from '@/composables/useSignFlow'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -33,8 +33,18 @@ const statusLabel = computed(() => {
|
|||||||
return labels[signingState.backendStatus] ?? (signingState.backendStatus || '已提交')
|
return labels[signingState.backendStatus] ?? (signingState.backendStatus || '已提交')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 签署人由医生在医护端填好,这里只展示 —— 两边取同一份数据,不各算一套 */
|
||||||
|
const signerLine = computed(() => {
|
||||||
|
const name = signingState.signerName || signingState.patientName
|
||||||
|
const relation = signingState.relation || '本人'
|
||||||
|
|
||||||
|
return `${name}(${relation})`
|
||||||
|
})
|
||||||
|
|
||||||
function returnToEntry() {
|
function returnToEntry() {
|
||||||
resetSignFlow()
|
resetSignFlow()
|
||||||
|
// 连同本地会话一起清掉:本次已提交,不该再靠旧凭证回到签署页
|
||||||
|
clearPersistedSession()
|
||||||
void router.replace('/entry')
|
void router.replace('/entry')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -53,7 +63,7 @@ function returnToEntry() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>签署人</span>
|
<span>签署人</span>
|
||||||
<strong>{{ signingState.signerName }}({{ signingState.relation }})</strong>
|
<strong>{{ signerLine }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>提交时间</span>
|
<span>提交时间</span>
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
/**
|
||||||
|
* 签署页。
|
||||||
|
*
|
||||||
|
* 按业务口径,**患者只签自己的名**:文书里的选项、医师签名都由医生在医护端填好,
|
||||||
|
* 因此这一页去掉了原来的「签署人姓名 + 与患者关系」填写 —— 那两项现在由医生填,
|
||||||
|
* 这里只做只读展示。
|
||||||
|
*
|
||||||
|
* 签名仍走 Canvas(`SignatureCanvas`)。将来接真实手写板设备时,换的是采集层
|
||||||
|
* (设备适配器),这一页的交互不用动。
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import DocumentPaper from '@/components/DocumentPaper.vue'
|
||||||
import SignatureCanvas from '@/components/SignatureCanvas.vue'
|
import SignatureCanvas from '@/components/SignatureCanvas.vue'
|
||||||
import { signingState, submitRealSignature } from '@/composables/useSignFlow'
|
import { signingState, submitRealSignature } from '@/composables/useSignFlow'
|
||||||
import { relationOptions, type SignerRelation } from '@/types/signing'
|
|
||||||
|
|
||||||
interface SignatureCanvasExpose {
|
|
||||||
clearSignature: () => void
|
|
||||||
isEmpty: () => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const signerName = ref('')
|
|
||||||
const relation = ref<SignerRelation | ''>('')
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const signatureDataUrl = ref('')
|
const signatureDataUrl = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const signatureCanvas = ref<SignatureCanvasExpose | null>(null)
|
|
||||||
|
/** 签署人由医生在医护端填好,这里只展示 */
|
||||||
|
const signerLine = computed(() => {
|
||||||
|
const name = signingState.signerName || signingState.patientName
|
||||||
|
const relation = signingState.relation || '本人'
|
||||||
|
|
||||||
|
return `${name}(${relation})`
|
||||||
|
})
|
||||||
|
|
||||||
function dataUrlToBlob(dataUrl: string) {
|
function dataUrlToBlob(dataUrl: string) {
|
||||||
const [header, encoded] = dataUrl.split(',')
|
const [header, encoded] = dataUrl.split(',')
|
||||||
@@ -29,40 +39,18 @@ function dataUrlToBlob(dataUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitSigning() {
|
async function submitSigning() {
|
||||||
const trimmedName = signerName.value.trim()
|
if (!signatureDataUrl.value) {
|
||||||
if (!trimmedName) {
|
|
||||||
errorMessage.value = '请输入实际签署人姓名。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!relation.value) {
|
|
||||||
errorMessage.value = '请选择签署人与患者的关系。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (signatureCanvas.value?.isEmpty()) {
|
|
||||||
errorMessage.value = '请完成手写签名。'
|
errorMessage.value = '请完成手写签名。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRelation = relation.value
|
|
||||||
|
|
||||||
if (!selectedRelation) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signingState.mode === 'real') {
|
if (signingState.mode === 'real') {
|
||||||
if (!signatureDataUrl.value) {
|
|
||||||
errorMessage.value = '未读取到签名图片,请重新签名。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitRealSignature({
|
await submitRealSignature({
|
||||||
file: dataUrlToBlob(signatureDataUrl.value),
|
file: dataUrlToBlob(signatureDataUrl.value),
|
||||||
signerName: trimmedName,
|
|
||||||
relation: selectedRelation,
|
|
||||||
consentAccepted: signingState.consentAccepted,
|
consentAccepted: signingState.consentAccepted,
|
||||||
})
|
})
|
||||||
await router.push('/result')
|
await router.push('/result')
|
||||||
@@ -71,11 +59,10 @@ async function submitSigning() {
|
|||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signingState.signerName = trimmedName
|
|
||||||
signingState.relation = selectedRelation
|
|
||||||
signingState.signatureCaptured = true
|
signingState.signatureCaptured = true
|
||||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||||
void router.push('/result')
|
void router.push('/result')
|
||||||
@@ -93,25 +80,38 @@ async function submitSigning() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mobile-heading">
|
<section class="mobile-heading">
|
||||||
<p class="eyebrow">签署人确认</p>
|
<p class="eyebrow">签署确认</p>
|
||||||
<h1>请确认实际签署人</h1>
|
<h1>请在文书签名处签名</h1>
|
||||||
<p>签署人信息将与本次知情同意记录一并保存。</p>
|
<p>文书内容已由医务人员填写完成,您只需完成患者签名。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mobile-card signer-form">
|
<section class="mobile-card signer-summary">
|
||||||
<label class="field-label" for="signer-name">签署人姓名</label>
|
<div>
|
||||||
<input id="signer-name" v-model="signerName" class="mobile-input" placeholder="请输入姓名" />
|
<span>患者</span>
|
||||||
|
<strong>{{ signingState.patientName }}</strong>
|
||||||
<label class="field-label" for="signer-relation">与患者关系</label>
|
</div>
|
||||||
<select id="signer-relation" v-model="relation" class="mobile-input">
|
<div>
|
||||||
<option value="" disabled>请选择关系</option>
|
<span>签署人</span>
|
||||||
<option v-for="option in relationOptions" :key="option.value" :value="option.value">
|
<strong>{{ signerLine }}</strong>
|
||||||
{{ option.label }}
|
</div>
|
||||||
</option>
|
<div>
|
||||||
</select>
|
<span>文书</span>
|
||||||
|
<strong>{{ signingState.documentTitle }} · {{ signingState.templateVersion }}</strong>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SignatureCanvas ref="signatureCanvas" @update:data-url="signatureDataUrl = $event" />
|
<!-- 签完的字会实时回填到文书里的签名位,患者能立刻看到自己签在哪一格 -->
|
||||||
|
<DocumentPaper
|
||||||
|
:content-html="signingState.documentContentHtml"
|
||||||
|
:answers="signingState.staffAnswers"
|
||||||
|
:patient-signature="signatureDataUrl || null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SignatureCanvas
|
||||||
|
:height="200"
|
||||||
|
placeholder="请在此签名"
|
||||||
|
@update:data-url="signatureDataUrl = $event ?? ''"
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||||
<button
|
<button
|
||||||
@@ -125,3 +125,32 @@ async function submitSigning() {
|
|||||||
<button class="mobile-text-button" type="button" @click="router.back()">返回修改</button>
|
<button class="mobile-text-button" type="button" @click="router.back()">返回修改</button>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signer-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signer-summary div {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signer-summary span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #8aa0aa;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signer-summary strong {
|
||||||
|
color: #173b56;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user