feat(signing): 将签署占位符改为原子节点并支持多选选项组

This commit is contained in:
yelan
2026-09-24 12:13:24 +08:00
parent ec24e85829
commit 9a6ac93e21
13 changed files with 2141 additions and 121 deletions
@@ -3,14 +3,15 @@ import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import type { SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
import {
buildFieldToken,
buildGenericTextToken,
allocateCustomFieldId,
collectFieldDefs,
findDuplicateFields,
GENERIC_TEXT_FIELD_LABEL,
SIGNING_FIELD_KIND_LABELS,
SIGNING_FIELD_PRESETS,
upgradeFieldTokensToStructured,
} from '@/utils/signing-fields'
import EditorWangPane from './editors/EditorWangPane.vue'
@@ -39,10 +40,16 @@ const savePermission = computed(() =>
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
// 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件,
// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste)。
// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste),
// 并把占位符的元素节点与渲染钩子重新注册一遍(见 utils/signing-field-node)。
interface EditorPaneHandle {
getText: () => string
insertText: (text: string) => void
insertField: (field: SigningFieldDef) => void
/** 就地改写某个字段的占位符,返回命中的节点数;0 表示正文里已经没有它 */
updateField: (fieldId: string, patch: Partial<Omit<SigningFieldDef, 'id'>>) => number
/** 就地删掉某个字段的占位符,返回命中的节点数 */
removeField: (fieldId: string) => number
}
const valueHtml = ref('<p><br></p>')
@@ -50,13 +57,20 @@ const docxInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
const importMessage = ref('')
const paneRef = ref<EditorPaneHandle | null>(null)
/** 打开时若把旧式 {…} 占位符升级成了原子占位符,这里放一句说明(不静默改内容) */
const upgradeNotice = ref('')
/** 正在配置的填写项字段键;空串表示没有选中 */
const selectedFieldId = ref('')
// 占位符分两类:
// ① 目录里的字段(utils/signing-fields 的 SIGNING_FIELD_PRESETS):取值有固定来源,
// 签署时按字段键取 HIS / 患者就诊快照自动填充,其中签名是独立控件类型——
// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字;
// ② 通用文本项(现场手填的输入框,如与患者关系、受托人姓名):没有系统取值来源,
// 默认写 {文本框},签署时渲染成一个空文本框,名称在正文里改即可,见 insertGenericTextField。
// 默认写「文本框」,签署时渲染成一个空文本框,名称在配置面板里改即可。
//
// 两类插入的都是**结构化占位符元素节点**(正文里存成 <span data-field>):
// 点一下即整块选中、一次退格整块删除,字段键 / 类型 / 必填 / 选项 / 多选都在元素上。
const form = reactive<TemplateEditorForm>({
templateCode: '',
@@ -96,9 +110,24 @@ watch(
versionNo: template?.version ?? 'v1',
contentHtml: template?.contentHtml ?? '',
})
valueHtml.value = template?.contentHtml || '<p><br></p>'
// 存量正文里的占位符是纯文本 {患者姓名},删一个要按五六次退格。
// 打开时统一升级成结构化占位符(原子节点、一次退格整块删除)。
// 升级沿用同一套解析口径,字段键前后完全一致,历史 fieldAnswers 不受影响;
// 正文里没有 { 时这一步原样返回,不会白白做一次 DOM 往返。
const rawContent = template?.contentHtml || '<p><br></p>'
const upgradedContent = upgradeFieldTokensToStructured(rawContent)
upgradeNotice.value =
upgradedContent === rawContent
? ''
: '已把正文里的旧式 {…} 占位符升级为可整块删除的占位符(字段口径不变)。'
form.contentHtml = upgradedContent
valueHtml.value = upgradedContent
syncDepartmentOptions()
importMessage.value = ''
selectedFieldId.value = ''
},
{ immediate: true },
)
@@ -120,24 +149,159 @@ function insertContent(text: string) {
paneRef.value?.insertText(text)
}
function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) {
insertContent(buildFieldToken(field))
/**
* 插入目录里的字段(患者姓名、签名、日期……)。
*
* 进的是**结构化占位符元素节点**,不是一段文字:点一下即整块选中,
* 一次退格整块删除;字段键与类型随节点走,不靠"第几次出现"推断。
*/
function insertField(field: SigningFieldDef) {
paneRef.value?.insertField(field)
}
/**
* 插入通用文本项:现场手填的输入框,默认 {文本框}。
* 插入通用文本项:现场手填的输入框,默认叫「文本框」。
*
* 每插一个就是一个独立填写项(字段键按出现位置编号,见 signing-fields 的 resolveFieldByLabel),
* 所以两处文本框天然是两份取值,不需要为了区分而改名字。
* 名称只决定文书里显示的文字:改成「与患者关系」是为了读起来像文书,不影响取值归属。
* 每插一个就是一个独立填写项,所以字段键必须**当场取一个没被占用的**
* (见 allocateCustomFieldId)——两个文本框是两处要填的内容,共用一个键才是错的。
* 名称只决定文书里显示的文字:改成「与患者关系」只是为了读起来像文书,
* 不影响它是哪个字段,也不影响历史取值。
*/
function insertGenericTextField() {
insertContent(buildGenericTextToken())
const label = GENERIC_TEXT_FIELD_LABEL
paneRef.value?.insertField({
id: allocateCustomFieldId(valueHtml.value, label),
label,
kind: 'text',
required: false,
options: [],
})
}
/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */
/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏,并点进去改配置 */
const detectedFields = computed(() => collectFieldDefs(valueHtml.value))
/** 正在配置的那一项(从 detectedFields 里现取,保证面板显示的就是正文里的口径) */
const selectedField = computed(
() => detectedFields.value.find((field) => field.id === selectedFieldId.value) ?? null,
)
/**
* 配置草稿。
*
* 与 selectedField 分开保存:面板上改到一半、还没点「应用修改」时,
* 正文(valueHtml)不能跟着变——那会让编辑区每次敲键盘都重排一次。
*/
const fieldDraft = reactive({
label: '',
kind: 'text' as SigningFieldKind,
required: false,
optionsText: '',
multi: false,
risk: '',
})
function syncDraft(field: SigningFieldDef | null) {
fieldDraft.label = field?.label ?? ''
fieldDraft.kind = field?.kind ?? 'text'
fieldDraft.required = field?.required ?? false
// 选项用顿号展示:科室更习惯「同意、不同意」这种写法,逗号也能识别
fieldDraft.optionsText = (field?.options ?? []).join('、')
// 缺省口径与 signing-fields 的 SigningFieldDef.multi 一致:勾选类默认可多选
fieldDraft.multi = field?.multi ?? field?.kind === 'checkbox'
fieldDraft.risk = field?.risk ?? ''
}
// 只在"选中项变了"时对齐草稿。不要 watch selectedField —— 它是 computed,
// 正文每敲一个字都会重算并换掉对象引用,那样会把面板上没提交的编辑冲掉。
watch(
() => selectedFieldId.value,
(fieldId) => {
syncDraft(detectedFields.value.find((field) => field.id === fieldId) ?? null)
},
{ immediate: true },
)
/** 点正文里的胶囊:直接选中并刷新草稿(同一个胶囊连点两次也应把草稿复原) */
function selectFieldFromEditor(field: SigningFieldDef) {
selectedFieldId.value = field.id
syncDraft(field)
}
/** 点列表项:再点一次收起,也顺带把草稿复原 */
function toggleField(fieldId: string) {
selectedFieldId.value = selectedFieldId.value === fieldId ? '' : fieldId
}
/** 选项文本 → 选项数组。顿号、中英文逗号、竖线都当分隔符(竖线与 data-options 口径一致) */
function parseOptions(text: string): string[] {
return text
.split(/[、,,|]/)
.map((option) => option.trim())
.filter(Boolean)
}
function applyFieldDraft() {
const field = selectedField.value
if (!field) {
return
}
const label = fieldDraft.label.trim()
if (!label) {
ElMessage.error('填写项名称不能为空')
return
}
const needsOptions = fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox'
const options = needsOptions ? parseOptions(fieldDraft.optionsText) : []
if (needsOptions && !options.length) {
ElMessage.error('单选 / 勾选至少要有一个选项')
return
}
// 字段键(field.id)刻意保持不变:id 是取值的归属,改叫法不该让历史取值对不上。
// 目录外的自定义文本项也一样——它的键里虽然带着旧名字,但那只是个内部标识。
//
// 改的是**编辑器模型**而不是 valueHtml 字符串:字符串往返要经过 setHtml,
// 会重建整篇文档——用户此刻的光标还停在正文里,旧选区在新 DOM 上找不到位置就抛异常,
// 而且光标也会丢。改模型两个问题都没有。
const changed = paneRef.value?.updateField(field.id, {
label,
kind: fieldDraft.kind,
required: fieldDraft.required,
options,
multi: options.length ? fieldDraft.multi : undefined,
risk: fieldDraft.risk.trim() || undefined,
})
if (!changed) {
ElMessage.warning(`正文里已经没有「${field.label}」了,请重新选择要修改的填写项`)
selectedFieldId.value = ''
return
}
ElMessage.success(`已更新「${label}」(正文里 ${changed} 处)`)
}
function removeField() {
const field = selectedField.value
if (!field) {
return
}
// 整项作废:正文里它的每一处出现都删掉(签名位在每页都出现是常态)
const changed = paneRef.value?.removeField(field.id) ?? 0
selectedFieldId.value = ''
ElMessage.success(`已删除「${field.label}」(正文里 ${changed} 处)`)
}
const placeholderHints = computed(() => {
const hints: string[] = []
const ids = new Set(detectedFields.value.map((field) => field.id))
@@ -179,6 +343,10 @@ async function handleDocxChange(event: Event) {
const arrayBuffer = await file.arrayBuffer()
const mammoth = await import('mammoth/mammoth.browser')
const result = await mammoth.convertToHtml({ arrayBuffer })
// 已知问题(既有,与本次占位符改动无关):编辑器已有光标时这一句会让 wangEditor 抛一条
// 未捕获的 promise 异常——setHtml 内部会 e.focus(),把缓存的上次选区装回去,
// 新正文的节点还没绑上 DOM 就先做了一次 DOM 同步。
// 正文仍然会正常导入(异常发生在同步选区那一步),要彻底修需要重建编辑器实例,另行处理。
valueHtml.value = result.value || '<p><br></p>'
importMessage.value = result.messages.length
? `已导入,${result.messages.length} 条转换提示(部分 Word 特性可能未还原)`
@@ -335,18 +503,81 @@ function save() {
<div class="placeholder-check">
<div class="placeholder-check__head">
<strong>占位符检查</strong>
<span>{{ detectedFields.length }} 个填写项</span>
<strong>填写项</strong>
<span>{{ detectedFields.length }} 个</span>
<span v-if="upgradeNotice" class="placeholder-check__notice">{{ upgradeNotice }}</span>
</div>
<div v-if="detectedFields.length" class="placeholder-check__list">
<span v-for="field in detectedFields" :key="field.id" class="placeholder-check__item">
<button
v-for="field in detectedFields"
:key="field.id"
type="button"
class="placeholder-check__item"
:class="{ 'is-active': field.id === selectedFieldId }"
:title="`点击配置「${field.label}」的名称 / 类型 / 必填 / 选项`"
@click="toggleField(field.id)"
>
{{ field.label }}
<em>{{ kindLabels[field.kind] }}</em>
</span>
<i v-if="field.required">必填</i>
<i v-if="field.options.length">{{ field.options.length }} 选项</i>
</button>
</div>
<p v-else class="placeholder-check__empty">
文书中还没有任何填写项,签署时不会采集患者填写内容。
</p>
<div v-if="selectedField" class="field-config">
<div class="field-config__head">
<strong>配置「{{ selectedField.label }}」</strong>
<span>字段键 {{ selectedField.id }}(改名称不影响取值归属)</span>
</div>
<div class="field-config__row">
<label class="field-config__item">
<span>名称</span>
<input v-model="fieldDraft.label" type="text" />
</label>
<label class="field-config__item">
<span>类型</span>
<select v-model="fieldDraft.kind">
<option v-for="(text, kind) in kindLabels" :key="kind" :value="kind">
{{ text }}
</option>
</select>
</label>
<label class="field-config__item grow">
<span>高危值(取值命中即标为异常项)</span>
<input v-model="fieldDraft.risk" type="text" placeholder="如 否" />
</label>
<label class="field-config__check">
<input v-model="fieldDraft.required" type="checkbox" />
<span>必填</span>
</label>
</div>
<div
v-if="fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox'"
class="field-config__row"
>
<label class="field-config__item grow">
<span>选项(用、或 | 分隔)</span>
<input v-model="fieldDraft.optionsText" type="text" placeholder="如 同意、不同意" />
</label>
<label class="field-config__check">
<input v-model="fieldDraft.multi" type="checkbox" />
<span>可多选</span>
</label>
</div>
<div class="field-config__actions">
<button type="button" class="field-config__button primary" @click="applyFieldDraft">
应用修改
</button>
<button type="button" class="field-config__button danger" @click="removeField">
删除该项
</button>
<span>改动会作用到正文里该项的每一处出现,保存文书后生效</span>
</div>
</div>
<ul v-if="placeholderHints.length" class="placeholder-check__hints">
<li v-for="hint in placeholderHints" :key="hint">{{ hint }}</li>
</ul>
@@ -374,8 +605,8 @@ function save() {
<span v-else class="docx-hint">
建议从 Word/WPS
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载;
需要患者现场填写的内容(与患者关系、受托人姓名等)点「文本框」插入输入框占位符,插几个就是几处独立填写、各填各的;把名称改成实际叫法只是为了读起来像文书。
占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。
需要患者现场填写的内容(与患者关系、受托人姓名等)点「文本框」插入输入框占位符,插几个就是几处独立填写、各填各的。
占位符在编辑区显示成带类型标签的胶囊:点一下即整块选中,按一次退格就整块删除;名称、类型、必填、选项在下方「填写项」里点开改。
</span>
</div>
<p class="docx-header-note">
@@ -388,6 +619,7 @@ function save() {
ref="paneRef"
:model-value="valueHtml"
@update:model-value="valueHtml = $event"
@select-field="selectFieldFromEditor"
/>
</div>
</div>
@@ -578,12 +810,22 @@ function save() {
font-size: 11px;
}
/* 打开文书时若升级了旧式占位符,在这里说一声——不静默改内容 */
.placeholder-check__notice {
padding: 2px 8px;
color: var(--brand);
font-size: 11px;
background: var(--brand-l);
border-radius: 10px;
}
.placeholder-check__list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
/* 每一项都是按钮:点开即可改名称 / 类型 / 必填 / 选项,再点一次收起 */
.placeholder-check__item {
display: inline-flex;
gap: 5px;
@@ -594,6 +836,17 @@ function save() {
background: #f4f8fa;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.placeholder-check__item:hover {
border-color: var(--brand);
}
.placeholder-check__item.is-active {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.placeholder-check__item em {
@@ -602,6 +855,137 @@ function save() {
font-style: normal;
}
.placeholder-check__item i {
padding: 0 5px;
color: var(--mut);
font-size: 10px;
font-style: normal;
background: rgb(0 0 0 / 5%);
border-radius: 8px;
}
.placeholder-check__item.is-active em,
.placeholder-check__item.is-active i {
color: #fff;
background: rgb(255 255 255 / 22%);
}
/* ---- 填写项配置 ---- */
.field-config {
padding: 9px 10px;
margin-top: 8px;
background: #f8fbfc;
border: 1px solid var(--line);
border-radius: 8px;
}
.field-config__head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
margin-bottom: 8px;
}
.field-config__head strong {
color: var(--ink);
font-size: 11.5px;
}
.field-config__head span {
color: var(--mut);
font-size: 10.5px;
}
.field-config__row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
margin-bottom: 8px;
}
.field-config__item {
display: block;
min-width: 0;
}
.field-config__item.grow {
flex: 1;
min-width: 180px;
}
.field-config__item > span {
display: block;
margin-bottom: 3px;
color: var(--ink);
font-size: 11px;
font-weight: 700;
}
.field-config__item input,
.field-config__item select {
width: 100%;
padding: 5px 7px;
color: var(--ink);
font-size: 12px;
border: 1px solid var(--line);
border-radius: 6px;
box-sizing: border-box;
}
.field-config__item input:focus,
.field-config__item select:focus {
border-color: var(--brand);
outline: none;
}
.field-config__check {
display: inline-flex;
gap: 5px;
align-items: center;
padding-bottom: 6px;
color: var(--ink);
font-size: 11.5px;
white-space: nowrap;
}
.field-config__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.field-config__actions > span {
color: var(--mut);
font-size: 10.5px;
}
.field-config__button {
padding: 4px 11px;
font-size: 11.5px;
font-weight: 700;
border-radius: 6px;
}
.field-config__button.primary {
color: #fff;
background: var(--brand);
border: 1px solid var(--brand);
}
.field-config__button.danger {
color: var(--danger, #c0392b);
background: #fff;
border: 1px solid currentcolor;
}
.field-config__button:hover {
filter: brightness(0.96);
}
.placeholder-check__empty {
margin: 0;
color: var(--mut);
@@ -3,14 +3,28 @@ import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { DomEditor } from '@wangeditor/editor'
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
import type { SigningFieldDef } from '@/api/workbench/types'
import { normalizePastedHtml } from '@/utils/rich-text'
import { decorateSigningMarkers, registerSigningEditorMarks } from '@/utils/signing-editor-marks'
import {
createSignFieldNode,
isSignFieldElement,
readFieldFromSignFieldNode,
registerSigningFieldNode,
removeSignFieldNodes,
SIGN_FIELD_CHIP_CLASS,
SIGN_FIELD_TYPE,
updateSignFieldNodes,
} from '@/utils/signing-field-node'
// 渲染钩子是 wangEditor 的全局注册表,必须在编辑器创建前挂上,
// 否则首帧渲染出来的占位符/勾选框还是普通文字(要等下一次重渲染才变胶囊)。
// 这两个都是 wangEditor 的全局注册表,必须在编辑器创建前挂上:
// - 渲染钩子决定旧式 {…} 占位符与 □ 勾选框的显示(要等下一次重渲染才生效就晚了);
// - 元素节点决定结构化占位符能不能被还原成原子胶囊(首帧就要是胶囊,否则看起来像丢内容)。
registerSigningEditorMarks()
registerSigningFieldNode()
const props = defineProps<{
modelValue: string
@@ -18,6 +32,8 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: string]
/** 点中了正文里的结构化占位符,交给上层打开「填写项配置」 */
'select-field': [field: SigningFieldDef]
}>()
const editorRef = shallowRef<IDomEditor>()
@@ -96,6 +112,44 @@ function handleCreated(editor: IDomEditor) {
editableRef.value = editable
editable.addEventListener('pointerdown', markUserCaret)
editable.addEventListener('keydown', markUserCaret)
editable.addEventListener('click', handleFieldClick)
}
/**
* 点中正文里的占位符胶囊 → 整块选中并通知上层打开配置面板。
*
* 必须再判一次 toSlateNode 的结果:旧式 {…} 占位符被 decorate 渲染成了
* 同名的 .ms-sign-chip,它只是个叶子、不是原子节点,
* 点它时什么都不该发生(它的配置从上方「填写项」列表里改)。
*/
function handleFieldClick(event: Event) {
const editor = editorRef.value
const target = event.target
if (!editor || !(target instanceof Element)) {
return
}
const chip = target.closest(`.${SIGN_FIELD_CHIP_CLASS}`)
if (!chip) {
return
}
try {
const node = DomEditor.toSlateNode(editor, chip)
if (!isSignFieldElement(node)) {
return
}
editor.focus()
editor.select(DomEditor.findPath(editor, node))
emit('select-field', readFieldFromSignFieldNode(node))
} catch {
// 解不出节点(点到装饰件、或节点已不在模型里)就静默放过:
// 点击本身不该报错,保持浏览器默认行为即可
}
}
function getText(): string {
@@ -116,11 +170,68 @@ function insertText(text: string) {
editor.insertNode({ text })
}
defineExpose({ getText, insertText })
/**
* 在光标处插入一个**结构化占位符**(Slate 元素,不是文本)。
*
* 与 insertText 的区别就是这个模块存在的理由:它进的是原子节点,
* 点一下即整块选中、一次退格整块删除;字段键、类型、必填、选项、多选
* 都挂在元素上,导出正文时还原成 `<span data-field>`,
* 与签署渲染端(utils/signing-document)共用同一份契约。
*/
function insertField(field: SigningFieldDef) {
const editor = editorRef.value
if (!editor) {
return
}
editor.focus(!userPlacedCaret.value)
// 当前选中的就是占位符时先右移一格:否则新节点会插进上一个占位符内部,
// 连插两个签名位会互相嵌套(内置图片节点也是这么处理的)
if (DomEditor.getSelectedNodeByType(editor, SIGN_FIELD_TYPE)) {
editor.move(1)
}
editor.insertNode(createSignFieldNode(field))
}
/**
* 就地改写某个字段的每一处占位符,返回命中的节点数。
*
* 面板改字段走这里而不是「改 HTML 字符串再塞回编辑器」:
* 字符串往返要经过 setHtml,而 setHtml 内部会 `e.focus()`——它会把缓存的上次选区装回去,
* 于是新正文的节点还没绑上 DOM 就先做了一次 DOM 同步,抛
* `Cannot resolve a DOM node from Slate node`(实测必现,且与光标在哪儿无关)。
* 改模型既保住光标,也彻底绕开这条路径。字段 id 不变,历史取值不受影响。
*/
function updateField(fieldId: string, patch: Partial<Omit<SigningFieldDef, 'id'>>): number {
const editor = editorRef.value
if (!editor) {
return 0
}
return updateSignFieldNodes(editor, fieldId, patch)
}
/** 就地删掉某个字段的每一处占位符,返回命中的节点数 */
function removeField(fieldId: string): number {
const editor = editorRef.value
if (!editor) {
return 0
}
return removeSignFieldNodes(editor, fieldId)
}
defineExpose({ getText, insertText, insertField, updateField, removeField })
onBeforeUnmount(() => {
editableRef.value?.removeEventListener('pointerdown', markUserCaret)
editableRef.value?.removeEventListener('keydown', markUserCaret)
editableRef.value?.removeEventListener('click', handleFieldClick)
editorRef.value?.destroy()
})
</script>
@@ -205,8 +316,12 @@ onBeforeUnmount(() => {
}
/* ---- 占位符 / 勾选框胶囊 ----
utils/signing-editor-marks 把 {患者姓名} 切成「{ / 患者姓名 / }」三段叶子、
把 □ 切成一 段叶子,这里负责把它们渲染成一眼能认出是"待签署内容"的形态。 */
现在有两种占位符胶囊,样式刻意保持一致(都代表"待签署内容",不该靠外观区分重要性):
① 结构化占位符(.ms-sign-node):Slate 元素节点,正文里存成 <span data-field>。
标签文字走下面的 ::before,DOM 里没有可落光标的文字,一次退格整块删除。
② 旧式 {患者姓名}(.ms-sign-brace + .ms-sign-chip):decorate 出来的叶子,
只是显示成胶囊,本质还是文字,要按多次退格。存量模板打开后会被升级成 ①。 */
/* 大括号:只缩小调淡,不移除。这两个字符必须留在 DOM 里参与光标偏移计算,
隐藏它们会让点击胶囊时的落点整体算错,也会让退格静默删掉看不见的字符 */
@@ -238,6 +353,25 @@ onBeforeUnmount(() => {
border-radius: 4px;
}
/* 结构化占位符的标签文字:走 CSS 内容而不是 DOM 文本节点。
元素是 void,DOM 里只有它自己与一个绝对定位的零宽占位叶子;
若在这里放真实文字,浏览器原生光标会落进文字里,
一次退格就只删掉一个字符 —— 正好破坏"一次退格整块删除"这个设计目标 */
.pane-content :deep(.ms-sign-node)::before {
content: attr(data-sign-label);
}
/* 选中态:点一下即整块选中,给个明确的反色,让人知道接下来的退格会删掉整块 */
.pane-content :deep(.ms-sign-node.is-selected) {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.pane-content :deep(.ms-sign-node.is-selected)::after {
border-left-color: rgb(255 255 255 / 35%);
}
/* 类型角标:签名/日期/文本……让科室不点开也知道这一项要填什么。
所有占位符共用一个样式,靠这个角标区分类型,不再按必填/签名位分色 */
.pane-content :deep(.ms-sign-chip)::after {