feat(签署): 支持文书填写项与签名回显

This commit is contained in:
yelan
2026-09-16 15:54:40 +08:00
parent b15df82be2
commit 5361839cf2
10 changed files with 1178 additions and 164 deletions
@@ -1,12 +1,18 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { SigningTaskRecord } from '@/api/workbench/types'
import { buildSigningDocumentHtml } from '@/utils/signing-document'
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
import {
buildSigningDocumentHtml,
describeFieldProgress,
readAnswersFromRoot,
type SigningDocumentData,
type SigningFieldProgress,
} from '@/utils/signing-document'
// 签署文书交互化渲染:变换管线(占位符填充 / 签名域 / 勾选表转复选框)见
// utils/signing-document,与文书库的「签署预览」共用同一实现。
// 本组件只负责挂载 HTML、收集勾选结果并渲染签署态外壳。
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
// utils/signing-document 与 utils/signing-fields,与文书库的「签署预览」共用同一实现。
// 本组件只负责挂载 HTML、收集填写项取值并渲染签署态外壳。
const props = withDefaults(
defineProps<{
task: SigningTaskRecord
@@ -17,32 +23,71 @@ const props = withDefaults(
const emit = defineEmits<{
'selection-change': [items: string[]]
'field-change': [answers: SigningAnswers, progress: SigningFieldProgress]
}>()
const containerRef = ref<HTMLElement | null>(null)
const selectedItems = ref<string[]>([])
const fieldAnswers = ref<SigningAnswers>({})
const progress = ref<SigningFieldProgress>({
total: 0,
filled: 0,
missingRequired: [],
abnormal: [],
})
// 任务自带勾选(已签署的文书)在挂载/切换任务时同步出来并上报,
// 否则父级拿到的是空数组,会当成"一项都没勾"
watch(
() => props.task,
(task) => {
selectedItems.value = [...(task.selectedItems ?? [])]
emit('selection-change', [...selectedItems.value])
},
{ immediate: true },
)
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
// 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
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 ?? '', props.task, props.interactive),
buildSigningDocumentHtml(
props.task.documentContentHtml ?? '',
documentData.value,
props.interactive,
),
)
/** 文书里的填写项数量(含只读展示),用于提示"这份文书有几个填写项" */
const fieldTotal = computed(() => progress.value.total)
/**
* 兜底:文书 HTML 更新后,按任务记录再同步一次 DOM 的勾选状态。
* 生成 HTML 时已经写了 checked 属性,这里不依赖 attribute 在序列化中的表现,
* 确保「签完再打开时勾选还在」这件事稳定成立。
* 从渲染后的 DOM 读回填写项取值。
* 生成 HTML 时已把取值写成 attribute,这里再按取值同步一次 DOM 状态,
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
*/
function applyStoredSelection() {
function syncFromDom() {
const container = containerRef.value
if (!container) {
return
}
const answers = readAnswersFromRoot(container)
fieldAnswers.value = answers
progress.value = describeFieldProgress(container, answers)
const checked = Array.from(
container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'),
)
selectedItems.value = checked.map((item) => item.getAttribute('data-sign-item') ?? '')
emit('field-change', { ...answers }, { ...progress.value })
emit('selection-change', [...selectedItems.value])
}
/** 按已固化的取值还原 DOM 控件状态(存量勾选表 + 新填写项都覆盖) */
function applyStoredValues() {
const container = containerRef.value
if (!container) {
@@ -54,33 +99,64 @@ function applyStoredSelection() {
container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox').forEach((input) => {
input.checked = selected.has(input.getAttribute('data-sign-item') ?? '')
})
}
watch(processedHtml, applyStoredSelection, { flush: 'post' })
const answers: SigningAnswers = { ...(props.task.fieldAnswers ?? {}) }
onMounted(applyStoredSelection)
function handleSelectionChange(event: Event) {
const target = event.target as HTMLInputElement
if (!target.classList.contains('sign-doc-checkbox')) {
return
if (props.task.signatureDataUrl && !answers.signature) {
answers.signature = props.task.signatureDataUrl
}
const container = containerRef.value
const checked = container
? Array.from(container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
: []
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
const id = host.getAttribute('data-sign-field')
const value = id ? answers[id] : undefined
selectedItems.value = checked.map((item) => item.getAttribute('data-sign-item') ?? '')
emit('selection-change', [...selectedItems.value])
if (id === undefined || value === undefined) {
return
}
if (typeof value === 'boolean') {
const checkbox = host.querySelector<HTMLInputElement>('input[type="checkbox"]')
if (checkbox) {
checkbox.checked = value
}
return
}
const input = host.querySelector<HTMLInputElement>(
'input:not([type="radio"]):not([type="checkbox"])',
)
if (input) {
input.value = value
}
host.querySelectorAll<HTMLInputElement>('input[type="radio"]').forEach((radio) => {
radio.checked = radio.value === value
})
})
}
watch(
() => props.task,
() => {
selectedItems.value = [...(props.task.selectedItems ?? [])]
fieldAnswers.value = { ...(props.task.fieldAnswers ?? {}) }
emit('selection-change', [...selectedItems.value])
},
{ immediate: true },
)
watch(processedHtml, applyStoredValues, { flush: 'post' })
onMounted(() => {
applyStoredValues()
syncFromDom()
})
</script>
<template>
<article class="sign-doc">
<div class="sign-doc-banner">
<span>已按任务固化的文书版本渲染(占位符已填充、"勾选"表已交互化)</span>
<span>已按任务固化的文书版本渲染(填写项已按患者信息预填,签名落在文书签名位)</span>
<span class="sign-doc-state">
{{
task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署'
@@ -92,18 +168,30 @@ function handleSelectionChange(event: Event) {
<div
ref="containerRef"
class="sign-doc-body"
@change="handleSelectionChange"
@change="syncFromDom"
@input="syncFromDom"
v-html="processedHtml"
/>
<!-- eslint-enable vue/no-v-html -->
<footer v-if="interactive" class="sign-doc-footer">
<span>
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{
selectedItems.length ? `:${selectedItems.join('、')}` : ''
}}
填写项 <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
>完成填写后通过「手写板签署 / 线上签署」采集签名,填写项与勾选结果随签署证据一并提交。</span
>
</footer>
<footer v-else-if="fieldTotal" class="sign-doc-footer">
<span>
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填,取值已随签署固化。
</span>
<span>完成勾选后通过「手写板签署 / 线上签署」采集签名,勾选结果将随签署证据一并提交。</span>
</footer>
</article>
</template>
@@ -160,6 +248,14 @@ function handleSelectionChange(event: Event) {
color: var(--brand);
}
.sign-doc-footer .is-abnormal {
color: var(--err);
}
.sign-doc-footer .is-missing {
color: var(--err);
}
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
.sign-doc-body :deep(p) {
margin: 0 0 6px;
@@ -223,4 +319,108 @@ function handleSelectionChange(event: Event) {
max-height: 52px;
vertical-align: middle;
}
/* ===== 填写项 ===== */
.sign-doc-body :deep(.sign-field) {
display: inline-flex;
gap: 4px;
align-items: center;
padding: 2px 7px;
margin: 0 2px;
vertical-align: middle;
background: #f7fbfd;
border: 1px solid #d4e3e8;
border-radius: 5px;
}
.sign-doc-body :deep(.sign-field__label) {
flex-shrink: 0;
color: #587786;
font-size: 11.5px;
}
.sign-doc-body :deep(.sign-field__required) {
margin-left: 2px;
color: var(--err);
}
.sign-doc-body :deep(.sign-field__control) {
display: inline-flex;
gap: 8px;
align-items: center;
}
.sign-doc-body :deep(.sign-field__input) {
width: 9em;
min-width: 0;
padding: 1px 5px;
color: #1c2b33;
font: inherit;
font-size: 12.5px;
background: #fff;
border: 1px solid #cfe0e6;
border-radius: 4px;
}
.sign-doc-body :deep(.sign-field__input:focus) {
border-color: var(--brand);
outline: none;
}
.sign-doc-body :deep(.sign-field__input:disabled) {
color: #1c2b33;
background: transparent;
border-color: transparent;
opacity: 1;
-webkit-text-fill-color: #1c2b33;
}
.sign-doc-body :deep(.sign-field__choices) {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
.sign-doc-body :deep(.sign-field__radio),
.sign-doc-body :deep(.sign-field__checkbox) {
width: 15px;
height: 15px;
accent-color: var(--brand);
}
.sign-doc-body :deep(.sign-field__signature-empty) {
padding: 12px 18px;
color: #a96900;
font-size: 11px;
background: #fff9ec;
border: 1px dashed #f0a21a;
border-radius: 4px;
}
/* 签名位比其他填写项更宽,避免签名图被压扁 */
.sign-doc-body :deep(.sign-field--signature) {
padding: 3px 8px;
}
.sign-doc-body :deep(.sign-field--signature .sign-field__control) {
min-width: 150px;
justify-content: center;
}
.sign-doc-body :deep(.sign-field--abnormal) {
background: #fdf0f0;
border-color: #efb9bb;
}
.sign-doc-body :deep(.sign-field--abnormal .sign-field__label) {
color: #a42e34;
}
.sign-doc-body :deep(.sign-field__badge) {
padding: 1px 6px;
color: #fff;
font-size: 10px;
background: var(--err);
border-radius: 8px;
}
</style>
@@ -1,5 +1,11 @@
<script setup lang="ts">
import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
import type {
SigningAnswers,
SigningArtifact,
SigningTaskEvent,
SigningTaskRecord,
} from '@/api/workbench/types'
import type { SigningFieldProgress } from '@/utils/signing-document'
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
import SigningDocumentPreview from './SigningDocumentPreview.vue'
@@ -25,6 +31,7 @@ const emit = defineEmits<{
action: [action: SigningTaskAction]
downloadArtifact: [artifact: SigningArtifact]
selectionChange: [items: string[]]
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
}>()
</script>
@@ -146,6 +153,7 @@ const emit = defineEmits<{
:task="task"
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
@selection-change="emit('selectionChange', $event)"
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
/>
<SigningDocumentPreview v-else :task="task" />
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
@@ -19,6 +19,7 @@ import {
voidSigningTask,
} from '@/api/workbench/signing'
import type {
SigningAnswers,
SigningDateRange,
SigningMethod,
SigningArtifact,
@@ -32,6 +33,7 @@ import type {
} from '@/api/workbench/types'
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import type { SigningFieldProgress } from '@/utils/signing-document'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
import SignaturePadDialog from './components/SignaturePadDialog.vue'
@@ -82,6 +84,10 @@ const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null)
/** 当前任务里患者已勾选的条目,签署完成时随签名一起固化到任务上 */
const signingSelection = ref<string[]>([])
/** 当前任务文书的填写项取值(含签名图),签署完成时随签名一起固化 */
const signingAnswers = ref<SigningAnswers>({})
/** 文书填写项完成情况:签署前用它做必填校验、提示异常项 */
const signingProgress = ref<SigningFieldProgress | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([])
const artifacts = ref<SigningArtifact[]>([])
const templates = ref<SigningTemplate[]>([])
@@ -215,9 +221,11 @@ async function loadOrganizationOptions() {
async function loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId
// 换任务时先清空勾选,避免把上一个任务的勾选带到新任务上;
// 文书组件挂载后会用任务自身的勾选重新上报
// 换任务时先清空勾选与填写项,避免把上一个任务的取值带到新任务上;
// 文书组件挂载后会用任务自身固化的取值重新上报
signingSelection.value = []
signingAnswers.value = {}
signingProgress.value = null
if (!id) {
selectedTask.value = null
@@ -254,6 +262,7 @@ async function loadTaskDetail(id: string | null) {
}
selectedTask.value = detailResult.value
signingAnswers.value = { ...(detailResult.value.fieldAnswers ?? {}) }
if (eventsResult.status === 'fulfilled') {
taskEvents.value = eventsResult.value
@@ -348,6 +357,26 @@ function handleSelectionChange(items: string[]) {
signingSelection.value = items
}
function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgress) {
signingAnswers.value = answers
signingProgress.value = progress
}
/**
* 采集签名前的填写项校验。
* 必填项没填完就采集签名,等于签了一份内容不完整的文书,因此在入口处挡住。
*/
function assertFieldsReady() {
const missing = signingProgress.value?.missingRequired ?? []
if (missing.length) {
ElMessage.warning(`请先完成必填项:${missing.join('、')}`)
return false
}
return true
}
function handlePageChange(nextPage: number) {
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
@@ -383,6 +412,10 @@ async function handleTaskAction(action: SigningTaskAction) {
return
}
if (!assertFieldsReady()) {
return
}
signatureDialogVisible.value = true
return
}
@@ -409,6 +442,10 @@ async function handleTaskAction(action: SigningTaskAction) {
return
}
if (!assertFieldsReady()) {
return
}
onlineDialogVisible.value = true
return
}
@@ -514,7 +551,12 @@ async function confirmPadSignature(signatureDataUrl: string) {
try {
await refreshTask(
await completeSigningTask(task.id, signatureDataUrl, signingSelection.value),
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'手写签署已完成',
)
signatureDialogVisible.value = false
@@ -532,7 +574,12 @@ async function completeOnlineSignature(signatureDataUrl: string) {
try {
await refreshTask(
await completeSigningTask(task.id, signatureDataUrl, signingSelection.value),
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'线上签署已完成',
)
onlineDialogVisible.value = false
@@ -655,6 +702,7 @@ onMounted(() => {
@action="handleTaskAction"
@download-artifact="downloadArtifact"
@selection-change="handleSelectionChange"
@field-change="handleFieldChange"
/>
</div>