feat(signing): 支持点击文中签名位就地采集签名

This commit is contained in:
yelan
2026-09-18 13:03:25 +08:00
parent 9062d0b3c5
commit 0d3eb2b9ad
13 changed files with 1173 additions and 41 deletions
+13 -4
View File
@@ -275,16 +275,20 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[]
next = { ...next, signatureDataUrl: DEMO_SIGNATURE_DATA_URL }
}
if (next.documentContentHtml) {
return next
}
const template = templates.get(next.documentId)
if (!template?.contentHtml) {
return next
}
// 已经进入签署流程的任务保留当时固化的正文——那是留痕的一部分,不能事后被换掉。
// 尚未开始签署的任务则跟着演示种子走:演示数据是持久化在 localStorage 里的,
// 改了 mock-document-library 的种子却还渲染上一轮的正文,表现为「新加的内容看不见」,
// 排查成本很高(本地文档库演示入口已随 documents 开关一起关掉了)。
if (next.documentContentHtml && !isTaskContentRefreshable(next.status)) {
return next
}
return {
...next,
templateVersionId: next.templateVersionId || template.versionId,
@@ -293,6 +297,11 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[]
})
}
/** 正文可跟随演示种子刷新的状态:还没真正开始签署,正文只是待签的快照 */
function isTaskContentRefreshable(status: SigningTaskStatus): boolean {
return status === 'pending' || status === 'expired'
}
/**
* 演示任务记录:优先用本地保存的,刷新后仍保留(任务固化的文书 HTML、签名图都在里面)。
* 所有写操作都要调用 persistMockRecords 落盘,否则刷新会回到切换前的状态。
@@ -86,7 +86,10 @@ interface DemoDocumentSpec {
/**
* 生成演示文书正文。
* 标题、说明段、勾选表、签名位、签署日期都用真实文书的写法,
* 其中 {患者签名}{签署日期} 是签署端识别签名位与日期的依据。
* 其中 {患者签名}{医师签名}{签署日期} 是签署端识别签名位与日期的依据。
*
* 患者签名与医师签名是两栏:前者是主签名位(手写板/线上签署采集),
* 后者是文书上的独立签名位,由医师在签署页点击就地采集,两者互不覆盖。
*/
function buildDemoDocumentHtml(spec: DemoDocumentSpec): string {
const paragraphs = [
@@ -109,6 +112,7 @@ function buildDemoDocumentHtml(spec: DemoDocumentSpec): string {
checkboxTable,
...extraFields,
'<p>患者(或家属)签名:{患者签名}</p>',
'<p>医师签名:{医师签名}</p>',
'<p>签署日期:{签署日期}</p>',
].join('')
}
+148 -10
View File
@@ -20,6 +20,7 @@ import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/wo
import {
createFieldTokenPattern,
FIELD_TOKEN_OPEN,
PRIMARY_SIGNATURE_FIELD_ID,
resolveAnswersFromSystem,
resolveFieldByLabel,
SIGNING_DOCUMENT_HEADER,
@@ -44,6 +45,20 @@ const FIELD_KIND_ATTR = 'data-sign-kind'
const FIELD_LABEL_ATTR = 'data-sign-label'
const FIELD_REQUIRED_ATTR = 'data-sign-required'
const FIELD_RISK_ATTR = 'data-sign-risk'
/** 需要宿主接管交互的填写项(目前只有「点击采集签名」),值是动作名 */
const FIELD_ACTION_ATTR = 'data-sign-action'
/** 签名位的采集动作名 */
const SIGNATURE_ACTION = 'signature'
/** 已采集签名图的类名,readAnswersFromRoot 与写入端都按它找 */
const SIGNATURE_IMAGE_CLASS = 'sign-doc-signature'
/** 可点击采集的签名位类名,样式与摘除入口时都要用同一个 */
const ACTIONABLE_CLASS = 'sign-field--actionable'
/**
* 可点击采集签名的填写项宿主节点选择器。
* 导出给签署端做事件委托用 —— 渲染与识别必须指向同一个属性,不能两边各写一份字符串。
*/
export const SIGNATURE_ACTION_SELECTOR = `[${FIELD_ACTION_ATTR}="${SIGNATURE_ACTION}"]`
/**
* 科室手打的复选框符号见 SIGNING_OPTION_MARKERS(utils/signing-fields):
@@ -66,6 +81,25 @@ export interface SigningFieldProgress {
missingRequired: string[]
/** 取值命中风险的项标签 */
abnormal: string[]
/**
* 仍未采集的签名位标签(不含主签名位「患者签名」)。
*
* 签名位在文书里可以逐个点击采集(医师签名、家属签名……),
* 主签名位则是手写板 / 线上签署要采集的那一个,把它列进来等于让签名入口挡住自己。
* 签署端用这份清单在采集前提醒「还有签名位没签」,避免文书带着空签名位被固化。
*/
pendingSignatures: string[]
}
/**
* 签署端点击文中签名位后发出的采集请求。
*
* label 一并带出去,是为了让签字板弹窗能说清「现在采的是谁的名字」——
* 只传 fieldId 的话弹窗只能显示一个内部键,操作人员看不出这笔签名要落在哪。
*/
export interface SigningSignatureRequest {
fieldId: string
label: string
}
function buildFieldWidget(
@@ -73,6 +107,7 @@ function buildFieldWidget(
field: SigningFieldDef,
answers: SigningAnswers,
interactive: boolean,
signatureActionable: boolean,
): HTMLElement {
const value = answers[field.id]
const abnormal =
@@ -89,6 +124,18 @@ function buildFieldWidget(
host.setAttribute(FIELD_RISK_ATTR, field.risk)
}
// 已签名的签名位不再挂动作:留着会让"还能再点一次"的观感落空
const signaturePending = field.kind === 'signature' && !value
if (signatureActionable && signaturePending) {
host.classList.add(ACTIONABLE_CLASS)
host.setAttribute(FIELD_ACTION_ATTR, SIGNATURE_ACTION)
// 可点就得可键盘触发,否则只在鼠标下可用
host.setAttribute('role', 'button')
host.setAttribute('tabindex', '0')
host.setAttribute('title', `点击采集「${field.label}」`)
}
const label = doc.createElement('span')
label.className = 'sign-field__label'
label.textContent = field.label
@@ -104,7 +151,7 @@ function buildFieldWidget(
const control = doc.createElement('span')
control.className = 'sign-field__control'
control.appendChild(buildFieldControl(doc, field, value, interactive))
control.appendChild(buildFieldControl(doc, field, value, interactive, signaturePending))
host.appendChild(control)
if (abnormal) {
@@ -122,25 +169,30 @@ function buildFieldControl(
field: SigningFieldDef,
value: SigningAnswers[string],
interactive: boolean,
signaturePending: boolean,
): Node {
const textValue = typeof value === 'string' ? value : value ? '是' : ''
if (field.kind === 'signature') {
if (textValue) {
const image = doc.createElement('img')
image.className = 'sign-doc-signature'
image.className = SIGNATURE_IMAGE_CLASS
image.src = textValue
image.alt = field.label
return image
}
// 签名位只写「待签名」:宿主胶囊上已经写着「医师签名」了,
// 这里再写一遍「待医师签名」是重复信息
const empty = doc.createElement('span')
empty.className = 'sign-field__signature-empty'
empty.textContent = `待${field.label}`
empty.textContent = signaturePending ? '✍ 点击签名' : '待签名'
return empty
}
if (field.kind === 'choice') {
// 有选项才渲染单选组;没有选项(结构化占位符只声明了 kind=choice 却没给 options)
// 就退化成文本框,否则科室拿到的是一个点不动的空控件
if (field.kind === 'choice' && field.options.length) {
const group = doc.createElement('span')
group.className = 'sign-field__choices'
@@ -209,7 +261,12 @@ function buildFieldControl(
* 模板未收录的占位符按自定义文本项处理,仍可在文书上填写,
* 避免出现"写了占位符但签署时既填不上也看不见"的情况。
*/
function renderTokenFields(root: Element, answers: SigningAnswers, interactive: boolean) {
function renderTokenFields(
root: Element,
answers: SigningAnswers,
interactive: boolean,
signatureActionable: boolean,
) {
const doc = root.ownerDocument
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
@@ -236,7 +293,13 @@ function renderTokenFields(root: Element, answers: SigningAnswers, interactive:
}
fragment.appendChild(
buildFieldWidget(doc, resolveFieldByLabel(match[1] ?? ''), answers, interactive),
buildFieldWidget(
doc,
resolveFieldByLabel(match[1] ?? ''),
answers,
interactive,
signatureActionable,
),
)
cursor = pattern.lastIndex
@@ -255,7 +318,12 @@ function renderTokenFields(root: Element, answers: SigningAnswers, interactive:
* 该写法是目标格式(字段语义显式、不依赖中文标签),
* 供后端模板或后续换编辑器后生产的内容使用。
*/
function renderStructuredFields(root: Element, answers: SigningAnswers, interactive: boolean) {
function renderStructuredFields(
root: Element,
answers: SigningAnswers,
interactive: boolean,
signatureActionable: boolean,
) {
const doc = root.ownerDocument
root.querySelectorAll('span[data-field]').forEach((element) => {
@@ -275,7 +343,7 @@ function renderStructuredFields(root: Element, answers: SigningAnswers, interact
risk: element.getAttribute('data-risk') ?? undefined,
}
element.replaceWith(buildFieldWidget(doc, field, answers, interactive))
element.replaceWith(buildFieldWidget(doc, field, answers, interactive, signatureActionable))
})
}
@@ -488,6 +556,18 @@ function buildDocumentHeader(doc: Document, answers: SigningAnswers): HTMLElemen
return header
}
/** 渲染开关。默认全部关闭,只读展示是"安全侧"缺省。 */
export interface SigningDocumentRenderOptions {
/**
* 签名位是否渲染成「✍ 点击签名」的可点按钮。
*
* 与 interactive 分开是必须的:文书库的静态预览也是 interactive 的
* (科室能在预览里试勾选、确认交互效果),但那里没有事件委托宿主,
* 签名位点了不会有任何反应 —— 渲染成可点按钮就是骗人。
*/
signatureActionable?: boolean
}
/**
* 生成签署态文书 HTML。
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
@@ -497,6 +577,7 @@ export function buildSigningDocumentHtml(
contentHtml: string,
data: SigningDocumentData,
interactive = false,
options: SigningDocumentRenderOptions = {},
): string {
if (!contentHtml) {
return ''
@@ -522,8 +603,10 @@ export function buildSigningDocumentHtml(
answers.signature = data.signatureDataUrl
}
renderStructuredFields(root, answers, interactive)
renderTokenFields(root, answers, interactive)
const signatureActionable = interactive && Boolean(options.signatureActionable)
renderStructuredFields(root, answers, interactive, signatureActionable)
renderTokenFields(root, answers, interactive, signatureActionable)
const selected = new Set(data.selectedItems ?? [])
convertCheckboxes(root, interactive, selected)
@@ -574,6 +657,49 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
return answers
}
/**
* 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。
*
* 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM,
* 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM
* (readAnswersFromRoot),文书 DOM 始终是取值的唯一来源。
*
* 签名图只作为 <img src> 写进渲染结果,不进模板正文:
* 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。
*/
export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean {
if (!fieldId || !dataUrl) {
return false
}
const host = root.querySelector<HTMLElement>(`[${FIELD_ATTR}="${fieldId}"]`)
const control = host?.querySelector('.sign-field__control')
if (!host || !control) {
return false
}
// 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读
if (control.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) {
return false
}
const image = root.ownerDocument.createElement('img')
image.className = SIGNATURE_IMAGE_CLASS
image.src = dataUrl
image.alt = host.getAttribute(FIELD_LABEL_ATTR) ?? fieldId
control.replaceChildren(image)
// 采集入口用完即摘:留着会让「还能再点一次」的观感落空
host.classList.remove(ACTIONABLE_CLASS)
host.removeAttribute(FIELD_ACTION_ATTR)
host.removeAttribute('role')
host.removeAttribute('tabindex')
host.removeAttribute('title')
return true
}
/**
* 统计填写项进度:已填数量、未填的必填项、命中的异常项。
*
@@ -589,6 +715,7 @@ export function describeFieldProgress(
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
}
const seen = new Set<string>()
@@ -617,6 +744,17 @@ export function describeFieldProgress(
progress.missingRequired.push(label)
}
// 待采集的签名位 = 挂上了采集动作、却还没采集的那一个(见 buildFieldWidget)。
// 直接认渲染时打的动作标记,而不是自己再推一遍「kind=signature 且无值」:
// 动作标记本身已经排除了已签名的位,两边判据一致才不会出现
// 「界面显示不可点、清单却催你点」的矛盾。
if (
host.getAttribute(FIELD_ACTION_ATTR) === SIGNATURE_ACTION &&
id !== PRIMARY_SIGNATURE_FIELD_ID
) {
progress.pendingSignatures.push(label)
}
if (risk && value === risk) {
progress.abnormal.push(label)
}
+14
View File
@@ -47,6 +47,20 @@ export function createFieldTokenPattern() {
return new RegExp(FIELD_TOKEN_SOURCE, 'g')
}
/**
* 主签名位字段键(患者签名)。
*
* 一个签署任务只有一份「签名图」产物,接口与任务记录都把它固定落在 signature 上
* (见 api/workbench/signing 的 completeSigningTask:`{ ...fieldAnswers, signature }`)。
* 其余签名位(如医师签名)各自存在 fieldAnswers 里,互不覆盖。
*
* 这条约定有两个用处:
* ① 手写板 / 线上签署采集的就是主签名位,因此它不能算「待补的签名位」,
* 否则签名入口会把自己挡住;
* ② 判断"还有别的签名位没签"时要排除它。
*/
export const PRIMARY_SIGNATURE_FIELD_ID = 'signature'
/**
* 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。
* id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
@@ -52,6 +52,7 @@ const signingProgress = ref<SigningFieldProgress>({
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
})
const signingBodyRef = ref<HTMLElement | null>(null)
@@ -69,7 +70,13 @@ watch(
if (visible) {
activeTab.value = 'content'
signingSelection.value = []
signingProgress.value = { total: 0, filled: 0, missingRequired: [], abnormal: [] }
signingProgress.value = {
total: 0,
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
}
}
},
)
@@ -1,21 +1,40 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { nextTick, ref, useId, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { SigningTaskRecord } from '@/api/workbench/types'
import SignatureCanvas from './SignatureCanvas.vue'
const props = defineProps<{
visible: boolean
task: SigningTaskRecord | null
}>()
// 同一个签字板承担两件事:任务级的主签名采集,以及文书里单个签名位(医师签名、
// 家属签名……)的采集。两者只有文案与「确认后做什么」不同,采集方式完全一样,
// 因此用参数区分而不是复制一份弹窗。
const props = withDefaults(
defineProps<{
visible: boolean
task: SigningTaskRecord | null
/** 弹窗标题:任务级是「手写板签署」,按签名位采集时传字段名(如「医师签名」) */
title?: string
/** 说明这一笔签名要落在哪里,避免医师签在了患者签名位 */
hint?: string
confirmLabel?: string
}>(),
{
title: '手写板签署',
hint: '请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):',
confirmLabel: '确认签署',
},
)
const emit = defineEmits<{
close: []
confirm: [signatureDataUrl: string]
}>()
// 两个实例可能同时挂在 DOM 上(主签名位与某个签名位),标题 id 必须各自唯一,
// 否则 aria-labelledby 会指到别人的标题上
const titleId = useId()
const signatureDataUrl = ref<string | null>(null)
const signatureCanvas = ref<{ clear: () => void } | null>(null)
@@ -37,7 +56,7 @@ function clearSignature() {
function confirmSignature() {
if (!signatureDataUrl.value) {
ElMessage.warning('请先完成患者签名')
ElMessage.warning('请先完成签名')
return
}
@@ -52,19 +71,17 @@ function confirmSignature() {
class="dialog-card signature-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="pad-title"
:aria-labelledby="titleId"
>
<header class="dialog-header">
<strong id="pad-title">手写板签署</strong>
<strong :id="titleId">{{ title }}</strong>
<span>{{ task.patientName }} · {{ task.documentName }}</span>
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<p class="dialog-hint">
请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):
</p>
<p class="dialog-hint">{{ hint }}</p>
<SignatureCanvas
ref="signatureCanvas"
:height="300"
@@ -77,7 +94,9 @@ function confirmSignature() {
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
清空重写
</button>
<button type="button" class="action-button" @click="confirmSignature">确认签署</button>
<button type="button" class="action-button" @click="confirmSignature">
{{ confirmLabel }}
</button>
</footer>
</section>
</div>
@@ -3,11 +3,14 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
import {
applySignatureToRoot,
buildSigningDocumentHtml,
describeFieldProgress,
readAnswersFromRoot,
SIGNATURE_ACTION_SELECTOR,
type SigningDocumentData,
type SigningFieldProgress,
type SigningSignatureRequest,
} from '@/utils/signing-document'
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
@@ -17,13 +20,22 @@ const props = withDefaults(
defineProps<{
task: SigningTaskRecord
interactive?: boolean
/**
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
*
* 不走 task:task 是服务端快照,这里装的是"本次会话刚采到、还没提交"的取值。
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
*/
signatureInjections?: SigningAnswers
}>(),
{ interactive: false },
{ 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)
@@ -34,6 +46,7 @@ const progress = ref<SigningFieldProgress>({
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
})
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
@@ -55,6 +68,9 @@ const processedHtml = computed(() =>
props.task.documentContentHtml ?? '',
documentData.value,
props.interactive,
// 本组件就是签名位的点击宿主(见 handleDocumentClick),
// 所以可采集与否只取决于文书当前是否可交互
{ signatureActionable: props.interactive },
),
)
@@ -158,10 +174,100 @@ watch(
{ immediate: true },
)
watch(processedHtml, applyStoredValues, { flush: 'post' })
/**
* 从事件目标往上找「可点击采集的签名位」。
* 用 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,
})
}
function handleDocumentClick(event: MouseEvent) {
if (!props.interactive) {
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()
})
@@ -190,6 +296,8 @@ defineExpose({ getDocumentElement })
class="sign-doc-body"
@change="syncFromDom"
@input="syncFromDom"
@click="handleDocumentClick"
@keydown="handleDocumentKeydown"
v-html="processedHtml"
/>
<!-- eslint-enable vue/no-v-html -->
@@ -204,6 +312,10 @@ defineExpose({ getDocumentElement })
<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
>
@@ -297,6 +409,14 @@ defineExpose({ getDocumentElement })
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;
@@ -469,6 +589,29 @@ defineExpose({ getDocumentElement })
border-radius: 4px;
}
/* 可点击采集的签名位:整块胶囊都是点击热区(子节点会冒泡到宿主),
hover / 聚焦给出明确反馈,否则操作人员不知道这里能点 */
.sign-doc-body :deep(.sign-field--actionable) {
cursor: pointer;
background: #fffaf0;
border-color: #f0a21a;
}
.sign-doc-body :deep(.sign-field--actionable:hover) {
background: #fff3d8;
border-color: #d98c05;
box-shadow: 0 0 0 2px rgb(240 162 26 / 20%);
}
.sign-doc-body :deep(.sign-field--actionable:focus-visible) {
outline: 2px solid var(--brand);
outline-offset: 1px;
}
.sign-doc-body :deep(.sign-field--actionable .sign-field__signature-empty) {
font-weight: 600;
}
/* 签名位比其他填写项更宽,避免签名图被压扁 */
.sign-doc-body :deep(.sign-field--signature) {
padding: 3px 8px;
@@ -7,7 +7,7 @@ import type {
SigningTaskEvent,
SigningTaskRecord,
} from '@/api/workbench/types'
import type { SigningFieldProgress } from '@/utils/signing-document'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
import SigningDocumentPreview from './SigningDocumentPreview.vue'
@@ -33,6 +33,8 @@ defineProps<{
canViewAudit: boolean
canViewArtifacts: boolean
canDownloadArtifact: boolean
/** 本次会话在文书里点击采集到的签名图,由签署页持有并回灌 */
signatureInjections: SigningAnswers
}>()
const emit = defineEmits<{
@@ -40,6 +42,7 @@ const emit = defineEmits<{
downloadArtifact: [artifact: SigningArtifact]
selectionChange: [items: string[]]
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
signatureRequest: [request: SigningSignatureRequest]
}>()
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
@@ -173,8 +176,10 @@ defineExpose({ getDocumentElement })
ref="documentRef"
:task="task"
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
:signature-injections="signatureInjections"
@selection-change="emit('selectionChange', $event)"
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
@signature-request="emit('signatureRequest', $event)"
/>
<SigningDocumentPreview v-else :task="task" />
<TaskAuditTimeline
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute } from 'vue-router'
import { getCampuses, getDepartments } from '@/api/management/organization'
@@ -38,7 +38,7 @@ import type {
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import type { SigningFieldProgress } from '@/utils/signing-document'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
@@ -95,6 +95,15 @@ const signingSelection = ref<string[]>([])
const signingAnswers = ref<SigningAnswers>({})
/** 文书填写项完成情况:签署前用它做必填校验、提示异常项 */
const signingProgress = ref<SigningFieldProgress | null>(null)
/**
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
* 与 signingAnswers 分开:signingAnswers 每次输入都会变,把它当渲染输入会让
* 整篇文书重渲染、光标丢失;这份只在真的采到签名时变化。
*/
const fieldSignatureInjections = ref<SigningAnswers>({})
/** 当前正在采集的签名位,null 表示弹窗不是由「点击文中签名位」打开的 */
const pendingSignatureField = ref<SigningSignatureRequest | null>(null)
const fieldSignatureDialogVisible = ref(false)
const taskEvents = ref<SigningTaskEvent[]>([])
const artifacts = ref<SigningArtifact[]>([])
const templates = ref<SigningTemplate[]>([])
@@ -253,6 +262,9 @@ async function loadTaskDetail(id: string | null) {
signingSelection.value = []
signingAnswers.value = {}
signingProgress.value = null
fieldSignatureInjections.value = {}
pendingSignatureField.value = null
fieldSignatureDialogVisible.value = false
if (!id) {
selectedTask.value = null
@@ -408,10 +420,14 @@ function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgre
}
/**
* 采集签名前的填写项校验。
* 必填项没填完就采集签名,等于签了一份内容不完整的文书,因此在入口处挡住。
* 采集主签名前的检查。
*
* ① 必填项没填完 → 直接挡住:签一份内容不完整的文书没有意义;
* ② 文书里还有没采集的签名位 → 让操作人员确认后再继续。
* 这一条不能硬挡:医师确实可能签在纸质件上,替业务做决定是越界;
* 但也不能不提醒,否则文书会带着空签名位被固化。
*/
function assertFieldsReady() {
async function assertSigningReady() {
const missing = signingProgress.value?.missingRequired ?? []
if (missing.length) {
@@ -419,7 +435,59 @@ function assertFieldsReady() {
return false
}
return true
const pending = signingProgress.value?.pendingSignatures ?? []
if (!pending.length) {
return true
}
try {
await ElMessageBox.confirm(
`文书中还有签名位未采集:${pending.join('、')}。可直接点击文中签名位完成,也可以继续采集主签名。`,
'还有签名位待采集',
{ confirmButtonText: '继续签署', cancelButtonText: '返回采集', type: 'warning' },
)
return true
} catch {
return false
}
}
/** 点击文中签名位:先记下采的是哪一个,再由弹窗把签名图回灌到该位置 */
function handleSignatureRequest(request: SigningSignatureRequest) {
if (!selectedTask.value) {
return
}
pendingSignatureField.value = request
fieldSignatureDialogVisible.value = true
}
function closeFieldSignatureDialog() {
fieldSignatureDialogVisible.value = false
pendingSignatureField.value = null
}
/**
* 单个签名位采集完成。
*
* 这里不调 completeSigningTask:医师签名只是文书上的一个填写项,
* 任务级的「签署完成」仍要由主签名位(患者签名)触发,
* 否则点一下医师签名就把整个任务标记成已签署了。
*/
function confirmFieldSignature(signatureDataUrl: string) {
const request = pendingSignatureField.value
if (!request) {
return
}
fieldSignatureInjections.value = {
...fieldSignatureInjections.value,
[request.fieldId]: signatureDataUrl,
}
closeFieldSignatureDialog()
ElMessage.success(`「${request.label}」签名已采集,将随本次签署一并固化`)
}
function handlePageChange(nextPage: number) {
@@ -471,7 +539,7 @@ async function handleTaskAction(action: SigningTaskAction) {
return
}
if (!assertFieldsReady()) {
if (!(await assertSigningReady())) {
return
}
@@ -501,7 +569,7 @@ async function handleTaskAction(action: SigningTaskAction) {
return
}
if (!assertFieldsReady()) {
if (!(await assertSigningReady())) {
return
}
@@ -803,10 +871,12 @@ onMounted(() => {
:can-view-audit="canViewEvents"
:can-view-artifacts="canViewArtifacts"
:can-download-artifact="canDownloadArtifacts"
:signature-injections="fieldSignatureInjections"
@action="handleTaskAction"
@download-artifact="downloadArtifact"
@selection-change="handleSelectionChange"
@field-change="handleFieldChange"
@signature-request="handleSignatureRequest"
/>
</div>
@@ -821,6 +891,16 @@ onMounted(() => {
@close="signatureDialogVisible = false"
@confirm="confirmPadSignature"
/>
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->
<SignaturePadDialog
:visible="fieldSignatureDialogVisible"
:task="selectedTask"
:title="`采集「${pendingSignatureField?.label ?? '签名'}」`"
:hint="`请让签署人在下框内签名,签名图将回显到文书中的「${pendingSignatureField?.label ?? '签名'}」位置;本次采集不会结束签署任务:`"
confirm-label="确认采集"
@close="closeFieldSignatureDialog"
@confirm="confirmFieldSignature"
/>
<OnlineSigningDialog
:visible="onlineDialogVisible"
:task="selectedTask"