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
+207
View File
@@ -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: '',
}
}