feat(签署): 支持文书填写项与签名回显
This commit is contained in:
@@ -1,31 +1,47 @@
|
||||
/**
|
||||
* 签署文书 HTML 渲染管线(原型)。
|
||||
* 文书渲染管线(原型)。
|
||||
*
|
||||
* 把「文书定稿 HTML」按签署时的效果做一次变换:
|
||||
* ① 占位符填充({患者姓名}等 → 患者与就诊快照数据);
|
||||
* ② 签名域:已有签名图时替换 {患者签名},否则给出待签署提示;
|
||||
* ③ 勾选表识别:表头含「勾选」列的表格,逐行转为复选框;
|
||||
* ④ 行内选项识别:正文手打的 □ 方框(□否 □是)转为单选/多选框。
|
||||
* 把「模板定稿 HTML」渲染成签署态:
|
||||
* ① 填写项渲染:占位符按控件类型渲染成文本/日期/单选/勾选/签名控件,
|
||||
* 初值来自 HIS 与患者就诊快照,签署时可在文书上直接确认或补录;
|
||||
* ② 签名回显:签名是 kind=signature 的填写项,签名图作为该字段的取值,
|
||||
* 签完后落在文书原本的签名位上,不需要"替换文字"这一步;
|
||||
* ③ 勾选表识别(存量兼容):表头含「勾选」列的表格逐行转为复选框;
|
||||
* ④ 行内选项识别(存量兼容):正文手打的 □ 方框转成单选/多选框。
|
||||
*
|
||||
* 签署任务详情(SigningDocumentInteractive)与文书库的「签署预览」共用本模块,
|
||||
* 避免两处各自演化后渲染结果不一致。真实实现中填充在后端完成、勾选结果随签署证据
|
||||
* 提交,此处仅用于前端闭环验证。
|
||||
* ③④ 是为已经存在的存量文书保留的启发式识别;用占位符写的新文书不再依赖它们。
|
||||
*
|
||||
* 渲染所需的取值模型见 signing-fields.ts;真实实现中填充与固化在后端完成,
|
||||
* 此处仅用于前端闭环验证。
|
||||
*/
|
||||
|
||||
/** 渲染所需的患者/就诊快照字段;SigningTaskRecord 与预览用示例数据都满足此形状。 */
|
||||
export interface SigningDocumentData {
|
||||
patientName: string
|
||||
sex: string
|
||||
age: number | string
|
||||
visitNo: string
|
||||
visitDate: string
|
||||
signedAt?: string
|
||||
signatureDataUrl?: string
|
||||
/** 已勾选的条目名:签署完成后重新打开文书时用来还原勾选状态 */
|
||||
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
|
||||
|
||||
import {
|
||||
createFieldTokenPattern,
|
||||
FIELD_TOKEN_OPEN,
|
||||
resolveAnswersFromSystem,
|
||||
resolveFieldByLabel,
|
||||
type SigningSystemSnapshot,
|
||||
} from './signing-fields'
|
||||
|
||||
/** 渲染所需的患者/就诊快照 + 已填写的填写项取值。 */
|
||||
export interface SigningDocumentData extends SigningSystemSnapshot {
|
||||
/** 已签署的存量文书里,由「勾选表」收集的条目 */
|
||||
selectedItems?: string[]
|
||||
/**
|
||||
* 填写项取值:已填过的值优先于系统快照(用于签署后回显)。
|
||||
* 属性名与 SigningTaskRecord.fieldAnswers 一致,避免任务对象透传时对不上而静默丢值。
|
||||
*/
|
||||
fieldAnswers?: SigningAnswers
|
||||
}
|
||||
|
||||
const SIGNATURE_PLACEHOLDER = '{患者签名}'
|
||||
/** 填写项在文书上的宿主节点属性,签署端靠它收集取值 */
|
||||
const FIELD_ATTR = 'data-sign-field'
|
||||
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'
|
||||
|
||||
/**
|
||||
* 科室手打的复选框符号:几何图形方框,以及 Wingdings 私有区码位。
|
||||
@@ -41,69 +57,224 @@ const OPTION_TEST_PATTERN = new RegExp(`[${OPTION_MARKERS}]`)
|
||||
/** 判定「同一组选项」的块级容器:同一段、同一单元格里的方框视为一组 */
|
||||
const OPTION_BLOCK_SELECTOR = 'p, li, td, th, div, h1, h2, h3, h4, h5, h6'
|
||||
|
||||
/** 收集所有包含指定关键字(占位符前缀等)的文本节点 */
|
||||
function collectTextNodes(root: Element, keyword: string): Text[] {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
/** 一个填写项在文书上的取值状态,用于签署端提示必填与异常 */
|
||||
export interface SigningFieldProgress {
|
||||
total: number
|
||||
filled: number
|
||||
/** 未填写的必填项标签 */
|
||||
missingRequired: string[]
|
||||
/** 取值命中风险的项标签 */
|
||||
abnormal: string[]
|
||||
}
|
||||
|
||||
function buildFieldWidget(
|
||||
doc: Document,
|
||||
field: SigningFieldDef,
|
||||
answers: SigningAnswers,
|
||||
interactive: boolean,
|
||||
): HTMLElement {
|
||||
const value = answers[field.id]
|
||||
const abnormal =
|
||||
Boolean(field.risk) && typeof value === 'string' && value === (field.risk as string)
|
||||
|
||||
const host = doc.createElement('span')
|
||||
host.className = `sign-field sign-field--${field.kind}${abnormal ? ' sign-field--abnormal' : ''}`
|
||||
host.setAttribute(FIELD_ATTR, field.id)
|
||||
host.setAttribute(FIELD_KIND_ATTR, field.kind)
|
||||
host.setAttribute(FIELD_LABEL_ATTR, field.label)
|
||||
host.setAttribute(FIELD_REQUIRED_ATTR, field.required ? 'true' : 'false')
|
||||
|
||||
if (field.risk) {
|
||||
host.setAttribute(FIELD_RISK_ATTR, field.risk)
|
||||
}
|
||||
|
||||
const label = doc.createElement('span')
|
||||
label.className = 'sign-field__label'
|
||||
label.textContent = field.label
|
||||
|
||||
if (field.required) {
|
||||
const mark = doc.createElement('span')
|
||||
mark.className = 'sign-field__required'
|
||||
mark.textContent = '*'
|
||||
label.appendChild(mark)
|
||||
}
|
||||
|
||||
host.appendChild(label)
|
||||
|
||||
const control = doc.createElement('span')
|
||||
control.className = 'sign-field__control'
|
||||
control.appendChild(buildFieldControl(doc, field, value, interactive))
|
||||
host.appendChild(control)
|
||||
|
||||
if (abnormal) {
|
||||
const badge = doc.createElement('span')
|
||||
badge.className = 'sign-field__badge'
|
||||
badge.textContent = '异常项'
|
||||
host.appendChild(badge)
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
function buildFieldControl(
|
||||
doc: Document,
|
||||
field: SigningFieldDef,
|
||||
value: SigningAnswers[string],
|
||||
interactive: 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.src = textValue
|
||||
image.alt = field.label
|
||||
return image
|
||||
}
|
||||
|
||||
const empty = doc.createElement('span')
|
||||
empty.className = 'sign-field__signature-empty'
|
||||
empty.textContent = `待${field.label}`
|
||||
return empty
|
||||
}
|
||||
|
||||
if (field.kind === 'choice') {
|
||||
const group = doc.createElement('span')
|
||||
group.className = 'sign-field__choices'
|
||||
|
||||
field.options.forEach((option) => {
|
||||
const wrapper = doc.createElement('label')
|
||||
wrapper.className = 'sign-doc-option'
|
||||
|
||||
const input = doc.createElement('input')
|
||||
input.className = 'sign-field__radio'
|
||||
input.type = 'radio'
|
||||
input.name = `sign-field-${field.id}`
|
||||
input.value = option
|
||||
input.disabled = !interactive
|
||||
|
||||
// 必须同时落成 attribute:innerHTML 只序列化 HTML 属性,
|
||||
// 只设 .checked 这个 property 会在生成 HTML 时被丢掉
|
||||
if (textValue === option) {
|
||||
input.checked = true
|
||||
input.setAttribute('checked', '')
|
||||
}
|
||||
|
||||
wrapper.appendChild(input)
|
||||
wrapper.appendChild(doc.createTextNode(option))
|
||||
group.appendChild(wrapper)
|
||||
})
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
if (field.kind === 'checkbox') {
|
||||
const wrapper = doc.createElement('label')
|
||||
wrapper.className = 'sign-doc-option'
|
||||
|
||||
const input = doc.createElement('input')
|
||||
input.className = 'sign-field__checkbox'
|
||||
input.type = 'checkbox'
|
||||
input.disabled = !interactive
|
||||
|
||||
if (value === true) {
|
||||
input.checked = true
|
||||
input.setAttribute('checked', '')
|
||||
}
|
||||
|
||||
wrapper.appendChild(input)
|
||||
wrapper.appendChild(doc.createTextNode(field.label))
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const input = doc.createElement('input')
|
||||
input.className = 'sign-field__input'
|
||||
input.type = field.kind === 'date' ? 'date' : 'text'
|
||||
input.value = textValue
|
||||
// value 属性同理:只设 property 的话序列化出来是空值
|
||||
input.setAttribute('value', textValue)
|
||||
input.readOnly = !interactive
|
||||
input.disabled = !interactive
|
||||
if (interactive) {
|
||||
input.placeholder = '请填写'
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
/**
|
||||
* 占位符渲染:把 {患者姓名} 就地替换成填写项控件,保留同一文本节点内的前后文字。
|
||||
* 模板未收录的占位符按自定义文本项处理,仍可在文书上填写,
|
||||
* 避免出现"写了占位符但签署时既填不上也看不见"的情况。
|
||||
*/
|
||||
function renderTokenFields(root: Element, answers: SigningAnswers, interactive: boolean) {
|
||||
const doc = root.ownerDocument
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const targets: Text[] = []
|
||||
let node = walker.nextNode()
|
||||
|
||||
while (node) {
|
||||
if ((node.textContent ?? '').includes(keyword)) {
|
||||
if ((node.textContent ?? '').includes(FIELD_TOKEN_OPEN)) {
|
||||
targets.push(node as Text)
|
||||
}
|
||||
|
||||
node = walker.nextNode()
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
targets.forEach((textNode) => {
|
||||
const text = textNode.textContent ?? ''
|
||||
const pattern = createFieldTokenPattern()
|
||||
const fragment = doc.createDocumentFragment()
|
||||
let cursor = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
function replaceTextPlaceholder(root: Element, placeholder: string, replacement: string) {
|
||||
collectTextNodes(root, placeholder).forEach((textNode) => {
|
||||
textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(replacement)
|
||||
})
|
||||
}
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > cursor) {
|
||||
fragment.appendChild(doc.createTextNode(text.slice(cursor, match.index)))
|
||||
}
|
||||
|
||||
/** 把 {患者签名} 就地换成签名图片,保留同一文本节点内的前后文字 */
|
||||
function replaceSignaturePlaceholder(root: Element, dataUrl: string) {
|
||||
collectTextNodes(root, SIGNATURE_PLACEHOLDER).forEach((textNode) => {
|
||||
const parts = (textNode.textContent ?? '').split(SIGNATURE_PLACEHOLDER)
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.appendChild(
|
||||
buildFieldWidget(doc, resolveFieldByLabel(match[1] ?? ''), answers, interactive),
|
||||
)
|
||||
|
||||
if (parts[0]) {
|
||||
fragment.appendChild(document.createTextNode(parts[0]))
|
||||
cursor = pattern.lastIndex
|
||||
}
|
||||
|
||||
const image = document.createElement('img')
|
||||
image.src = dataUrl
|
||||
image.alt = '患者签名'
|
||||
image.className = 'sign-doc-signature'
|
||||
fragment.appendChild(image)
|
||||
|
||||
if (parts[1]) {
|
||||
fragment.appendChild(document.createTextNode(parts[1]))
|
||||
if (cursor < text.length) {
|
||||
fragment.appendChild(doc.createTextNode(text.slice(cursor)))
|
||||
}
|
||||
|
||||
textNode.replaceWith(fragment)
|
||||
})
|
||||
}
|
||||
|
||||
function fillPatientPlaceholders(root: Element, data: SigningDocumentData) {
|
||||
const replacements: Record<string, string> = {
|
||||
'{患者姓名}': data.patientName,
|
||||
'{性别}': data.sex,
|
||||
'{年龄}': `${data.age} 岁`,
|
||||
'{就诊号}': data.visitNo,
|
||||
'{联系电话}': '—',
|
||||
'{预约日期}': data.visitDate,
|
||||
'{与患者关系}': '本人',
|
||||
'{签署日期}': data.signedAt || data.visitDate || ' 年 月 日',
|
||||
}
|
||||
/**
|
||||
* 结构化占位符渲染:兼容 <span data-field="..."> 写法。
|
||||
* 该写法是目标格式(字段语义显式、不依赖中文标签),
|
||||
* 供后端模板或后续换编辑器后生产的内容使用。
|
||||
*/
|
||||
function renderStructuredFields(root: Element, answers: SigningAnswers, interactive: boolean) {
|
||||
const doc = root.ownerDocument
|
||||
|
||||
collectTextNodes(root, '{').forEach((textNode) => {
|
||||
Object.entries(replacements).forEach(([placeholder, value]) => {
|
||||
textNode.textContent = (textNode.textContent ?? '').split(placeholder).join(value)
|
||||
})
|
||||
root.querySelectorAll('span[data-field]').forEach((element) => {
|
||||
const id = element.getAttribute('data-field') ?? ''
|
||||
const label = element.getAttribute('data-label') ?? id
|
||||
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const field: SigningFieldDef = {
|
||||
id,
|
||||
label,
|
||||
kind: (element.getAttribute('data-kind') ?? 'text') as SigningFieldKind,
|
||||
required: element.getAttribute('data-required') === 'true',
|
||||
options: (element.getAttribute('data-options') ?? '').split('|').filter(Boolean),
|
||||
risk: element.getAttribute('data-risk') ?? undefined,
|
||||
}
|
||||
|
||||
element.replaceWith(buildFieldWidget(doc, field, answers, interactive))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +336,7 @@ function convertCheckboxes(root: Element, interactive: boolean, selected: Set<st
|
||||
|
||||
/** 收集所有含方框符号的文本节点 */
|
||||
function collectOptionTextNodes(root: Element): Text[] {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
const targets: Text[] = []
|
||||
let node = walker.nextNode()
|
||||
|
||||
@@ -192,14 +363,14 @@ function replaceOptionMarkers(
|
||||
selected: Set<string>,
|
||||
) {
|
||||
const parts = (textNode.textContent ?? '').split(OPTION_SPLIT_PATTERN)
|
||||
const fragment = document.createDocumentFragment()
|
||||
const fragment = textNode.ownerDocument.createDocumentFragment()
|
||||
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = parts[index] ?? ''
|
||||
|
||||
if (!OPTION_TEST_PATTERN.test(part)) {
|
||||
if (part) {
|
||||
fragment.appendChild(document.createTextNode(part))
|
||||
fragment.appendChild(textNode.ownerDocument.createTextNode(part))
|
||||
}
|
||||
|
||||
continue
|
||||
@@ -210,7 +381,7 @@ function replaceOptionMarkers(
|
||||
const label = labelMatch?.[1] ?? ''
|
||||
const tail = labelMatch?.[2] ?? ''
|
||||
|
||||
const input = document.createElement('input')
|
||||
const input = textNode.ownerDocument.createElement('input')
|
||||
input.type = type
|
||||
input.className = 'sign-doc-checkbox'
|
||||
input.disabled = !interactive
|
||||
@@ -227,15 +398,15 @@ function replaceOptionMarkers(
|
||||
input.value = label
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('label')
|
||||
const wrapper = textNode.ownerDocument.createElement('label')
|
||||
wrapper.className = 'sign-doc-option'
|
||||
wrapper.appendChild(input)
|
||||
wrapper.appendChild(document.createTextNode(label))
|
||||
wrapper.appendChild(textNode.ownerDocument.createTextNode(label))
|
||||
|
||||
fragment.appendChild(wrapper)
|
||||
|
||||
if (tail) {
|
||||
fragment.appendChild(document.createTextNode(tail))
|
||||
fragment.appendChild(textNode.ownerDocument.createTextNode(tail))
|
||||
}
|
||||
|
||||
// 选项名已随方框一起消费,跳过它对应的片段
|
||||
@@ -246,7 +417,7 @@ function replaceOptionMarkers(
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内选项识别:正文里手打的方框(□否 □是、□同意 □不同意)。
|
||||
* 行内选项识别(存量兼容):正文里手打的方框(□否 □是、□同意 □不同意)。
|
||||
* 同一段/同一单元格里出现两个及以上方框时按「二选一」处理成互斥单选;
|
||||
* 只有一个方框则保持多选语义,避免把「□高血压」这类单项确认误判成互斥组。
|
||||
*/
|
||||
@@ -286,7 +457,7 @@ function convertInlineOptions(root: Element, interactive: boolean, selected: Set
|
||||
/**
|
||||
* 生成签署态文书 HTML。
|
||||
* contentHtml 为空时返回空串;解析失败时原样返回,不会抛错。
|
||||
* interactive 为 false(预览场景)时复选框只读展示。
|
||||
* interactive 为 false(预览 / 已签署查看)时填写项只读展示取值。
|
||||
*/
|
||||
export function buildSigningDocumentHtml(
|
||||
contentHtml: string,
|
||||
@@ -307,19 +478,104 @@ export function buildSigningDocumentHtml(
|
||||
return contentHtml
|
||||
}
|
||||
|
||||
fillPatientPlaceholders(root, data)
|
||||
|
||||
if (data.signatureDataUrl) {
|
||||
replaceSignaturePlaceholder(root, data.signatureDataUrl)
|
||||
} else {
|
||||
replaceTextPlaceholder(root, SIGNATURE_PLACEHOLDER, '(待患者签署)')
|
||||
// 已填写的取值优先于系统快照:签署后被固化的填写项要保持原样回显
|
||||
const answers: SigningAnswers = {
|
||||
...resolveAnswersFromSystem(data),
|
||||
...(data.fieldAnswers ?? {}),
|
||||
}
|
||||
|
||||
// 已签署的文书再次打开时,把上次记录的勾选状态还原出来
|
||||
const selected = new Set(data.selectedItems ?? [])
|
||||
if (data.signatureDataUrl && !answers.signature) {
|
||||
answers.signature = data.signatureDataUrl
|
||||
}
|
||||
|
||||
renderStructuredFields(root, answers, interactive)
|
||||
renderTokenFields(root, answers, interactive)
|
||||
|
||||
const selected = new Set(data.selectedItems ?? [])
|
||||
convertCheckboxes(root, interactive, selected)
|
||||
convertInlineOptions(root, interactive, selected)
|
||||
|
||||
return root.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* 从渲染后的文书 DOM 里读回填写项取值。
|
||||
* 签名读的是签名图,其余按控件类型读,返回结果直接可作为签署证据的一部分固化。
|
||||
*/
|
||||
export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
|
||||
const answers: SigningAnswers = {}
|
||||
|
||||
root.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`).forEach((host) => {
|
||||
const id = host.getAttribute(FIELD_ATTR)
|
||||
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const kind = (host.getAttribute(FIELD_KIND_ATTR) ?? 'text') as SigningFieldKind
|
||||
|
||||
if (kind === 'signature') {
|
||||
answers[id] = host.querySelector('img')?.getAttribute('src') ?? ''
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'choice') {
|
||||
const checked = host.querySelector<HTMLInputElement>('input[type="radio"]:checked')
|
||||
answers[id] = checked?.value ?? ''
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'checkbox') {
|
||||
const box = host.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
answers[id] = Boolean(box?.checked)
|
||||
return
|
||||
}
|
||||
|
||||
answers[id] = host.querySelector<HTMLInputElement>('input')?.value ?? ''
|
||||
})
|
||||
|
||||
return answers
|
||||
}
|
||||
|
||||
/** 统计填写项进度:已填数量、未填的必填项、命中的异常项 */
|
||||
export function describeFieldProgress(
|
||||
root: HTMLElement,
|
||||
answers: SigningAnswers,
|
||||
): SigningFieldProgress {
|
||||
const progress: SigningFieldProgress = {
|
||||
total: 0,
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
}
|
||||
|
||||
root.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`).forEach((host) => {
|
||||
const id = host.getAttribute(FIELD_ATTR)
|
||||
const label = host.getAttribute(FIELD_LABEL_ATTR) ?? id ?? ''
|
||||
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = answers[id]
|
||||
const empty = value === undefined || value === '' || value === false
|
||||
const risk = host.getAttribute(FIELD_RISK_ATTR)
|
||||
const isSignature = host.getAttribute(FIELD_KIND_ATTR) === 'signature'
|
||||
|
||||
progress.total += 1
|
||||
|
||||
if (!empty) {
|
||||
progress.filled += 1
|
||||
} else if (host.getAttribute(FIELD_REQUIRED_ATTR) === 'true' && !isSignature) {
|
||||
// 签名必填但尚未采集,正是"点击签署"要完成的事,不能算作待补的必填项,
|
||||
// 否则签名入口会把自己挡住。
|
||||
progress.missingRequired.push(label)
|
||||
}
|
||||
|
||||
if (risk && value === risk) {
|
||||
progress.abnormal.push(label)
|
||||
}
|
||||
})
|
||||
|
||||
return progress
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 文书填写项(占位符)模型。
|
||||
*
|
||||
* 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义:
|
||||
* ① 稳定字段键(id):与 HIS / 患者快照的取值一一对应,不再依赖中文标签匹配;
|
||||
* ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项,
|
||||
* 签完把取值写回同一字段即可在文书原位置回显;
|
||||
* ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完;
|
||||
* ④ 风险值(risk):签署取值命中即标记为异常项。
|
||||
*
|
||||
* 载体仍沿用既有文书里的 {患者姓名} 写法,原因见 buildFieldToken 的注释:
|
||||
* 当前编辑器(WangEditor / Slate)无法无损往返带 data-* 属性的结构化节点,
|
||||
* 而纯文本占位符可以,且存量模板零迁移。渲染管线同时支持 span[data-field] 写法,
|
||||
* 后续换成支持结构化节点的编辑器时可以平滑升级。
|
||||
*/
|
||||
|
||||
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
|
||||
|
||||
/** 占位符书写格式:{患者姓名} */
|
||||
export const FIELD_TOKEN_OPEN = '{'
|
||||
export const FIELD_TOKEN_CLOSE = '}'
|
||||
|
||||
const FIELD_TOKEN_SOURCE = `${FIELD_TOKEN_OPEN}([^${FIELD_TOKEN_OPEN}${FIELD_TOKEN_CLOSE}]{1,40})${FIELD_TOKEN_CLOSE}`
|
||||
|
||||
/** /g 正则有 lastIndex 副作用,每次解析都新建一个,避免跨调用互相干扰。 */
|
||||
export function createFieldTokenPattern() {
|
||||
return new RegExp(FIELD_TOKEN_SOURCE, 'g')
|
||||
}
|
||||
|
||||
/**
|
||||
* 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。
|
||||
* id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
|
||||
*/
|
||||
export const SIGNING_FIELD_PRESETS: SigningFieldDef[] = [
|
||||
{ id: 'patient', label: '患者姓名', kind: 'text', required: true, options: [] },
|
||||
{ id: 'sex', label: '性别', kind: 'text', required: false, options: [] },
|
||||
{ id: 'age', label: '年龄', kind: 'text', required: false, options: [] },
|
||||
{ id: 'visit', label: '就诊号', kind: 'text', required: false, options: [] },
|
||||
{ id: 'phone', label: '联系电话', kind: 'text', required: false, options: [] },
|
||||
{ id: 'appointment', label: '预约日期', kind: 'date', required: false, options: [] },
|
||||
{ id: 'signedDate', label: '签署日期', kind: 'date', required: true, options: [] },
|
||||
{ id: 'relationship', label: '与患者关系', kind: 'text', required: false, options: [] },
|
||||
{ id: 'signature', label: '患者签名', kind: 'signature', required: true, options: [] },
|
||||
{ id: 'doctor', label: '医师签名', kind: 'signature', required: false, options: [] },
|
||||
]
|
||||
|
||||
/**
|
||||
* 同一字段在不同文书里的常见写法。
|
||||
* 各科室历史文书用词不统一(患者签名 / 患方签名 / 患者(或家属)签名),
|
||||
* 归一到同一个字段键,取值才不会因为换了叫法就填不上。
|
||||
*/
|
||||
const FIELD_LABEL_ALIASES: Record<string, string> = {
|
||||
'患者(或家属)签名': 'signature',
|
||||
'患者/家属签名': 'signature',
|
||||
患方签名: 'signature',
|
||||
'患者/监护人签字': 'signature',
|
||||
签字: 'signature',
|
||||
'住院号/门诊号': 'visit',
|
||||
'门诊/住院号': 'visit',
|
||||
门诊号: 'visit',
|
||||
住院号: 'visit',
|
||||
手机号: 'phone',
|
||||
预约日期: 'appointment',
|
||||
日期: 'signedDate',
|
||||
签名日期: 'signedDate',
|
||||
}
|
||||
|
||||
const PRESET_BY_ID = new Map(SIGNING_FIELD_PRESETS.map((field) => [field.id, field]))
|
||||
const PRESET_BY_LABEL = new Map(SIGNING_FIELD_PRESETS.map((field) => [field.label, field]))
|
||||
|
||||
/** 自定义占位符的字段键前缀:label 不在目录里时按文本填写项处理 */
|
||||
const CUSTOM_FIELD_PREFIX = 'custom:'
|
||||
|
||||
export function getPresetField(id: string): SigningFieldDef | null {
|
||||
const preset = PRESET_BY_ID.get(id)
|
||||
return preset ? { ...preset } : null
|
||||
}
|
||||
|
||||
export function createCustomField(
|
||||
label: string,
|
||||
kind: SigningFieldKind = 'text',
|
||||
options: string[] = [],
|
||||
): SigningFieldDef {
|
||||
return {
|
||||
id: `${CUSTOM_FIELD_PREFIX}${label}`,
|
||||
label,
|
||||
kind,
|
||||
required: false,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按占位符文字定位字段定义:先查目录,再查别名,都不是就当自定义文本项。
|
||||
* 这样存量文书里写的是「患者(或家属)签名」也能命中签名控件。
|
||||
*/
|
||||
export function resolveFieldByLabel(label: string): SigningFieldDef {
|
||||
const trimmed = label.trim()
|
||||
const preset = PRESET_BY_LABEL.get(trimmed)
|
||||
if (preset) {
|
||||
return { ...preset }
|
||||
}
|
||||
|
||||
const aliasId = FIELD_LABEL_ALIASES[trimmed]
|
||||
if (aliasId) {
|
||||
const aliased = PRESET_BY_ID.get(aliasId)
|
||||
if (aliased) {
|
||||
return { ...aliased }
|
||||
}
|
||||
}
|
||||
|
||||
return createCustomField(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成占位符文本。
|
||||
*
|
||||
* 用纯文本而不是 <span data-field>:WangEditor 是 Slate 模型,插入的 HTML 会被
|
||||
* 重新解析,未注册的自定义元素会退化成纯文本、data-* 属性全部丢失,
|
||||
* 导致「保存后再打开,占位符已经不是占位符」。纯文本可以无损往返,
|
||||
* 且不需要为编辑器注册自定义节点,存量模板也不需要迁移。
|
||||
*/
|
||||
export function buildFieldToken(field: SigningFieldDef): string {
|
||||
return `${FIELD_TOKEN_OPEN}${field.label}${FIELD_TOKEN_CLOSE}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文书 HTML 里的全部占位符,按出现顺序去重。
|
||||
* 同一个字段在文书里出现多次(如签名与日期在每页都出现)只算一个填写项。
|
||||
*/
|
||||
export function collectFieldDefs(contentHtml: string): SigningFieldDef[] {
|
||||
if (!contentHtml) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const fields: SigningFieldDef[] = []
|
||||
const pattern = createFieldTokenPattern()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(contentHtml)) !== null) {
|
||||
const field = resolveFieldByLabel(match[1] ?? '')
|
||||
if (!field.label || seen.has(field.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(field.id)
|
||||
fields.push(field)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/** 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复) */
|
||||
export function findDuplicateFields(contentHtml: string): SigningFieldDef[] {
|
||||
if (!contentHtml) {
|
||||
return []
|
||||
}
|
||||
|
||||
const counts = new Map<string, { field: SigningFieldDef; count: number }>()
|
||||
const pattern = createFieldTokenPattern()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(contentHtml)) !== null) {
|
||||
const field = resolveFieldByLabel(match[1] ?? '')
|
||||
const entry = counts.get(field.id)
|
||||
counts.set(field.id, { field, count: (entry?.count ?? 0) + 1 })
|
||||
}
|
||||
|
||||
return [...counts.values()].filter((item) => item.count > 1).map((item) => item.field)
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统侧取值来源(HIS / 患者与就诊快照)。
|
||||
* 字段键与目录里的 id 对应,是「自动填充」的唯一入口。
|
||||
*/
|
||||
export interface SigningSystemSnapshot {
|
||||
patientName?: string
|
||||
sex?: string
|
||||
age?: number | string
|
||||
visitNo?: string
|
||||
visitDate?: string
|
||||
phone?: string
|
||||
doctorName?: string
|
||||
signedAt?: string
|
||||
signatureDataUrl?: string
|
||||
}
|
||||
|
||||
/** 把患者/就诊快照映射成填写项初值,字段键即 SIGNING_FIELD_PRESETS 的 id。 */
|
||||
export function resolveAnswersFromSystem(snapshot: SigningSystemSnapshot): SigningAnswers {
|
||||
return {
|
||||
patient: snapshot.patientName ?? '',
|
||||
sex: snapshot.sex ?? '',
|
||||
age: snapshot.age === undefined || snapshot.age === null ? '' : String(snapshot.age),
|
||||
visit: snapshot.visitNo ?? '',
|
||||
phone: snapshot.phone ?? '',
|
||||
appointment: snapshot.visitDate ?? '',
|
||||
// 签署日期是必填项,未签署前先给当天日期,符合"系统预填 + 现场确认"的业务预期;
|
||||
// 真正确认的日期以签署完成时固化的取值(signedAt)为准
|
||||
signedDate: (snapshot.signedAt ?? new Date().toISOString()).slice(0, 10),
|
||||
signature: snapshot.signatureDataUrl ?? '',
|
||||
// 医师签名与代理人关系必须由现场填写,不能由系统代填;
|
||||
// 上一版把「与患者关系」恒填「本人」,等于把代理人签署这条路堵死了。
|
||||
relationship: '',
|
||||
doctor: '',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user