feat(签署记录): 新增签署记录查询页并复用签署任务接口
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
|
||||
import {
|
||||
applyAnswerToFieldHost,
|
||||
applySelectedItemsToRoot,
|
||||
applySignatureToRoot,
|
||||
buildSigningDocumentHtml,
|
||||
collectSelectedItemsFromRoot,
|
||||
describeFieldProgress,
|
||||
OPTION_TICK_SELECTOR,
|
||||
readAnswersFromRoot,
|
||||
SIGNATURE_ACTION_SELECTOR,
|
||||
toggleOptionAt,
|
||||
type SigningDocumentData,
|
||||
type SigningFieldProgress,
|
||||
type SigningSignatureRequest,
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
|
||||
// utils/signing-document 与 utils/signing-fields,与文书库的「签署预览」共用同一实现。
|
||||
// 本组件只负责挂载 HTML、收集填写项取值并渲染签署态外壳。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
task: SigningTaskRecord
|
||||
interactive?: boolean
|
||||
/**
|
||||
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
|
||||
*
|
||||
* 不走 task:task 是服务端快照,这里装的是"本次会话刚采到、还没提交"的取值。
|
||||
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
|
||||
*/
|
||||
signatureInjections?: SigningAnswers
|
||||
}>(),
|
||||
{ interactive: false, signatureInjections: () => ({}) },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'selection-change': [items: string[]]
|
||||
'field-change': [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||
/** 请求采集某个签名位,由签署页打开签字板并把结果回灌到 signatureInjections */
|
||||
'signature-request': [request: SigningSignatureRequest]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const selectedItems = ref<string[]>([])
|
||||
const fieldAnswers = ref<SigningAnswers>({})
|
||||
const progress = ref<SigningFieldProgress>({
|
||||
total: 0,
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
pendingSignatures: [],
|
||||
})
|
||||
|
||||
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
|
||||
// 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
|
||||
const documentData = computed<SigningDocumentData>(() => ({
|
||||
patientName: props.task.patientName,
|
||||
sex: props.task.sex,
|
||||
age: props.task.age,
|
||||
visitNo: props.task.visitNo,
|
||||
visitDate: props.task.visitDate,
|
||||
signedAt: props.task.signedAt,
|
||||
signatureDataUrl: props.task.signatureDataUrl,
|
||||
selectedItems: props.task.selectedItems,
|
||||
fieldAnswers: props.task.fieldAnswers,
|
||||
}))
|
||||
|
||||
const processedHtml = computed(() =>
|
||||
buildSigningDocumentHtml(
|
||||
props.task.documentContentHtml ?? '',
|
||||
documentData.value,
|
||||
props.interactive,
|
||||
// 本组件就是签名位的点击宿主(见 handleDocumentClick),
|
||||
// 所以可采集与否只取决于文书当前是否可交互
|
||||
{ signatureActionable: props.interactive },
|
||||
),
|
||||
)
|
||||
|
||||
/** 文书里的填写项数量(含只读展示),用于提示"这份文书有几个填写项" */
|
||||
const fieldTotal = computed(() => progress.value.total)
|
||||
|
||||
/** 签名/状态文案:作废要单独说清楚,不能被当成"待患者签署" */
|
||||
const signatureStateLabel = computed(() => {
|
||||
if (props.task.status === 'void') {
|
||||
return '已作废'
|
||||
}
|
||||
|
||||
if (props.task.signatureDataUrl) {
|
||||
return '已采集签名'
|
||||
}
|
||||
|
||||
return props.task.status === 'signed' ? '已签署' : '待患者签署'
|
||||
})
|
||||
|
||||
/**
|
||||
* 从渲染后的 DOM 读回填写项取值与勾选清单。
|
||||
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
|
||||
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
|
||||
*/
|
||||
function syncFromDom() {
|
||||
const container = containerRef.value
|
||||
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const answers = readAnswersFromRoot(container)
|
||||
fieldAnswers.value = answers
|
||||
progress.value = describeFieldProgress(container, answers)
|
||||
selectedItems.value = collectSelectedItemsFromRoot(container)
|
||||
|
||||
emit('field-change', { ...answers }, { ...progress.value })
|
||||
emit('selection-change', [...selectedItems.value])
|
||||
}
|
||||
|
||||
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 新填写项都覆盖) */
|
||||
function applyStoredValues() {
|
||||
const container = containerRef.value
|
||||
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
applySelectedItemsToRoot(container, props.task.selectedItems ?? [])
|
||||
|
||||
const answers: SigningAnswers = { ...(props.task.fieldAnswers ?? {}) }
|
||||
|
||||
if (props.task.signatureDataUrl && !answers.signature) {
|
||||
answers.signature = props.task.signatureDataUrl
|
||||
}
|
||||
|
||||
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
|
||||
const id = host.getAttribute('data-sign-field')
|
||||
const value = id ? answers[id] : undefined
|
||||
|
||||
if (id === undefined || value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// 读写的判据都在 signing-document 里(readAnswersFromRoot 的镜像),
|
||||
// 这里不自己写 data-sign-* 选择器,否则选项组这类"同 DOM 不同语义"的控件会两处漂移
|
||||
applyAnswerToFieldHost(host, value)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.task,
|
||||
() => {
|
||||
selectedItems.value = [...(props.task.selectedItems ?? [])]
|
||||
fieldAnswers.value = { ...(props.task.fieldAnswers ?? {}) }
|
||||
emit('selection-change', [...selectedItems.value])
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 从事件目标往上找「可点击采集的签名位」。
|
||||
* 用 closest 而不是直接比对 target:签名位胶囊里还有类型标签、虚线框等子节点,
|
||||
* 点在这些子节点上时 target 并不是宿主节点本身。
|
||||
*/
|
||||
function resolveSignatureHost(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Element)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = target.closest<HTMLElement>(SIGNATURE_ACTION_SELECTOR)
|
||||
|
||||
// closest 会一路向上穿过容器,事件也可能来自容器外,必须确认落在本文书里
|
||||
return host && containerRef.value?.contains(host) ? host : null
|
||||
}
|
||||
|
||||
function requestSignature(host: HTMLElement) {
|
||||
const fieldId = host.getAttribute('data-sign-field') ?? ''
|
||||
|
||||
if (!fieldId) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('signature-request', {
|
||||
fieldId,
|
||||
label: host.getAttribute('data-sign-label') ?? fieldId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击文书里的 ☑ / ☐ 选项。
|
||||
*
|
||||
* 与签名位分开处理:签名位是"请求采集"(要弹签字板),选项是就地切换。
|
||||
* 切换逻辑本身在 utils/signing-document 的 toggleOptionAt 里,
|
||||
* 组件只负责把点击翻译成一次调用 —— 单选互斥的判据不该在组件里再推一遍。
|
||||
*/
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
|
||||
if (target instanceof Element) {
|
||||
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
|
||||
const container = containerRef.value
|
||||
|
||||
if (option && container && toggleOptionAt(container, option)) {
|
||||
syncFromDom()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const host = resolveSignatureHost(event.target)
|
||||
|
||||
if (host) {
|
||||
requestSignature(host)
|
||||
}
|
||||
}
|
||||
|
||||
/** 签名位挂了 role=button 与 tabindex,键盘必须同样可用,否则只有鼠标点得动 */
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (!props.interactive || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||
return
|
||||
}
|
||||
|
||||
const host = resolveSignatureHost(event.target)
|
||||
|
||||
if (host) {
|
||||
// 空格默认会滚动容器,必须挡掉
|
||||
event.preventDefault()
|
||||
requestSignature(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把外部采集到的签名图就地写进签名位,写完立刻回读 DOM 上报取值。
|
||||
*
|
||||
* 不重算 processedHtml:整篇重建会换掉 v-html 的 DOM,
|
||||
* 操作人员正在输入的填写项会连同光标一起丢。
|
||||
*/
|
||||
function patchSignatures() {
|
||||
const container = containerRef.value
|
||||
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const patched = Object.entries(props.signatureInjections).some(
|
||||
([fieldId, value]) =>
|
||||
typeof value === 'string' && applySignatureToRoot(container, fieldId, value),
|
||||
)
|
||||
|
||||
if (patched) {
|
||||
syncFromDom()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
processedHtml,
|
||||
() => {
|
||||
applyStoredValues()
|
||||
// 重渲染会丢掉就地写入的签名图,得重新贴一遍
|
||||
patchSignatures()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(() => props.signatureInjections, patchSignatures, { deep: true, flush: 'post' })
|
||||
|
||||
onMounted(() => {
|
||||
applyStoredValues()
|
||||
patchSignatures()
|
||||
syncFromDom()
|
||||
})
|
||||
|
||||
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
||||
function getDocumentElement() {
|
||||
return containerRef.value
|
||||
}
|
||||
|
||||
defineExpose({ getDocumentElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="sign-doc">
|
||||
<div class="sign-doc-banner">
|
||||
<span>已按任务固化的文书版本渲染(填写项已按患者信息预填)</span>
|
||||
<span class="sign-doc-state" :class="{ 'is-void': task.status === 'void' }">
|
||||
{{ signatureStateLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="sign-doc-body"
|
||||
@change="syncFromDom"
|
||||
@input="syncFromDom"
|
||||
@click="handleDocumentClick"
|
||||
@keydown="handleDocumentKeydown"
|
||||
v-html="processedHtml"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
|
||||
<footer v-if="interactive" class="sign-doc-footer">
|
||||
<span>
|
||||
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填
|
||||
<template v-if="progress.abnormal.length">
|
||||
・异常项:<strong class="is-abnormal">{{ progress.abnormal.join('、') }}</strong>
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="progress.missingRequired.length" class="is-missing">
|
||||
必填未完成:{{ progress.missingRequired.join('、') }},请补充后再采集签名。
|
||||
</span>
|
||||
<span v-else-if="progress.pendingSignatures.length" class="is-pending">
|
||||
还有签名位待采集:<strong>{{ progress.pendingSignatures.join('、') }}</strong
|
||||
>,可直接点击文中签名位完成。
|
||||
</span>
|
||||
<span v-else
|
||||
>完成填写后通过「手写板签署 / 线上签署」采集签名,填写项与勾选结果随签署证据一并提交。</span
|
||||
>
|
||||
</footer>
|
||||
<footer v-else-if="fieldTotal" class="sign-doc-footer">
|
||||
<span>
|
||||
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填,取值已随签署固化。
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sign-doc {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 480px;
|
||||
background: #fff;
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 作废章压在文书上,位置与存量回退纸样(SigningDocumentPreview)保持一致 */
|
||||
.void-stamp {
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
right: 32px;
|
||||
padding: 4px 12px;
|
||||
color: rgb(208 83 79 / 70%);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
pointer-events: none;
|
||||
border: 2.5px solid rgb(208 83 79 / 55%);
|
||||
border-radius: 8px;
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
|
||||
.sign-doc-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 16px;
|
||||
color: var(--brand);
|
||||
font-size: 11.5px;
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.sign-doc-state {
|
||||
padding: 2px 9px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: var(--brand);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sign-doc-state.is-void {
|
||||
background: #8a9aa3;
|
||||
}
|
||||
|
||||
.sign-doc-body {
|
||||
flex: 1;
|
||||
padding: 26px 32px 30px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sign-doc-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--mut);
|
||||
background: #f8fbfc;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sign-doc-footer strong {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.sign-doc-footer .is-abnormal {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.sign-doc-footer .is-missing {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.sign-doc-footer .is-pending {
|
||||
color: #a96900;
|
||||
}
|
||||
|
||||
.sign-doc-footer .is-pending strong {
|
||||
color: #a96900;
|
||||
}
|
||||
|
||||
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
|
||||
.sign-doc-body :deep(p) {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.85;
|
||||
color: #1c2b33;
|
||||
}
|
||||
|
||||
/* 文书表格按内容自适应列宽并允许换行,避免绝对列宽在窄容器里把整表裁掉 */
|
||||
.sign-doc-body :deep(table) {
|
||||
width: 100%;
|
||||
margin: 8px 0 12px;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(th),
|
||||
.sign-doc-body :deep(td) {
|
||||
padding: 5px 7px;
|
||||
border: 1px solid #52646e;
|
||||
vertical-align: middle;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(th) {
|
||||
font-weight: 700;
|
||||
background: #f2f6f8;
|
||||
}
|
||||
|
||||
.sign-doc-body :deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
|
||||
它们同时出现在文书库预览、签署工作台与编辑器里,各写一份必然漂移。
|
||||
这里只保留"这份文书外壳"自己的版式(正文字号、表格边框等)。 */
|
||||
</style>
|
||||
Reference in New Issue
Block a user