feat(signing): 将签署占位符改为原子节点并支持多选选项组
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user