feat(患者签署): 接入签署上下文接口并补齐文书渲染与手写签名链路
This commit is contained in:
@@ -100,7 +100,7 @@ defineExpose({ getDocumentElement })
|
||||
<template>
|
||||
<article class="sign-doc">
|
||||
<div class="sign-doc-banner">
|
||||
<span>已按任务固化的文书版本渲染(填写项已按患者信息预填)</span>
|
||||
<span></span>
|
||||
<span class="sign-doc-state" :class="{ 'is-void': task.status === 'void' }">
|
||||
{{ signatureStateLabel }}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ApiResponse,
|
||||
SignatureUploadInput,
|
||||
SignatureUploadResponse,
|
||||
SigningContextResponse,
|
||||
TokenConsumeRequest,
|
||||
TokenConsumeResponse,
|
||||
} from './types'
|
||||
@@ -19,6 +20,21 @@ export function consumeSigningToken(payload: TokenConsumeRequest): Promise<Token
|
||||
.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) {
|
||||
if (value) {
|
||||
return value
|
||||
|
||||
@@ -6,10 +6,20 @@ export interface ApiResponse<T> {
|
||||
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 {
|
||||
token: string
|
||||
/** 允许不传:渠道由 token 决定,前端不该替后端写死一个 */
|
||||
channel?: SignDeliveryChannel
|
||||
taskId?: string
|
||||
}
|
||||
@@ -23,6 +33,82 @@ export interface TokenConsumeResponse {
|
||||
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 =
|
||||
'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">
|
||||
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 hasSignature = ref(false)
|
||||
/** 有没有落过笔。用于占位文案与「签名为空」的前置校验 */
|
||||
const hasInk = ref(false)
|
||||
|
||||
let drawingContext: CanvasRenderingContext2D | null = null
|
||||
let context: CanvasRenderingContext2D | null = null
|
||||
let pixelRatio = 1
|
||||
let drawing = false
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function resizeCanvas() {
|
||||
/**
|
||||
* 按当前尺寸重建画布。
|
||||
*
|
||||
* 重建前先把已有笔迹拷到离屏画布,重建后原样画回去 —— 手机键盘弹出、
|
||||
* 横竖屏切换都会触发 resize,不这么做患者会看到刚签好的名字凭空消失。
|
||||
*/
|
||||
function setupCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1)
|
||||
canvas.width = Math.floor(rect.width * ratio)
|
||||
canvas.height = Math.floor(rect.height * ratio)
|
||||
const nextRatio = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const nextWidth = Math.max(Math.round(rect.width * nextRatio), 1)
|
||||
const nextHeight = Math.max(Math.round(props.height * nextRatio), 1)
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
// 尺寸没变就别动画布:重建一次就丢一次笔迹,而 resize 触发得非常频繁
|
||||
if (canvas.width === nextWidth && canvas.height === nextHeight && context) {
|
||||
return
|
||||
}
|
||||
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
context.lineCap = 'round'
|
||||
context.lineJoin = 'round'
|
||||
context.lineWidth = 2.4
|
||||
context.strokeStyle = '#173b56'
|
||||
drawingContext = context
|
||||
hasSignature.value = false
|
||||
let snapshot: HTMLCanvasElement | null = null
|
||||
|
||||
if (hasInk.value && canvas.width > 0 && canvas.height > 0) {
|
||||
snapshot = document.createElement('canvas')
|
||||
snapshot.width = canvas.width
|
||||
snapshot.height = canvas.height
|
||||
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) {
|
||||
const canvas = canvasRef.value
|
||||
|
||||
if (!canvas) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
@@ -47,66 +114,100 @@ function getPoint(event: PointerEvent) {
|
||||
|
||||
function startDrawing(event: PointerEvent) {
|
||||
const point = getPoint(event)
|
||||
if (!drawingContext || !point) {
|
||||
|
||||
if (!context || !point) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = canvasRef.value
|
||||
|
||||
// 指针捕获:手指划出画布边界后事件仍派发到这里,不必再挂 pointerleave,
|
||||
// 也就不会出现「划到边缘就断笔」
|
||||
canvas?.setPointerCapture(event.pointerId)
|
||||
drawing = true
|
||||
drawingContext.beginPath()
|
||||
drawingContext.moveTo(point.x, point.y)
|
||||
context.beginPath()
|
||||
context.moveTo(point.x, point.y)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function draw(event: PointerEvent) {
|
||||
if (!drawing || !drawingContext) {
|
||||
if (!drawing || !context) {
|
||||
return
|
||||
}
|
||||
|
||||
const point = getPoint(event)
|
||||
|
||||
if (!point) {
|
||||
return
|
||||
}
|
||||
|
||||
drawingContext.lineTo(point.x, point.y)
|
||||
drawingContext.stroke()
|
||||
hasSignature.value = true
|
||||
context.lineTo(point.x, point.y)
|
||||
context.stroke()
|
||||
hasInk.value = true
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function stopDrawing(event: PointerEvent) {
|
||||
if (!drawing) {
|
||||
return
|
||||
}
|
||||
|
||||
drawing = false
|
||||
|
||||
const canvas = canvasRef.value
|
||||
if (drawing && canvas?.hasPointerCapture(event.pointerId)) {
|
||||
|
||||
if (canvas?.hasPointerCapture(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() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || !drawingContext) {
|
||||
|
||||
if (!canvas || !context) {
|
||||
return
|
||||
}
|
||||
|
||||
drawingContext.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)
|
||||
hasSignature.value = false
|
||||
context.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)
|
||||
hasInk.value = false
|
||||
emit('update:dataUrl', null)
|
||||
}
|
||||
|
||||
function isEmpty() {
|
||||
return !hasSignature.value
|
||||
return !hasInk.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
defineExpose({ clearSignature, isEmpty, getDataUrl })
|
||||
|
||||
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(() => {
|
||||
window.removeEventListener('resize', resizeCanvas)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
defineExpose({ clearSignature, isEmpty })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -115,16 +216,19 @@ defineExpose({ clearSignature, isEmpty })
|
||||
<span>请在框内手写签名</span>
|
||||
<span class="signature-hint">签名需清晰完整</span>
|
||||
</div>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="signature-canvas"
|
||||
aria-label="手写签名区域"
|
||||
@pointerdown="startDrawing"
|
||||
@pointermove="draw"
|
||||
@pointerup="stopDrawing"
|
||||
@pointercancel="stopDrawing"
|
||||
@pointerleave="stopDrawing"
|
||||
/>
|
||||
<div class="signature-field">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="signature-canvas"
|
||||
:style="{ height: `${height}px` }"
|
||||
aria-label="手写签名区域"
|
||||
@pointerdown="startDrawing"
|
||||
@pointermove="draw"
|
||||
@pointerup="stopDrawing"
|
||||
@pointercancel="stopDrawing"
|
||||
/>
|
||||
<span v-if="!hasInk" class="signature-placeholder">{{ placeholder }}</span>
|
||||
</div>
|
||||
<button class="clear-button" type="button" @click="clearSignature">清除重签</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,65 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { consumeSigningToken, uploadSigningSignature } from '@/api'
|
||||
import type { SignatureUploadResponse } from '@/api/types'
|
||||
import type { SignFlowState } from '@/types/signing'
|
||||
import { consumeSigningToken, fetchSigningContext, uploadSigningSignature } from '@/api'
|
||||
import type {
|
||||
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 = {
|
||||
mode: 'mock',
|
||||
status: 'idle',
|
||||
taskStatus: '',
|
||||
patientName: '张*',
|
||||
sex: '',
|
||||
age: '',
|
||||
visitNo: 'MZ20260827018',
|
||||
department: '',
|
||||
documentTitle: '儿科急诊特殊检查知情同意书',
|
||||
templateVersion: 'v1.0.0',
|
||||
documentContentHtml: '',
|
||||
contentSha256: '',
|
||||
staffAnswers: {},
|
||||
fields: [],
|
||||
documentLoaded: false,
|
||||
documentError: '',
|
||||
taskNo: 'CT-20260827-001',
|
||||
taskId: '',
|
||||
deliveryId: '',
|
||||
uploadToken: '',
|
||||
channel: '',
|
||||
consentAccepted: false,
|
||||
signerType: '',
|
||||
signerName: '',
|
||||
relation: '',
|
||||
signatureCaptured: false,
|
||||
@@ -28,26 +72,263 @@ const initialState: SignFlowState = {
|
||||
|
||||
export const signingState = reactive<SignFlowState>({ ...initialState })
|
||||
|
||||
/** 最近一次拿到的上下文,只用于写本地缓存 */
|
||||
let lastContext: SigningContextResponse | null = null
|
||||
/** 会话有效期:consume 不返回,由 context 补上 */
|
||||
let sessionExpiresAt: string | null = null
|
||||
|
||||
export function resetSignFlow() {
|
||||
Object.assign(signingState, {
|
||||
...initialState,
|
||||
staffAnswers: {},
|
||||
fields: [],
|
||||
uploadResult: null,
|
||||
})
|
||||
lastContext = null
|
||||
sessionExpiresAt = null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
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) {
|
||||
resetSignFlow()
|
||||
|
||||
const candidate = token?.trim()
|
||||
|
||||
// 地址栏没有 token 时先尝试恢复本地会话(刷新 / 切后台回来都会走到这里)
|
||||
if (!candidate && restoreSession()) {
|
||||
return true
|
||||
}
|
||||
|
||||
const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false'
|
||||
|
||||
signingState.mode = shouldUseRealFlow ? 'real' : 'mock'
|
||||
|
||||
if (!shouldUseRealFlow) {
|
||||
// 演示模式也要给一份带占位符的正文,否则「医生填了什么、患者该签哪里」根本验不了
|
||||
applyDemoContext()
|
||||
signingState.status = 'ready'
|
||||
return true
|
||||
}
|
||||
@@ -61,17 +342,17 @@ export async function initializeSignFlow(token?: string) {
|
||||
signingState.status = 'loading'
|
||||
|
||||
try {
|
||||
const session = await consumeSigningToken({ token: candidate, channel: 'SMS' })
|
||||
const session = await consumeSigningToken({ token: candidate })
|
||||
|
||||
signingState.taskId = session.taskId
|
||||
signingState.taskNo = session.taskId
|
||||
signingState.deliveryId = session.deliveryId
|
||||
signingState.uploadToken = session.uploadToken
|
||||
signingState.channel = session.channel
|
||||
signingState.documentTitle = '待签署知情同意文书'
|
||||
signingState.patientName = '患者信息待确认'
|
||||
signingState.visitNo = ''
|
||||
signingState.status = 'ready'
|
||||
|
||||
await loadSigningContext(candidate)
|
||||
persistSession()
|
||||
return true
|
||||
} catch (error) {
|
||||
signingState.status = 'error'
|
||||
@@ -82,8 +363,6 @@ export async function initializeSignFlow(token?: string) {
|
||||
|
||||
export async function submitRealSignature(input: {
|
||||
file: Blob
|
||||
signerName: string
|
||||
relation: NonNullable<SignFlowState['relation']>
|
||||
consentAccepted: boolean
|
||||
}): Promise<SignatureUploadResponse> {
|
||||
if (signingState.mode !== 'real') {
|
||||
@@ -109,23 +388,24 @@ export async function submitRealSignature(input: {
|
||||
uploadToken: signingState.uploadToken,
|
||||
file: input.file,
|
||||
metadata: JSON.stringify({
|
||||
signerName: input.signerName,
|
||||
relation: input.relation,
|
||||
// 签署人信息由医生在医护端填好,患者端原样带上,不再自己填
|
||||
signerName: signingState.signerName,
|
||||
signerType: signingState.signerType,
|
||||
relation: signingState.relation,
|
||||
consentAccepted: input.consentAccepted,
|
||||
signedAt: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
|
||||
signingState.signerName = input.signerName
|
||||
signingState.relation = input.relation
|
||||
signingState.consentAccepted = input.consentAccepted
|
||||
signingState.signatureCaptured = true
|
||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||
signingState.backendStatus = result.status
|
||||
signingState.pipelineId = result.pipelineId
|
||||
signingState.uploadResult = result
|
||||
// 凭证用掉了就清掉:既避免重复提交,也不在这台设备上留着
|
||||
signingState.uploadToken = ''
|
||||
signingState.status = 'submitted'
|
||||
clearPersistedSession()
|
||||
return result
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 占位文案要压在画布正中间,所以画布外面必须有一层定位容器 */
|
||||
.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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SignDeliveryChannel, SignatureUploadResponse } from '@/api/types'
|
||||
import type { SignDeliveryChannel, SignatureUploadResponse, SigningContextField } from '@/api/types'
|
||||
|
||||
export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他'
|
||||
|
||||
@@ -10,17 +10,47 @@ export type SignFlowStatus =
|
||||
export interface SignFlowState {
|
||||
mode: SignFlowMode
|
||||
status: SignFlowStatus
|
||||
|
||||
/** 后端返回的任务状态(`WAITING_SIGN` / `SIGNED` / `EXPIRED` / `VOIDED` / `FAILED`) */
|
||||
taskStatus: string
|
||||
|
||||
patientName: string
|
||||
sex: string
|
||||
age: string
|
||||
visitNo: string
|
||||
department: 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
|
||||
taskId: string
|
||||
deliveryId: string
|
||||
uploadToken: string
|
||||
channel: SignDeliveryChannel | ''
|
||||
|
||||
consentAccepted: boolean
|
||||
|
||||
/** 签署人:按业务口径由医生在医护端填好,患者端只展示 */
|
||||
signerType: string
|
||||
signerName: string
|
||||
relation: SignerRelation | ''
|
||||
|
||||
signatureCaptured: boolean
|
||||
submittedAt: string
|
||||
backendStatus: string
|
||||
@@ -28,11 +58,3 @@ export interface SignFlowState {
|
||||
uploadResult: SignatureUploadResponse | null
|
||||
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">
|
||||
/**
|
||||
* 文书阅读页。
|
||||
*
|
||||
* 换成真实文书渲染(`DocumentPaper`):正文与「医生已填」结果都来自 context 接口。
|
||||
* 原来那三段写死的假段落删掉了 —— 假文字既验不了渲染路径,也会让人误以为文书就长这样。
|
||||
*
|
||||
* 保留「阅读」与「签署」分成两步:手机上长文档与签名区分页更清晰,
|
||||
* 且「已阅读」与「已签名」是两个独立的留痕动作。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DocumentPaper from '@/components/DocumentPaper.vue'
|
||||
import { signingState } from '@/composables/useSignFlow'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -35,25 +45,10 @@ function continueToSigner() {
|
||||
<p>请完整阅读以下内容。如有疑问,请联系为您说明情况的医生。</p>
|
||||
</section>
|
||||
|
||||
<article class="document-card">
|
||||
<div class="document-meta">
|
||||
<span>患者:{{ signingState.patientName }}</span>
|
||||
<span>版本:V1.0</span>
|
||||
</div>
|
||||
<h2>一、检查/治疗说明</h2>
|
||||
<p>
|
||||
医生已向患者或监护人说明拟实施的检查、治疗目的、操作过程、可能风险及替代方案。患者或监护人可以就相关内容向医务人员提出问题。
|
||||
</p>
|
||||
<h2>二、可能风险</h2>
|
||||
<p>
|
||||
具体风险因患者病情、检查方式和治疗方案而异。请在充分了解相关情况后,结合医生建议决定是否同意。
|
||||
</p>
|
||||
<h2>三、患者权利</h2>
|
||||
<p>
|
||||
患者或监护人有权选择同意或拒绝本次检查/治疗。拒绝可能影响后续诊疗安排,相关情况将按医院流程记录。
|
||||
</p>
|
||||
<div class="document-placeholder">正式文书 PDF 将在接入后端后展示</div>
|
||||
</article>
|
||||
<DocumentPaper
|
||||
:content-html="signingState.documentContentHtml"
|
||||
:answers="signingState.staffAnswers"
|
||||
/>
|
||||
|
||||
<label class="check-row">
|
||||
<input v-model="hasRead" type="checkbox" />
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 签署入口页。
|
||||
*
|
||||
* 三件事:
|
||||
* ① 从地址栏取一次性 token 并**立即从地址栏清掉**(防进入历史记录 / Referrer 外泄);
|
||||
* ② 消费 token 换投递凭证,再拉一次上下文(患者信息 + 文书 + 医生已填结果);
|
||||
* ③ 按渠道与任务状态决定展示什么:正常入口 / 终态(已签、过期、作废、失败)。
|
||||
*
|
||||
* **拿不到文书正文时不放行签署** —— 让患者在没看过正式文书的情况下签名,
|
||||
* 比签不了更严重。这是原有实现就定下的保守口径,继续保留。
|
||||
*
|
||||
* 刷新不丢链接:token 清掉后靠 `useSignFlow` 的 sessionStorage 会话恢复,
|
||||
* 不在这里重新拼 token。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -8,8 +22,91 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
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) {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -41,14 +138,6 @@ async function initialize() {
|
||||
}
|
||||
}
|
||||
|
||||
function startSigning() {
|
||||
if (!canStartSigning.value) {
|
||||
return
|
||||
}
|
||||
|
||||
void router.push('/consent')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initialize()
|
||||
})
|
||||
@@ -70,43 +159,34 @@ onMounted(() => {
|
||||
<p>请稍候,系统正在确认本次签署凭证。</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="signingState.status === 'error' || signingState.status === 'blocked'">
|
||||
<section class="mobile-state-card mobile-state-card--error">
|
||||
<p class="eyebrow">链接不可用</p>
|
||||
<h1>无法继续签署</h1>
|
||||
<p>{{ signingState.errorMessage }}</p>
|
||||
<p class="secure-note">请不要重复尝试或转发链接,如有疑问请联系现场医务人员。</p>
|
||||
</section>
|
||||
</template>
|
||||
<section v-else-if="hasError" class="mobile-state-card mobile-state-card--error">
|
||||
<p class="eyebrow">链接不可用</p>
|
||||
<h1>无法继续签署</h1>
|
||||
<p>{{ signingState.errorMessage }}</p>
|
||||
<p class="secure-note">请不要重复尝试或转发链接,如有疑问请联系现场医务人员。</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<section class="mobile-hero">
|
||||
<p class="eyebrow">待签署文书</p>
|
||||
<h1>{{ signingState.documentTitle }}</h1>
|
||||
<p v-if="isRealFlow">
|
||||
签署链接已验证。请先核对现场展示的正式文书内容,再继续确认签署人信息。
|
||||
</p>
|
||||
<p v-else>请确认以下任务信息,并在阅读文书后完成签署。</p>
|
||||
<p>{{ channelEntry.note }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="isRealFlow" class="mobile-card real-session-card">
|
||||
<div class="card-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>
|
||||
<section class="mobile-card patient-card">
|
||||
<div class="card-label">{{ channelEntry.label }}</div>
|
||||
<div class="patient-row">
|
||||
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
||||
<div>
|
||||
<strong>{{ signingState.patientName }}</strong>
|
||||
<span>{{ signingState.visitNo }}</span>
|
||||
<span>{{ patientMeta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
||||
@@ -116,118 +196,27 @@ onMounted(() => {
|
||||
<div class="notice-icon">i</div>
|
||||
<div>
|
||||
<strong>请使用实际签署人的信息</strong>
|
||||
<p>如果由家属或监护人代签,请在下一步选择与患者的关系。</p>
|
||||
<p>
|
||||
本次签署人:{{ signerLine }}。文书内容已由医务人员填写完成,如与实际不符请联系现场医务人员。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="isRealFlow" class="mobile-card integration-note">
|
||||
<strong>正式文书内容暂未由公开接口返回</strong>
|
||||
<p>
|
||||
当前后端公开 Token
|
||||
接口只返回一次性上传凭证,没有提供患者端读取任务文书的接口。为避免在未阅读正式内容的情况下签署,本版本不会提交真实签名。
|
||||
</p>
|
||||
<section v-if="!signingState.documentLoaded" class="mobile-card integration-note">
|
||||
<strong>暂未读取到正式文书</strong>
|
||||
<p>{{ signingState.documentError }}</p>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="mobile-primary-button"
|
||||
type="button"
|
||||
:disabled="isRealFlow"
|
||||
:disabled="!canStartSigning"
|
||||
@click="startSigning"
|
||||
>
|
||||
{{ isRealFlow ? '等待文书接口接入' : '开始阅读并签署' }}
|
||||
{{ signingState.documentLoaded ? '开始阅读并签署' : '等待文书接口接入' }}
|
||||
</button>
|
||||
|
||||
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
||||
</template>
|
||||
</main>
|
||||
</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 { useRouter } from 'vue-router'
|
||||
|
||||
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
||||
import { clearPersistedSession, resetSignFlow, signingState } from '@/composables/useSignFlow'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -33,8 +33,18 @@ const statusLabel = computed(() => {
|
||||
return labels[signingState.backendStatus] ?? (signingState.backendStatus || '已提交')
|
||||
})
|
||||
|
||||
/** 签署人由医生在医护端填好,这里只展示 —— 两边取同一份数据,不各算一套 */
|
||||
const signerLine = computed(() => {
|
||||
const name = signingState.signerName || signingState.patientName
|
||||
const relation = signingState.relation || '本人'
|
||||
|
||||
return `${name}(${relation})`
|
||||
})
|
||||
|
||||
function returnToEntry() {
|
||||
resetSignFlow()
|
||||
// 连同本地会话一起清掉:本次已提交,不该再靠旧凭证回到签署页
|
||||
clearPersistedSession()
|
||||
void router.replace('/entry')
|
||||
}
|
||||
</script>
|
||||
@@ -53,7 +63,7 @@ function returnToEntry() {
|
||||
</div>
|
||||
<div>
|
||||
<span>签署人</span>
|
||||
<strong>{{ signingState.signerName }}({{ signingState.relation }})</strong>
|
||||
<strong>{{ signerLine }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>提交时间</span>
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
/**
|
||||
* 签署页。
|
||||
*
|
||||
* 按业务口径,**患者只签自己的名**:文书里的选项、医师签名都由医生在医护端填好,
|
||||
* 因此这一页去掉了原来的「签署人姓名 + 与患者关系」填写 —— 那两项现在由医生填,
|
||||
* 这里只做只读展示。
|
||||
*
|
||||
* 签名仍走 Canvas(`SignatureCanvas`)。将来接真实手写板设备时,换的是采集层
|
||||
* (设备适配器),这一页的交互不用动。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DocumentPaper from '@/components/DocumentPaper.vue'
|
||||
import SignatureCanvas from '@/components/SignatureCanvas.vue'
|
||||
import { signingState, submitRealSignature } from '@/composables/useSignFlow'
|
||||
import { relationOptions, type SignerRelation } from '@/types/signing'
|
||||
|
||||
interface SignatureCanvasExpose {
|
||||
clearSignature: () => void
|
||||
isEmpty: () => boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const signerName = ref('')
|
||||
const relation = ref<SignerRelation | ''>('')
|
||||
const errorMessage = ref('')
|
||||
const signatureDataUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
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) {
|
||||
const [header, encoded] = dataUrl.split(',')
|
||||
@@ -29,40 +39,18 @@ function dataUrlToBlob(dataUrl: string) {
|
||||
}
|
||||
|
||||
async function submitSigning() {
|
||||
const trimmedName = signerName.value.trim()
|
||||
if (!trimmedName) {
|
||||
errorMessage.value = '请输入实际签署人姓名。'
|
||||
return
|
||||
}
|
||||
if (!relation.value) {
|
||||
errorMessage.value = '请选择签署人与患者的关系。'
|
||||
return
|
||||
}
|
||||
if (signatureCanvas.value?.isEmpty()) {
|
||||
if (!signatureDataUrl.value) {
|
||||
errorMessage.value = '请完成手写签名。'
|
||||
return
|
||||
}
|
||||
|
||||
const selectedRelation = relation.value
|
||||
|
||||
if (!selectedRelation) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signingState.mode === 'real') {
|
||||
if (!signatureDataUrl.value) {
|
||||
errorMessage.value = '未读取到签名图片,请重新签名。'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await submitRealSignature({
|
||||
file: dataUrlToBlob(signatureDataUrl.value),
|
||||
signerName: trimmedName,
|
||||
relation: selectedRelation,
|
||||
consentAccepted: signingState.consentAccepted,
|
||||
})
|
||||
await router.push('/result')
|
||||
@@ -71,11 +59,10 @@ async function submitSigning() {
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
signingState.signerName = trimmedName
|
||||
signingState.relation = selectedRelation
|
||||
signingState.signatureCaptured = true
|
||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||
void router.push('/result')
|
||||
@@ -93,25 +80,38 @@ async function submitSigning() {
|
||||
</div>
|
||||
|
||||
<section class="mobile-heading">
|
||||
<p class="eyebrow">签署人确认</p>
|
||||
<h1>请确认实际签署人</h1>
|
||||
<p>签署人信息将与本次知情同意记录一并保存。</p>
|
||||
<p class="eyebrow">签署确认</p>
|
||||
<h1>请在文书签名处签名</h1>
|
||||
<p>文书内容已由医务人员填写完成,您只需完成患者签名。</p>
|
||||
</section>
|
||||
|
||||
<section class="mobile-card signer-form">
|
||||
<label class="field-label" for="signer-name">签署人姓名</label>
|
||||
<input id="signer-name" v-model="signerName" class="mobile-input" placeholder="请输入姓名" />
|
||||
|
||||
<label class="field-label" for="signer-relation">与患者关系</label>
|
||||
<select id="signer-relation" v-model="relation" class="mobile-input">
|
||||
<option value="" disabled>请选择关系</option>
|
||||
<option v-for="option in relationOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<section class="mobile-card signer-summary">
|
||||
<div>
|
||||
<span>患者</span>
|
||||
<strong>{{ signingState.patientName }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>签署人</span>
|
||||
<strong>{{ signerLine }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>文书</span>
|
||||
<strong>{{ signingState.documentTitle }} · {{ signingState.templateVersion }}</strong>
|
||||
</div>
|
||||
</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>
|
||||
<button
|
||||
@@ -125,3 +125,32 @@ async function submitSigning() {
|
||||
<button class="mobile-text-button" type="button" @click="router.back()">返回修改</button>
|
||||
</main>
|
||||
</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