feat(signing): 支持点击文中签名位就地采集签名
This commit is contained in:
@@ -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('')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
|
||||
|
||||
Reference in New Issue
Block a user