feat(签署文书): 通用文本项按出现位置独立成项并插入文本框占位符

This commit is contained in:
yelan
2026-09-18 13:30:06 +08:00
parent d2cd3f2489
commit 11fe83d807
5 changed files with 184 additions and 47 deletions
+5 -2
View File
@@ -129,11 +129,14 @@ ClinicalLayout
科室在文档库的 Word 编辑器中维护文书,签署时需要交互化的内容按下面的写法即可,系统在签署和「签署预览」时自动转换:
- 占位符:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}`、`{医师签名}` 等,由签署任务的患者与就诊快照填充;
- 占位符分两类,写法都是 `{…}`:
- **系统预填项**:`{患者姓名}`、`{性别}`、`{年龄}`、`{就诊号}`、`{签署日期}`、`{患者签名}`、`{医师签名}` 等,字段键与取值来源固定,由签署任务的患者与就诊快照填充,目录见 `utils/signing-fields.ts` 的 `SIGNING_FIELD_PRESETS`;
- **通用文本项**:现场手填的输入框,默认写 `{文本框}`,签署时渲染成一个空文本框。**每个占位符各自是一个填写项**,字段键按出现位置编号(`custom:名称`、第 2 个起是 `custom:名称#2`……),所以同一份文书里放几个文本框就是几份取值,不需要为了区分而改名字;名称只决定文书里显示的文字,直接把正文里的「文本框」改成「与患者关系」「受托人姓名」即可(仅为可读性,不影响取值归属)。
- 只有上面那类系统预填项按字段键去重、同名共用一份取值——页眉的患者姓名、每页的签名位反复出现,取值本来就该是同一个。
- 勾选表:做成表格,第一行表头写「勾选」,另设一列写「项目」作为勾选条目名,签署时「勾选」列每个单元格变成复选框;
- 行内选项:正文里直接写方框符号,如 `□否 □是`、`□同意 □不同意`。同一段落或同一单元格内出现两个及以上方框时按二选一(互斥单选)处理,只有一个方框时保持独立复选框语义。
编辑器弹窗顶部提供「插入勾选框」「二选一」按钮,光标停在表格单元格内时插入内容会落进该单元格,不需要科室自己找符号面板。导入(.docx / 粘贴)阶段只做格式转换,不做勾选识别;识别统一在签署渲染时进行。
编辑器弹窗顶部提供「插入勾选框」「二选一」按钮,以及插入通用文本项的「文本框」按钮(虚线圈出的那一个,点一下插入 `{文本框}`,把「文本框」三个字改成实际名称即可)。光标停在表格单元格内时插入内容会落进该单元格,不需要科室自己找符号面板。导入(.docx / 粘贴)阶段只做格式转换,不做勾选识别;识别统一在签署渲染时进行。
识别规则集中在 `utils/signing-document.ts`,调整后签署端与「签署预览」会同步生效。
+13 -18
View File
@@ -18,11 +18,10 @@
import type { SigningAnswers, SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
import {
createFieldTokenPattern,
FIELD_TOKEN_OPEN,
PRIMARY_SIGNATURE_FIELD_ID,
resolveAnswersFromSystem,
resolveFieldByLabel,
scanFieldTokens,
SIGNING_DOCUMENT_HEADER,
SIGNING_OPTION_MARKERS,
type SigningSystemSnapshot,
@@ -260,6 +259,11 @@ function buildFieldControl(
* 占位符渲染:把 {患者姓名} 就地替换成填写项控件,保留同一文本节点内的前后文字。
* 模板未收录的占位符按自定义文本项处理,仍可在文书上填写,
* 避免出现"写了占位符但签署时既填不上也看不见"的情况。
*
* 解析统一走 scanFieldTokens,且计数器跨文本节点共用一个:
* 自定义文本项靠"同名占位符第几次出现"编号,这里数出的序号必须和
* 填写项统计(collectFieldDefs)数出来的完全一致,否则同一处内容
* 在面板里是一个字段键、落到 DOM 上是另一个,取值就悄悄对不上了。
*/
function renderTokenFields(
root: Element,
@@ -270,6 +274,7 @@ function renderTokenFields(
const doc = root.ownerDocument
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const targets: Text[] = []
const counters = new Map<string, number>()
let node = walker.nextNode()
while (node) {
@@ -282,28 +287,18 @@ function renderTokenFields(
targets.forEach((textNode) => {
const text = textNode.textContent ?? ''
const pattern = createFieldTokenPattern()
const fragment = doc.createDocumentFragment()
let cursor = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) {
fragment.appendChild(doc.createTextNode(text.slice(cursor, match.index)))
scanFieldTokens(text, counters).forEach(({ start, end, field }) => {
if (start > cursor) {
fragment.appendChild(doc.createTextNode(text.slice(cursor, start)))
}
fragment.appendChild(
buildFieldWidget(
doc,
resolveFieldByLabel(match[1] ?? ''),
answers,
interactive,
signatureActionable,
),
)
fragment.appendChild(buildFieldWidget(doc, field, answers, interactive, signatureActionable))
cursor = pattern.lastIndex
}
cursor = end
})
if (cursor < text.length) {
fragment.appendChild(doc.createTextNode(text.slice(cursor)))
@@ -30,6 +30,7 @@ import type { IEditorConfig } from '@wangeditor/editor'
import {
createFieldTokenPattern,
isCustomFieldId,
resolveFieldByLabel,
SIGNING_FIELD_KIND_LABELS,
SIGNING_OPTION_MARKERS,
@@ -57,6 +58,8 @@ interface SigningLeafMark {
/** 控件类型的中文名,渲染在胶囊右侧 */
signTag?: string
signRequired?: boolean
/** 是否目录外的自定义文本项(现场手填),决定悬浮提示里说"自动填充"还是"现场填写" */
signCustom?: boolean
/** 原文,如 {患者姓名},用于悬浮提示 */
signRaw?: string
}
@@ -97,6 +100,7 @@ export function decorateSigningMarkers([node, path]: DecoratedEntry): DecoratedR
signKind: field.kind,
signTag: SIGNING_FIELD_KIND_LABELS[field.kind] ?? '文本',
signRequired: field.required,
signCustom: isCustomFieldId(field.id),
signRaw: text.slice(start, end),
}
@@ -177,8 +181,11 @@ function renderSigningMarker(node: StyledNode, vnode: StyledVNode): StyledVNode
if (mark.signPart === 'label') {
const kind = mark.signTag ?? '文本'
// 提示必须分开说:目录里的字段签署时自动填充,自定义文本项是留给现场手填的空框。
// 一律写成"自动填充",科室会以为写了占位符就有人替他填。
const source = mark.signCustom ? '签署时由患者/家属现场填写' : '签署时按患者与就诊信息自动填充'
props.className = 'ms-sign-chip'
props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),签署时按患者与就诊信息自动填充`
props.title = `待填写项:${mark.signRaw ?? ''}(${kind}${mark.signRequired ? ',必填' : ''}),${source}`
if (mark.signTag) {
dataset.signTag = mark.signTag
+123 -22
View File
@@ -2,7 +2,8 @@
* 文书填写项(占位符)模型。
*
* 相比「把占位符当普通文字做字符串替换」,这里给每个占位符补上语义:
* ① 稳定字段键(id):与 HIS / 患者快照的取值一一对应,不再依赖中文标签匹配;
* ① 稳定字段键(id):目录里的字段与 HIS / 患者快照的取值一一对应,不依赖中文标签匹配;
* 目录外的自定义文本项按出现位置编号(custom:名称#第几次),两处文本框就是两份取值;
* ② 控件类型(kind):签名是与文本/日期/单选/勾选并列的一种填写项,
* 签完把取值写回同一字段即可在文书原位置回显;
* ③ 必填与选项:模板保存时可做去重校验,签署时可校验必填项是否填完;
@@ -64,6 +65,12 @@ export const PRIMARY_SIGNATURE_FIELD_ID = 'signature'
/**
* 填写项目录:key 为字段键,label 是文书里显示的文字,kind 决定签署时的控件。
* id 与 label 的对应关系由这里统一维护,渲染端与编辑器共用同一份,避免两处各自演化。
*
* 收录标准只有一条:**取值有固定来源**。这里的每一项都能按 id 从 HIS / 患者就诊快照
* 或签署动作里取到值(见 resolveAnswersFromSystem),所以字段键必须稳定、不能随叫法变。
* 现场手填的文本框没有系统取值来源,不需要固定字段键,一律走自定义文本项
* (createCustomField + buildGenericTextToken),不再为「与患者关系」这类写法逐条登记——
* 逐条登记既列不全科室的叫法,也会让同一个输入框因写法不同而落到两个字段键上。
*/
export const SIGNING_FIELD_PRESETS: SigningFieldDef[] = [
{ id: 'patient', label: '患者姓名', kind: 'text', required: true, options: [] },
@@ -73,7 +80,6 @@ export const SIGNING_FIELD_PRESETS: SigningFieldDef[] = [
{ 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: [] },
]
@@ -136,7 +142,10 @@ const FIELD_LABEL_ALIASES: Record<string, string> = {
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 不在目录里时按文本填写项处理 */
/**
* 自定义占位符的字段键前缀:label 不在目录里时按文本填写项处理。
* 键 = `custom:名称`,同名占位符第 2 个起再接 `#出现次序`——见 resolveFieldByLabel。
*/
const CUSTOM_FIELD_PREFIX = 'custom:'
export function getPresetField(id: string): SigningFieldDef | null {
@@ -148,9 +157,11 @@ export function createCustomField(
label: string,
kind: SigningFieldKind = 'text',
options: string[] = [],
/** 同名占位符在文书里的出现次序,从 1 开始;见 resolveFieldByLabel */
sequence = 1,
): SigningFieldDef {
return {
id: `${CUSTOM_FIELD_PREFIX}${label}`,
id: `${CUSTOM_FIELD_PREFIX}${label}${sequence > 1 ? `#${sequence}` : ''}`,
label,
kind,
required: false,
@@ -158,11 +169,54 @@ export function createCustomField(
}
}
/** 判断字段键是否来自目录之外的自定义文本项(现场手填的输入框) */
export function isCustomFieldId(id: string): boolean {
return id.startsWith(CUSTOM_FIELD_PREFIX)
}
/**
* 通用文本项的默认名称(占位符 {文本框})。名称只决定文书里显示的文字,不决定它是哪个字段。
*
* 为什么留一个通用入口,而不是逐个登记「与患者关系」「受托人姓名」:
* 这类内容是现场手填的输入框,没有系统取值来源,叫法又千变万化(代理文书里
* 还有「受托人姓名」「代理人身份证号」……),在目录里逐条登记既列不全,
* 也会让同一个输入框因叫法不同落到两个字段键上。
*
* 名称也不负责区分"是不是同一个填写项":文本框按出现位置各自成项(见 resolveFieldByLabel),
* 所以一份文书里的两个文本框天然就是两份取值,不需要为了区分而改名字——
* 改名只是让文书写得像文书(「文本框」改成「与患者关系」)。
*
* 默认名不叫「文本」是为了在编辑器里能认出来:胶囊的角标已经写着「文本」,
* 名称再叫「文本」会显示成「文本 文本」,分不清哪个是名字哪个是类型。
*/
export const GENERIC_TEXT_FIELD_LABEL = '文本框'
/**
* 生成通用文本项的占位符,默认 {文本框}。
*
* 走的是目录外的自定义文本项:resolveFieldByLabel 查不到目录就按文本控件处理,
* 签署时渲染成一个输入框,取值落在 fieldAnswers 里。
*/
export function buildGenericTextToken(label: string = GENERIC_TEXT_FIELD_LABEL): string {
return buildFieldToken(createCustomField(label.trim()))
}
/**
* 按占位符文字定位字段定义:先查目录,再查别名,都不是就当自定义文本项。
* 这样存量文书里写的是「患者(或家属)签名」也能命中签名控件。
*
* sequence 是**同名占位符在本文书里的第几次出现**(从 1 开始),只有自定义文本项用得上:
*
* - 目录里的字段按 id 去重,同名共用一份取值是刻意的——页眉的患者姓名、每页的签名位
* 反复出现,取值只能是同一个;
* - 自定义文本项没有取值来源,两个 {文本框} 是两处要填的内容,共用一个值才是错的。
* 而占位符是纯文本,正文里只有文字可依据,所以用"第几次出现"给它们各自编号:
* 第 1 个 custom:文本框,第 2 个 custom:文本框#2……位置就是它们的身份。
*
* 由此得到一条对科室更省心的规则:**改名字不影响取值归属**,名字纯粹是给人看的。
* 前提是模板内容定稿后就冻结(发起签署时会快照正文),位置编号才不会漂移。
*/
export function resolveFieldByLabel(label: string): SigningFieldDef {
export function resolveFieldByLabel(label: string, sequence = 1): SigningFieldDef {
const trimmed = label.trim()
const preset = PRESET_BY_LABEL.get(trimmed)
if (preset) {
@@ -177,7 +231,50 @@ export function resolveFieldByLabel(label: string): SigningFieldDef {
}
}
return createCustomField(trimmed)
return createCustomField(trimmed, 'text', [], sequence)
}
/** 扫描结果:占位符在文本里的位置 + 它对应的字段定义 */
export interface ScannedFieldToken {
start: number
end: number
field: SigningFieldDef
}
/**
* 扫描一段文本里的占位符并解析成字段定义。
*
* counters 必须**跨段传递**(同一份文书的每个文本节点/整段 HTML 共用一个):
* 自定义文本项靠"同名占位符第几次出现"编号,分段扫描时如果各自从 1 数起,
* 编辑器面板统计出的字段键就会和签署渲染时写进 DOM 的键对不上,
* 表现是填写项统计里有这一项、签署时却收集不到值。
*
* 之所以要有个统一的扫描函数,就是因为这条编号规则被三处用到
* (填写项统计 collectFieldDefs、重复检查 findDuplicateFields、签署渲染 renderTokenFields),
* 三处各写一份计时器迟早会漂移。
*/
export function scanFieldTokens(text: string, counters: Map<string, number>): ScannedFieldToken[] {
const tokens: ScannedFieldToken[] = []
const pattern = createFieldTokenPattern()
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const label = (match[1] ?? '').trim()
if (!label) {
continue
}
const sequence = (counters.get(label) ?? 0) + 1
counters.set(label, sequence)
tokens.push({
start: match.index,
end: pattern.lastIndex,
field: resolveFieldByLabel(label, sequence),
})
}
return tokens
}
/**
@@ -194,7 +291,10 @@ export function buildFieldToken(field: SigningFieldDef): string {
/**
* 解析文书 HTML 里的全部占位符,按出现顺序去重。
* 同一个字段在文书里出现多次(如签名与日期在每页都出现)只算一个填写项。
*
* 「去重」只对目录里的字段生效:同一个字段在文书里出现多次(如签名与日期在每页都出现)
* 只算一个填写项,它们共用一份取值。自定义文本项按出现位置各自成项(见 resolveFieldByLabel),
* 所以两份 {文本框} 会得到两个填写项,不会被合成一个。
*/
export function collectFieldDefs(contentHtml: string): SigningFieldDef[] {
if (!contentHtml) {
@@ -203,37 +303,37 @@ export function collectFieldDefs(contentHtml: string): SigningFieldDef[] {
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] ?? '')
scanFieldTokens(contentHtml, new Map()).forEach(({ field }) => {
if (!field.label || seen.has(field.id)) {
continue
return
}
seen.add(field.id)
fields.push(field)
}
})
return fields
}
/** 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复) */
/**
* 同一占位符在文书里写了多次的字段(保存模板时提示,通常是误重复)。
*
* 这里**只会命中目录里的字段**:自定义文本项按出现位置编号,每次出现的字段键都不同,
* 计数永远到不了 2。这是对的——两处 {文本框} 是两处要填的内容,
* 不是"同一个字段写重复了",不该提示科室去合并。
*/
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] ?? '')
scanFieldTokens(contentHtml, new Map()).forEach(({ field }) => {
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)
}
@@ -267,9 +367,10 @@ export function resolveAnswersFromSystem(snapshot: SigningSystemSnapshot): Signi
// 真正确认的日期以签署完成时固化的取值(signedAt)为准
signedDate: (snapshot.signedAt ?? new Date().toISOString()).slice(0, 10),
signature: snapshot.signatureDataUrl ?? '',
// 医师签名与代理人关系必须由现场填写,不能由系统代填;
// 上一版把「与患者关系」恒填「本人」,等于把代理人签署这条路堵死了。
relationship: '',
// 医师签名必须由现场采集,不能由系统代填。
// 同理,与患者关系、受托人姓名这类现场手填的文本框是自定义文本项
// (见 createCustomField),没有系统初值——把「与患者关系」恒填「本人」
// 等于把代理人签署这条路堵死了,所以这一项连空值都不在这里预置。
doctor: '',
}
}
@@ -5,8 +5,10 @@ import { ElMessage } from 'element-plus'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import {
buildFieldToken,
buildGenericTextToken,
collectFieldDefs,
findDuplicateFields,
GENERIC_TEXT_FIELD_LABEL,
SIGNING_FIELD_KIND_LABELS,
SIGNING_FIELD_PRESETS,
} from '@/utils/signing-fields'
@@ -43,9 +45,12 @@ const importing = ref(false)
const importMessage = ref('')
const paneRef = ref<EditorPaneHandle | null>(null)
// 占位符在签署时按字段键取 HIS 与患者就诊信息自动填充,其中签名是独立控件类型:
// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字。
// 目录见 utils/signing-fields 的 SIGNING_FIELD_PRESETS,与签署端渲染共用同一份。
// 占位符分两类:
// ① 目录里的字段(utils/signing-fields 的 SIGNING_FIELD_PRESETS):取值有固定来源,
// 签署时按字段键取 HIS / 患者就诊快照自动填充,其中签名是独立控件类型——
// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字;
// ② 通用文本项(现场手填的输入框,如与患者关系、受托人姓名):没有系统取值来源,
// 默认写 {文本框},签署时渲染成一个空文本框,名称在正文里改即可,见 insertGenericTextField。
const form = reactive<TemplateEditorForm>({
templateCode: '',
@@ -113,6 +118,17 @@ function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) {
insertContent(buildFieldToken(field))
}
/**
* 插入通用文本项:现场手填的输入框,默认 {文本框}。
*
* 每插一个就是一个独立填写项(字段键按出现位置编号,见 signing-fields 的 resolveFieldByLabel),
* 所以两处文本框天然是两份取值,不需要为了区分而改名字。
* 名称只决定文书里显示的文字:改成「与患者关系」是为了读起来像文书,不影响取值归属。
*/
function insertGenericTextField() {
insertContent(buildGenericTextToken())
}
/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */
const detectedFields = computed(() => collectFieldDefs(valueHtml.value))
@@ -284,6 +300,14 @@ function save() {
>
{{ field.label }}{{ field.required ? ' *' : '' }}
</button>
<button
type="button"
class="placeholder-chip is-generic"
:title="`插入一个文本输入框(占位符 ${GENERIC_TEXT_FIELD_LABEL}),签署时由患者/家属现场填写。插几个就是几个独立填写项,各填各的;名称只决定显示的文字,可在正文里直接改(如改成「与患者关系」)`"
@click="insertGenericTextField()"
>
{{ GENERIC_TEXT_FIELD_LABEL }}
</button>
</div>
</div>
<div class="meta-field">
@@ -344,7 +368,8 @@ function save() {
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
<span v-else class="docx-hint">
建议从 Word/WPS
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载;
需要患者现场填写的内容(与患者关系、受托人姓名等)点「文本框」插入输入框占位符,插几个就是几处独立填写、各填各的;把名称改成实际叫法只是为了读起来像文书。
占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。
</span>
</div>
@@ -514,6 +539,12 @@ function save() {
background: #fff4e2;
}
/* 通用文本项:虚线圈住,和左边一排系统预填字段区分开——
它不是某个固定字段,插到哪就是一个现场手填的输入框,名称可以在正文里随便改 */
.placeholder-chip.is-generic {
border: 1px dashed var(--brand);
}
.placeholder-check {
padding: 10px 18px 0;
}