病历编辑器接入
本文示例使用以下公共入口;业务类型和服务仍由你的应用定义:
import { MedicalRecordEditorController, RenderMedicalRecordEditor, type MedicalRecordDocument,} from 'ds-ui'
病历编辑器是 DirectSurface UI 中最复杂的业务组件之一。它不是普通富文本控件,而是面向电子病历场景的文档编辑器,包含分页排版、数据元、表格、批注、留痕、审阅侧栏、弹层编辑、打印 SVG 和大文档性能优化。
本文面向业务系统开发者,说明如何把编辑器接入页面、如何组织文档数据、哪些能力由编辑器负责、哪些规则应由业务系统负责。
能力边界
编辑器负责:
- Canvas 实时编辑和绘制。
- 病历文档分页、缩放、滚动、横向文档标尺和可视页绘制。
- 文本输入、中文 IME、粘贴、拖选、复制和光标。
- 段落格式、字体、颜色、项目符号、编号、缩进。
- 表格插入、合并、拆分、行列增删、边框和斜线。
- 日期、下拉、多选、数值和时间等数据元的弹层编辑。
- 批注和质控问题的创建、展示、状态流转和锚点状态。
- 留痕显示、留痕提示、审阅项导航和筛选。
- 打印模式下输出 SVG 节点。
业务系统负责:
- 登录用户、当前患者、当前就诊、文书状态和权限判断。
- 是否允许编辑、是否启用留痕、是否显示批注和审阅块。
- 文档 JSON 的加载、保存、归档、版本和服务端审计。
- 模板库、病历分类树、文书实例列表和接口错误处理。
- 打印流程、打印预览、PDF 生成、归档签名和外部质控流程。
编辑器本身不判断病历业务状态。业务应根据流程状态和权限设置 editor.mode、editor.disabled,并按需配置高级 controller 选项,而不是把“已归档不能编辑”“上级医生才能审核”等规则写进底层编辑器。
组件职责分层
| 层级 | 职责 | 业务侧用法 |
|---|---|---|
RenderMedicalRecordEditor |
页面级 UI façade;持有组件生命周期、焦点、输入、滚动、弹层和审阅界面,并提供加载、模式、禁用、序列化和校验入口。 | 默认入口。页面创建、挂载和释放 editor。 |
MedicalRecordEditorController |
一次文档编辑会话;持有文档上下文、选区、历史记录、编辑命令、工具栏状态、变更事件和无界面打印能力。 | 高级工具栏或设计器通过 editor.controller 使用;不负责挂载和绘制。 |
| layout / paginator / painter 等内部对象 | 完成分页、排版和绘制,不承担页面编排或业务状态。 | 不作为业务接入入口。 |
因此,controller 和 render editor 的拆分是有价值的:同一套文档命令既能被 Canvas 交互层调用,也能用于无界面的打印和测试。普通页面不需要同时创建两者;editor 默认创建并拥有 controller,只有依赖注入、共享测试夹具或无界面服务才直接管理 controller。
最小接入
先创建编辑器组件,再由页面发起业务请求。编辑器内部 controller 构造后暂时存在的空白文档只是组件可挂载的内部占位,不应拿去保存或打印。
class MedicalRecordPage { readonly editor = new RenderMedicalRecordEditor({ mode: 'view', showRuler: true, visual: { chrome: { verticalRuler: true, statusBar: true, pageLabels: true, sectionLabels: true, }, }, disabled: true, }) async load(documentId: string): Promise<void> { this.editor.disabled = true this.editor.mode = 'view' const response = await fetch(`/api/medical-records/${documentId}`) if (!response.ok) throw new Error(`HTTP ${response.status}`) const document = await response.json() as MedicalRecordDocument this.editor.loadDocument(document) this.editor.mode = 'form' this.editor.disabled = false this.editor.requestFocus() } dispose(): void { this.editor.dispose() }} function openMedicalRecord( contentPanel: RenderStackPanel, documentId: string,): MedicalRecordPage { const page = new MedicalRecordPage() contentPanel.addChild(page.editor, 1) void page.load(documentId).catch(error => console.error(error)) return page}
这条链路的顺序是固定的:组件挂载 → 页面进入 view + disabled 加载态 → 请求业务文档 → loadDocument() → 设置本次业务需要的模式并解除禁用。请求失败时保持禁用并展示业务错误;不要让内部空白占位进入保存链路。
默认情况下编辑器创建并拥有内部 controller,页面关闭时释放编辑器即可。如果高级场景显式传入 controller,应通过 controllerOwnership 明确所有权,并在 borrowed 模式下由调用方释放。不要把 controller 放到应用级单例里长期复用。
表面边界
RenderMedicalRecordEditor 默认使用 appearance: 'framed',由编辑器自己绘制工作区背景和外边框。当它作为已有边界的 pane 或页签内容区时,可以使用 flush 去掉重复的外边框:
const embeddedEditor = new RenderMedicalRecordEditor({ appearance: 'flush',})
appearance 也可在实例上动态修改。切换只影响绘制,不改变编辑器尺寸、分页、滚动或文档状态。debugState().appearance 可用于集成调试。
页面结构
典型病历编辑页面由“模板树 + 工具栏 + 编辑器”组成:
RenderStackPanel vertical
RenderToolbar
RenderSplitter horizontal
left: 模板树 / 文书目录 / 大纲
right: RenderMedicalRecordEditor
实现要点:
- 病历编辑器自己管理滚动,不要再套一层同方向
ScrollViewer。 - 左侧模板树适合用
RenderTreeView,长文本要有省略和 tooltip。 - 工具栏应读取
controller.getToolbarState(),并在 controllerchange事件后刷新。 - 大文档下不要在每次输入后重建整个页面 render tree。
文档数据
MedicalRecordDocument 可以是:
| 输入类型 | 说明 |
|---|---|
| 已反序列化的病历 JSON 对象 | 业务接口和模板库最常用。 |
| JSON 字符串 | 适合本地文件或旧接口返回字符串。 |
| 底层文档模型对象 | 高级扩展场景使用,普通业务优先传入已反序列化的病历 JSON 对象。 |
加载文档:
async function loadMedicalRecord( editor: RenderMedicalRecordEditor, url: string,): Promise<void> { const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status}`) const document = await response.json() as MedicalRecordDocument editor.loadDocument(document)}
加载本地 JSON:
async function loadLocalMedicalRecord( editor: RenderMedicalRecordEditor, file: File,): Promise<void> { const text = await file.text() const document = JSON.parse(text) as MedicalRecordDocument editor.loadDocument(document)}
保存是业务流程:编辑器只序列化当前文档,接口调用、错误处理和保存状态由页面管理。
async function saveMedicalRecord( editor: RenderMedicalRecordEditor, documentId: string,): Promise<void> { const revision = editor.contentRevision const document = editor.serialize() const response = await fetch(`/api/medical-records/${documentId}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(document), }) if (!response.ok) throw new Error(`HTTP ${response.status}`) // 只确认本次实际保存的内容;请求期间有新编辑时保持 dirty。 if (editor.contentRevision === revision) { editor.resetDirty() }}
contentRevision 是单调递增的内容状态序号;加载新文档、修改内容、撤销或重做都会推进它。上面的比较由业务页面明确处理异步保存期间的并发编辑,无需引入编辑器专用的保存事务或状态令牌。如果业务本身会禁用编辑直到保存完成,也可在成功后直接调用 resetDirty()。
推荐保存口径:
- 页面保存
editor.serialize()的结果,并且只在服务端确认成功后调用resetDirty()。 - 不保存临时可视状态,例如滚动条位置、当前 hover、popup 状态。
- 是否保存留痕、批注、审计轨迹取决于文档 JSON 结构和业务服务约定。
- 保存前可以先执行
editor.validate(),把必填数据元或业务校验问题展示给用户。
内容修改标记与书写变化高亮是两套状态:
editor.isDirty表示自加载文档或最近一次resetDirty()之后,是否发生过可序列化内容修改。editor.contentRevision在加载新文档、内容修改、撤销和重做后递增;选区、校验、模式、焦点和禁用变化不会递增。editor.resetDirty()只把当前时点设为新的修改基线,不序列化、不调用接口,也不改变文档内容。- dirty 是保守的“基线后发生过修改”标记;即使撤销后文本恰好回到相同内容,也不会自动变回 clean。
controller.clearWriteChangeMarks()只清理数据元的变化高亮,不会清除 dirty。- 旧
markDocumentClean()只清理书写变化高亮;不要用它重置 dirty。
书写变化高亮只在 form 模式且 showWriteChangeMarks 开启时显示,使用独立的琥珀色角标,不改变普通数据元底线或 composite 文本数据元 outline。它是当前编辑会话的瞬时提示,不等同于会进入文档审阅体系的正文留痕。
模板库接入
模板库通常不是编辑器能力,而是业务页面组合:
模板索引接口
-> TreeView roots
-> 点击模板节点
-> fetch 模板 JSON
-> editor.loadDocument(document)
推荐模板索引字段:
| 字段 | 说明 |
|---|---|
id |
模板唯一标识。 |
name |
模板显示名称。 |
children |
子分类或子模板。 |
hasDocument |
当前节点是否能加载文档。 |
documentPath |
静态模板 JSON 或接口地址。 |
parseError |
模板解析失败时用于提示。 |
模板树只负责选择模板。真正加载文档后,应调用 editor.loadDocument() 和 editor.requestFocus()。
工具栏和状态同步
工具栏不要自己猜当前选区状态,应使用:
const editor = new RenderMedicalRecordEditor()const controller = editor.controllerconst state = controller.getToolbarState()
常用状态:
| 字段 | 说明 |
|---|---|
canUndo / canRedo |
当前用户界面是否可以撤销、重做;view 模式下始终为 false。 |
fontName / fontSizePt |
当前输入或选区字体;模型字号单位始终为 pt,界面可将标准值显示为“小四”“五号”等中文字号。 |
color / background |
当前文字颜色和背景色。 |
bold / italic / underline / linethrough |
当前文本样式。 |
subscript / superscript |
上下标状态。 |
textAlign |
当前段落对齐。 |
paragraphNumberType |
none、ul、ol。 |
enableTrackChanges |
是否启用留痕。 |
showTrackChanges |
是否显示正文内联留痕。 |
showTrackChangesSidebar |
是否显示右侧留痕详情栏。 |
showTrackChangesTip |
已弃用的兼容字段,等价于 showTrackChangesSidebar。 |
showComment |
是否显示批注。 |
showCommentSidebar |
是否允许批注和质控问题进入右侧审阅侧栏;默认关闭。 |
documentMode |
当前文档模式。 |
监听变化:
const editor = new RenderMedicalRecordEditor()const controller = editor.controllerconst subscription = controller.onChange(() => { const state = controller.getToolbarState() state.canUndo}) subscription.unsubscribe()
页面 dispose 时要释放订阅。业务只关心 dirty、模式、焦点和禁用状态时,优先使用 editor.onStateChange():
const editor = new RenderMedicalRecordEditor()const subscription = editor.onStateChange(event => { state.canSave = event.state.isDirty state.canLeave = !event.state.isDirty}) subscription.unsubscribe()
工具栏应使用 getToolbarState().canUndo/canRedo 控制用户操作。controller.canUndo()/canRedo() 只表示原始历史栈状态,undo()/redo() 是可信业务命令,不代替页面的用户权限判断。
右键菜单扩展
业务右键菜单通过 controller 注册,不要在病历 Element 上保存监听器。每次打开菜单时,编辑器先生成内置段落和表格菜单,再按注册顺序调用业务 provider,最后追加兼容的设计器菜单。
import type { MedicalRecordContextMenuProvider, MedicalRecordEditorController,} from 'ds-ui' function registerRecordContextMenu( controller: MedicalRecordEditorController,) { const provider: MedicalRecordContextMenuProvider = context => { if (!context.businessModelPath) return [] const modelPath = context.businessModelPath.join('/') return [{ caption: '复制模型路径', allowReadonly: true, click: () => navigator.clipboard.writeText(modelPath), }] } return controller.addContextMenuProvider(provider)} const subscription = registerRecordContextMenu(editor.controller)subscription.unsubscribe()
sourceElement 表示当前选区对应的菜单冒泡起点;targetElement 表示本次右键直接命中的元素。二者都是不稳定的内部对象引用,只适合在本次 provider 调用中做身份比较,不应保存到业务状态。业务定位优先使用 businessModelPath。
右键菜单是基于打开时选区生成的短生命周期快照。菜单打开后如果文档、模式或选区发生变化,编辑器会关闭菜单或拒绝执行已经失效的命令。
allowReadonly: true 只应用于复制、查看和定位等不会修改文档的叶子动作。普通菜单项会在不可写目标上禁用,并在执行前再次校验 source、target、选区和当前权限;作为分组使用的父菜单会根据可用子项决定是否可以展开。需要修改文档时,应在 click 中调用正式的 controller 命令,不要直接修改内部 Element。
常用编辑 API
文档和历史
| API | 说明 |
|---|---|
loadDocument(document) |
加载已经存在或由业务接口返回的病历文档,重建分页、清理历史基线。 |
createNewDocument(document?, paperName?) |
明确创建一份新文档;未传文档时按纸张模板创建空白文档。 |
loadDoc(data, { autoScrollToTop? }) |
兼容旧 API,默认加载后滚动到顶部。 |
serialize() |
序列化当前文档。 |
serializeString() |
序列化为字符串。 |
isDirty |
自加载或最近一次 resetDirty() 之后是否发生过内容修改。 |
contentRevision |
内容状态序号,可用于业务判断异步操作期间是否加载或产生了新内容。 |
resetDirty() |
以当前时点重置修改标记。 |
validate() |
执行文档校验。 |
undo() / redo() |
可信业务命令:撤销、重做,不隐式受文档模式限制。 |
canUndo() / canRedo() |
原始历史栈状态,不代表当前用户界面权限。 |
beginHistoryStep() / commitHistoryStep() |
需要把一组外部操作合并成一步历史时使用。 |
选区序列化与复制
controller.getSelectedJSON()、controller.getSelectedText() 和 controller.execCopy() 使用同一份语义化选区内容。getSelectedJSON() 返回带 documentVersion 和 elements 的元素片段 JSON;getSelectedText() 返回同一片段的纯文本表示。没有有效选区、选区为空或选区无法完整序列化时,两者都返回空字符串。
选区内容遵循以下规则:
- 同一段落内的部分文本、全部文本或跨多个 Text 节点选择都返回 inline 元素,不附加 Paragraph 包装。
- 跨段落选择保留起始部分段、中间完整段和结束部分段的 Paragraph 包装及原顺序;纯文本结果用段落换行分隔。
- 数据元内部或完整数据元的选择只返回可见内容,不复制数据元 schema;无可见值的原子数据元不产生内容。
- 删除留痕、批注 marker 和数据元装饰不会进入结果;新增留痕展开为其可见内容。
- 只有单元格或矩形表格选区返回表格片段;单元格内普通文本选择仍按 inline 处理。
execCopy() 从上述同一片段生成 doc/plain 和 text/plain。两种 MIME 任一写入失败时,剪切命令不会删除正文。
文本和段落
| API | 说明 |
|---|---|
setTextFormat(props) |
设置文字属性;使用 fontSizePt 和 letterSpacingPt。 |
setTextFormatByFn(setter) |
使用 canonical pt draft 修改文字属性。 |
adjustFontSizePt(deltaPt) |
按 pt 增减字号。 |
setParaStyle(props) |
设置段落属性;使用 indentMm/hangingMm/tabsMm 和 border.widthPt。 |
setParaStyleByFn(setter) |
使用 canonical mm/pt draft 修改段落属性。 |
setParagraphNumberType(type) |
设置项目符号、编号或取消。 |
insertSoftBreak() |
插入行内换行符。 |
insertPageBreak() |
插入分页符。 |
表格
| API | 说明 |
|---|---|
insertTable(rows, cols) |
插入表格。 |
setCellBorder(command) |
设置当前选区表格边框。 |
mergeCellsBySelection() / combineCell() |
合并当前表格选区。 |
splitCell(rows, cols) |
拆分当前单元格。 |
restoreMergeCellsAtCursor() |
还原合并单元格。 |
insertRowAboveAtCursor() / insertRowBelowAtCursor() |
插入行。 |
insertColToLeftAtCursor() / insertColToRightAtCursor() |
插入列。 |
removeCurrRowAtCursor() / removeCurrColAtCursor() |
删除当前行或列。 |
removeTableAtCursor() |
删除当前表格。 |
setCellDiagonal(diagonal) |
设置单元格斜线。 |
setTableCellBgColorAtSelection(color) |
设置选区单元格背景。 |
批注和质控
| API | 说明 |
|---|---|
insertComment(text, options?) |
对当前选区插入批注。 |
insertQualityIssue(text) |
插入质控问题。 |
updateCommentText(id, text) |
更新批注内容。 |
setCommentStatus(id, status, remark?) |
更新批注状态。 |
removeComment(id) |
删除指定批注。 |
removeCurrentComment() |
删除当前光标所在批注。 |
clearAllComments() |
清空批注。 |
状态包括:
| 状态 | 语义 |
|---|---|
open |
未处理。 |
addressed |
已处理,待确认。 |
resolved |
已解决。 |
reopened |
重新打开。 |
closed |
已关闭。 |
anchorChanged |
关联正文发生变化。 |
orphaned |
关联正文锚点丢失。 |
数据元
| API | 说明 |
|---|---|
dataFields.listFields(query?) |
获取当前文档或指定范围内的数据字段句柄。默认只返回最外层数据元;传入 includeChildren: true 时递归展开嵌套字段。 |
dataFields.findFields(ref, options?) |
按模型路径、id、name 或 fieldName 查找字段;返回数组以显式保留重复业务标识。 |
dataFields.getValues(query?) |
读取适合业务采集的轻量值快照。 |
insertElementSchema(schema) |
插入一个序列化元素,例如文本数据元、日期数据元、列表数据元。 |
getCurrentDataElement() |
获取当前数据元。 |
setCurrentDataElementValue(value) |
设置当前数据元值。 |
moveFocusToNextDataElement(source?) |
跳到指定源元素之后的下一个数据元;省略源元素时从当前选择开始。 |
moveFocusToPreviousDataElement(source?) |
跳到指定源元素之前的上一个数据元;省略源元素时从当前选择开始。 |
getDataElementPosition(element) |
获取数据元装饰位置,用于弹层或按钮定位。 |
日期、下拉、多选、树形选择等数据元的编辑 UI 由编辑器内置弹层处理。业务如果需要自定义弹层,应基于实际 render rect 定位,不能基于理想文本宽度定位。
文本数据元统一使用 type: 'data-ele-text',并通过持久化属性 contentModel 声明内容语法:
contentModel |
内容约束 | 标量写入 |
|---|---|---|
scalar |
默认值;可以包含普通文本和行内内容,但不能包含嵌套数据元。 | 支持 setValue()。 |
composite |
必须显式写出;可以包含文本和嵌套数据元,包括嵌套的 data-ele-text。 |
不支持整体标量写入。 |
contentModel 不会根据当前 children 自动推断或升级。composite 只允许 valueType: 'string',也不能同时配置 scalar 值约束。新模板不得再写入旧 type: 'data-group';历史文档和历史剪贴板片段会在兼容迁移入口转换为 data-ele-text + contentModel: 'composite',当前版本载荷中直接出现旧类型会被拒绝。
例如,插入可容纳嵌套数据元的文本数据元:
function insertAdmissionSummary(controller: MedicalRecordEditorController): void { controller.insertElementSchema({ type: 'data-ele-text', props: { id: 'admissionSummary', caption: '入院摘要', contentModel: 'composite', valueType: 'string', valueTextProps: {}, nullTextProps: {}, }, })}
文本数据元使用 valueType 声明值语义。当前支持 string、number 和 time;整数不单独定义类型,而由 number 配合 constraints.scale: 0 表达。constraints 可以携带文本长度、数值约束或时刻精度与范围元数据:
function insertTemperatureField(controller: MedicalRecordEditorController): void { controller.insertElementSchema({ type: 'data-ele-text', props: { id: 'temperature', caption: '体温', valueType: 'number', constraints: { minValue: '30', maxValue: '45', precision: 3, scale: 1, }, valueTextProps: {}, nullTextProps: {}, }, })}
minLength、maxLength 用于字符串;precision、scale 用于数值。minValue、maxValue 可以按当前 valueType 表示数值或时刻范围。数值边界使用十进制字符串,校验时也按十进制字符串精确比较,不经过 JavaScript 浮点数转换。precision 表示总数字位数(不计算整数部分的前导零),scale 表示小数位数;scale: 0 可用于整数。
时刻数据元只表示一天内的本地时刻,不包含日期、时区或持续时长。constraints.timePrecision: 'minute' 使用严格的 HH:mm,'second' 使用严格的 HH:mm:ss;省略时按 second。例如:
function insertRoundTimeField(controller: MedicalRecordEditorController): void { controller.insertElementSchema({ type: 'data-ele-text', props: { id: 'roundTime', caption: '查房时间', valueType: 'time', constraints: { timePrecision: 'minute', minValue: '08:00', maxValue: '18:00', }, valueTextProps: {}, nullTextProps: {}, }, })}
编辑器仍以字符串保存文本数据元的值,不会截断、四舍五入或自动改写输入。在设计模式和表单编辑模式中,valueType: 'string' 继续使用正文内的行内编辑;number 和 time 作为原子值在数据元下方使用 canvas 弹层编辑。数值弹层的 Enter 或点击弹层外部会尝试一次性提交,Tab 和 Shift+Tab 在提交成功后分别移动到下一个和上一个数据元,Escape 放弃草稿。时间弹层中的列选择和“现在”只更新草稿,点击“确定”、按 Enter 或对可选字段执行“清空”才提交;“取消”、Escape 或点击外部均放弃草稿。提交校验失败时弹层保持打开,文档值、dirty、revision 和 History 均不变化。若弹层打开期间字段值被其他操作修改,当前草稿不会覆盖新值;若值类型、必填性、可编辑性或约束配置发生变化,旧编辑会话会终止,重新打开后使用新配置;进入只读模式也会取消当前草稿。
数值草稿输入过程中只提示多个小数点、非法字符等明显格式错误,-、.、-.、12. 等渐进状态可以继续输入;提交时再执行必填、完整格式、范围、精度和小数位校验。时间值不经过渐进文本输入,确认时严格检查格式、时分秒和范围。validateRecord() 和 validate() 仍会使用同一套最终校验作为保存、导入、粘贴或 API 写入后的防线,结果可通过 collectValidationIssues() 读取。旧 dataType 不再属于文本数据元 schema,读取时不会转换为 valueType,再次序列化时也不会保留。
读取整份病历的数据字段时,使用 controller 上稳定的只读门面:
const fields = editor.controller.dataFields.listFields({ includeChildren: true,}) const diagnosisFields = editor.controller.dataFields.findFields({ fieldName: 'diagnosis',}) const diagnosis = diagnosisFields[0]const currentValue = diagnosis?.getValue()diagnosis?.setValue('updated diagnosis')
字段句柄不暴露内部元素对象,元数据为只读。getValue() 读取字段实时值;setValue() 委托给文档树中的实际数据元,值或属性变化继续使用文档树原有的向上传播和帧刷新机制,不额外创建刷新或历史事务。文档重新加载后旧句柄会失效,应重新查询。modelPath 是查询时的当前结构位置,不是持久业务主键。
authoredEditable 和 valueWriteMode 表达两个不同维度:前者是模板作者配置的编辑权限,后者说明字段是否接受标量 API 写入。scalar 文本数据元的 valueWriteMode 为 'scalar';composite 文本数据元为 'none'。composite 字段仍可通过 getValue() 读取聚合后的可见文本,但调用 setValue() 会抛出稳定的 composite-data-element-scalar-write 错误;需要修改其内容时,应定位并写入具体子字段,或在 design 模式下通过结构命令修改模板。
id、name 和 fieldName 可能重复,所以查找 API 始终返回数组。复选框按最近的 composite 文本数据元或正文区域组合成逻辑字段,并通过 memberModelPaths 暴露其物理成员;模板中同组复选框配置不一致时,diagnostics 会给出只读诊断信息。
批量采集时使用 getValues() 获取携带 contentRevision 的脱离快照。controller.docCtx 仅为旧接入保留,已弃用;新业务不要依赖内部元素、选区、刷新回调或建议状态。旧版内部 registerTypeHandlers/Element 事件分发已经移除,也不属于兼容公共 API;上下文菜单继续使用公开的菜单贡献接口,其他业务交互应通过 Controller 的公开命令和事件完成。
表达式编辑语言支持
模板设计器可以把病历表达式语言适配到公共 RenderCodeEditor。适配器只提供编辑期的语法诊断、字段与函数补全、语义高亮和 hover,不执行表达式、不读取内部 Element,也不修改病历文档:
import { RenderCodeEditor, createMedicalExpressionLanguageAdapter, type MedicalExpressionLanguageContext,} from 'ds-ui' const languageContext: MedicalExpressionLanguageContext = { kind: 'value', ownerId: 'bmi', availableScopes: ['tr', 'tb'], fields: [ { id: 'weight', caption: '体重', elementType: 'data-ele-text', valueType: 'number', }, { id: 'score', scope: 'tr', caption: '本行得分', elementType: 'data-ele-text', valueType: 'number', }, ],} const expressionEditor = new RenderCodeEditor({ value: '$weight', languageAdapter: createMedicalExpressionLanguageAdapter(), languageContext, completionTriggerCharacters: ['$', '.'],})
kind 必须是 value 或 visible。值表达式提供 ownerId 后,自引用会成为错误诊断。字段 id 是表达式中 $id 的实际引用名;scope: 'tr' | 'tb' 分别对应 $tr.id 和 $tb.id。有作用域的引用只解析相同 scope 的字段,不会回退到未声明 scope 的全局字段。多选列表应同时声明 elementType: 'data-ele-list' 和 multiSelect: true,或者显式声明 collection: true,这样 CONTAINS($field, code) 才能通过类型诊断。
空字符串在两种 kind 下都合法,用于清除已有表达式。首版诊断包括语法错误、未知或重复引用、值 owner 自引用、不可用 scope,以及 CONTAINS 字段类型错误。适配器不会公开内部 parser、IR 或 evaluator;需要展示函数帮助或快速插入时,可通过 medicalExpressionFunctionInfos() 获取源自同一函数元数据的只读编辑期目录。编辑期合同要求 AVG 至少有一个参数,以避免空平均值在不同执行端产生不同结果;底层 parser 仍可读取历史 AVG(),但新模板不能通过语言适配器校验。
模板设计器可以让 valueExpr 和 visibleExpr 复用同一个表达式编辑窗口。值表达式上下文使用 kind: 'value' 并提供 ownerId,因此禁止引用自身;显隐表达式使用 kind: 'visible',允许引用当前数据元,并把结果作为元素是否参与排版的条件。两类编辑都应通过 Controller 的设计器修改入口提交,使重新绑定、布局刷新、dirty 和 Undo/Redo 保持一致。空字符串或纯空白表示清除表达式,提交时应规范化为未设置;原本没有表达式时,直接确认空白编辑器不得产生 dirty 或 History。
新Formula Pilot入口
新Formula authoring通过独立ds-ui/medical-formula子路径接入,并复用根入口的RenderFormulaAuthoringEditor。当前Demo设计器在数据元右键“属性”打开的PropertyGrid中显示独立的“Formula(Pilot)/值公式/可见性公式”行;它们不覆盖旧valueExpr和visibleExpr。
Pilot使用显式、plain-data evaluation snapshot提供只读preview。窗口打开后,source变化只从冻结snapshot选择Checked analysis需要的exact values,不回读live Element。保存时由Medical save preflight产生immutable plan,再通过Controller原子commit写入definition和首次值;空白保存删除对应role。
Medical文档schema已经为scalar Text、Date和independent Check提供严格版本化的props.formulaBindings definition envelope,并随完整数据元clone/copy/serialize;只复制内部文本不会携带definition。unknown future/invalid nested schema在Element构造前fail closed。旧valueExpr/visibleExpr与新definition可以同时保留,但新runtime保持blocked-legacy-conflict,不会自动覆盖旧runtime。
使用该子路径的应用必须显式安装@ds-ui/formula-engine@0.1.0-rc.0。Formula Engine保持外部依赖,不进入ds-ui根bundle。root与ds-ui/medical-formula现在由同一次multi-entry构建共享Medical class identity,真实双tarball root owner已通过preflight、commit、undo/redo与runtime dependency batch验证。
installDefaultMedicalFormulaRuntime(controller)是显式opt-in入口。安装只compile/bind并保留文档中已持久化的derived value;只有真实依赖change才会重算,成功写回加入发起修改的同一History action。Controller的getMedicalFormulaRuntimeState()和onMedicalFormulaRuntimeStateChange()提供只读状态,screen ERROR/visibility不进入document JSON、clipboard、业务value或History。handle dispose()可重复调用。Medical Demo通过medicalFormulaProductionPilotEnabled显式控制PropertyGrid rows与runtime安装;关闭不会删除已有definition。offscreen print和旧表达式迁移仍属于后续阶段。
doc、header、body 和 footer 是分页运行时必需的页面容器,当前不支持配置 visibleExpr。设计器不应为这些节点提供新的显隐表达式编辑入口;历史文档若含有此类配置,应只读展示并报告兼容问题。Paragraph、Section、Table、Row、Cell 和段内元素继续支持显隐表达式。
视图和打印
| API | 说明 |
|---|---|
updateViewOptions(patch) |
批量更新对外视图配置,并主动触发所需的布局、选区或绘制失效。 |
setDocumentMode(mode) |
设置 design、form、view。 |
setTrackChangesEnabled(enabled) |
启用或关闭留痕。 |
setShowTrackChanges(enabled) |
显示或隐藏正文内联留痕。 |
setShowTrackChangesSidebar(enabled) |
显示或隐藏右侧留痕详情栏。 |
setShowTrackChangesTip(enabled) |
已弃用的兼容方法,等价于 setShowTrackChangesSidebar(enabled)。 |
setShowComment(enabled) |
显示或隐藏批注。 |
setShowCommentSidebar(enabled) |
显式显示或隐藏批注、质控问题审阅侧栏。 |
setShowRuler(enabled) |
显示或隐藏编辑器顶部的横向文档标尺。 |
setContinuousHeaderFooterPolicy(policy) |
设置 Continuous 分节同页时的页眉页脚归属策略。 |
setDataElementInputValidationEnabled(enabled) |
启用或关闭数据元输入校验。 |
zoom |
当前实际缩放比例,范围为 0.5~2。 |
zoomMode |
当前缩放模式:fixed 或 fit-width。 |
setZoom(value) |
设置固定缩放比例,或传入 fit-width 持续适合页宽。 |
pageDisplayMode |
当前页面显示模式:continuous 或 grid。 |
setPageDisplayMode(mode) |
切换连续单列或多页网格视图。 |
setPaperOrient(orientation) |
设置纸张方向。 |
setPaperSize(name) / setPaperSizeMm(widthMm, heightMm) |
按预设名或 mm 设置纸张大小。 |
setDocumentMarginsMm(pageMarginsMm) |
按 mm 设置页边距。 |
printSvg(options?) |
生成打印 SVG 页面,可独立设置页码范围和打印视图。 |
缩放和页面显示模式都是纯运行时视图状态,不进入病历 JSON,不改变 dirty、History、contentRevision 或 documentRevision,也不影响打印结果。
fit-width 以当前分页结果中最宽的实际页面为准;窗口、文档、纸张尺寸、方向或审阅栏变化后会自动重算。
grid 使用每一页的实际纸张宽高,按视口和缩放自动排列 1~4 列;窄窗口会自然退化为单列。多页网格与批注/留痕审阅侧栏互斥:打开侧栏时,编辑器会将 pageDisplayMode 切换为 continuous;侧栏开启期间调用 setPageDisplayMode('grid') 不会进入网格;关闭侧栏后也不会自动恢复网格,需要由用户重新开启。模式切换、网格缩放或窗口变化导致列数重排时,编辑器会尽量保持当前可见页及其页内位置稳定。
function configurePageView(controller: MedicalRecordEditorController): void { controller.setZoom(1.25) controller.setZoom('fit-width') controller.setZoom(controller.zoom + 0.1) controller.setZoom(controller.zoom - 0.1) controller.setPageDisplayMode('grid') controller.setPageDisplayMode('continuous')}
controller.viewOptions、编辑上下文和当前文档共享同一个运行时对象。该属性保留给读取和兼容使用;对外业务动态修改视图配置时,应调用 updateViewOptions(patch) 或已有专用 setter。Controller 会根据配置的影响范围主动重算布局、选区或绘制,不需要等鼠标事件带动刷新。不要替换 viewOptions 对象。
例如:
function configureMedicalRecordView(controller: MedicalRecordEditorController): void { controller.updateViewOptions({ documentMode: 'form', showRuler: true, showTrackChanges: true, showTrackChangesSidebar: false, showComments: true, showCommentSidebar: false, showParagraphSymbols: false, showLineBreaks: false, paragraphSymbolColor: '#667085', })}
留痕显示由两个开关组成:showTrackChanges 是正文内联留痕的总开关,showTrackChangesSidebar 控制右侧详情卡片及其布局宽度。开启内联留痕时,插入和删除留痕都参与正文排版,并保留嵌套留痕各自的显示与定位;关闭内联留痕时,删除内容不参与排版,插入内容作为接受修订后的正文继续显示。开启内联留痕但关闭侧栏时,鼠标悬停留痕文字会自动显示修改人、修改时间、操作类型和修改原因;开启侧栏后,这些信息由侧栏持续展示,正文不再重复弹出提示。关闭内联留痕会同时关闭侧栏并释放其布局宽度。旧字段 showTrackChangesTip 仍可使用,但只作为 showTrackChangesSidebar 的兼容别名;同一次 patch 同时传入新旧字段时,以 showTrackChangesSidebar 为准。
批注显示同样拆分为正文与侧栏两个开关:showComments 控制正文批注范围和 hover 信息,showCommentSidebar 控制批注与质控问题是否参与右侧审阅索引。批注侧栏默认关闭;只有正文批注已显示且业务显式开启侧栏时,编辑器才遍历全文审阅条目、计算数量并启用上一条/下一条导航。关闭正文批注会同步关闭批注侧栏;插入批注只自动显示正文批注,不会自动打开侧栏。
横向文档标尺默认显示,也可通过 editor.showRuler、构造参数 showRuler 或 updateViewOptions({ showRuler }) 控制。标尺读取当前页所属 Section 的实际纸张方向和页边距,因此混合纸张尺寸、横向页面和跨 Section 文档会按当前页切换刻度。
左侧纵向标尺默认关闭,可通过 visual.chrome.verticalRuler 独立开启。它在分页视图中跟随当前活动页显示页面高度、上下页边距以及实际页眉页脚所属 Section 的参考位置,当前仅作为只读页面投影,不修改病历文档。纵向标尺和横向标尺可分别显示或隐藏;fullPageView 连续整页模式没有物理分页边界,因此不会显示纵向标尺。
选区落在段落中且当前模式允许修改时,标尺会显示三个蓝色缩进标记:上方三角设置首行起点 indentMm,下方三角设置后续行起点 hangingMm,底部方块同时移动两者并保持间距。拖动过程仅显示位置预览和参考线,松开时通过 setParaStyle 一次性写回文档并形成历史记录;取消拖动不会修改文档。当前标尺不支持直接拖动页面左右边距。
编辑器视觉 Chrome
状态栏、活动页标签和 Section 标签属于编辑器视口,不属于病历文档,也不会进入打印结果。它们默认关闭,业务页面可按工作台空间和使用场景开启:
const editor = new RenderMedicalRecordEditor({ visual: { chrome: { statusBar: true, verticalRuler: true, pageLabels: true, sectionLabels: true, }, },})
状态栏复用框架的 RenderStatusBar,显示编辑器自身能够确定的状态:当前页/总页数、当前 Section 序号、文档模式、dirty 状态、校验问题数、留痕状态,以及页眉页脚编辑和“同前节”状态。业务接口保存成功后应调用 resetDirty(),状态栏才会从“未保存”恢复为“已保存”。患者、就诊、归档、签名等业务状态仍由外层页面显示,不应写入编辑器状态栏。
活动页优先跟随可见选区;选区所在页离开视口后,改为跟随视口中心页。Section 标签显示文档中的顺序编号,不直接暴露内部 sectionId。运行时可以增量调整:
editor.updateVisualOptions({ chrome: { statusBar: false, verticalRuler: true, pageLabels: true, sectionLabels: true, },})
showRuler 仍作为横向标尺的兼容入口保留;visual.chrome.ruler 可在创建时使用,构造参数顶层 showRuler 的优先级更高。纵向标尺只使用独立的 visual.chrome.verticalRuler 配置。
主题、文档颜色与打印边界
RenderMedicalRecordEditor 自动消费当前 PaintContext.theme。工作区、页面边界、横纵标尺、搜索浮层、页码和 Section 标签、状态栏、光标,以及选区、搜索命中、数据元轮廓和页眉页脚编辑提示都会随 runtime 主题重绘。其中选区与搜索分别使用 theme.editor.selectionBg、selectionText、searchMatchBg、searchActiveMatchBg 和 searchActiveMatchBorder。
主题只控制编辑器外壳和非打印辅助态,不改写病历文档模型。纸张仍保持打印白色;文档中的文字颜色、段落背景、表格边框、数据元值样式、水印和其他作者设置继续由文档数据决定,并原样进入打印结果。切换深色或高对比度主题不会自动反转病历正文颜色。
由于纸张固定为白色,插入光标以及页码、Section、页眉页脚编辑标签会按白色纸张单独校验对比度,而不是直接复用深色外壳的正文颜色。标签背景会先合成为不透明颜色,保证标签位于纸张内或工作区边缘时都保持可读。
通过 updateViewOptions() 明确设置的兼容视觉颜色仍优先于对应的历史默认值。选区、数据元轮廓和激活批注高亮只有在继续使用历史默认色时才映射到主题;显式业务颜色保持不变。主题投影只在绘制时创建,不会自行写回 controller.viewOptions,也不会产生文档历史记录。
自定义 CanvasElementPainter 收到的 event.viewOptions 保留正常的自有属性、枚举、对象展开和字段写回行为;主题覆盖值只影响读取结果。
独立使用公开的 DocumentCanvasPainter 时,可以选择传入同一个完整主题,使选区和编辑辅助态与应用一致;不传 theme 会保留原有 painter 视觉:
function createThemedPainter( controller: MedicalRecordEditorController, appTheme: ResolvedTheme,): DocumentCanvasPainter { return new DocumentCanvasPainter({ viewOptions: controller.viewOptions, renderCtx: controller.renderContext, selectionOverlays: controller.selectionOverlays, theme: appTheme, })}
文档模式
| 模式 | 用途 |
|---|---|
design |
模板设计,允许插入数据元、表格、批注等结构。 |
form |
表单填写,医生、护士只能修改被当前内容所有者允许编辑的数据元内容。 |
view |
只读查看。 |
业务系统应根据文书状态切换模式。例如新建模板用 design,医生、护士书写用 form,归档查看用 view。
公开 API 只接受这三个字符串。传入已移除的 edit、未知字符串或未定义的内部数值会直接抛出异常,并保持原模式不变;调用方不能把非法值当作 design 的兼容别名。模式切换会立即重新校验当前选区,最终是否可放置光标仍由选区权限闸门决定,不需要用户再次点击来触发求值。
范围删除采用整次预检、整次提交;任一适用的权限或结构准入不满足时,整次删除拒绝,文档和选区都保持不变:
design的非折叠选区删除不受数据元 authorededitable、光标可编辑状态、外部值编辑器或既有留痕所有权限制,也不受enableTrackChanges影响。完整选中的数据元或留痕节点会被物理删除;部分切入时只递归物理删除选中内容并保留外层结构,不产生新的留痕。form要求选区涉及的每个直接内容所有者都允许本次修改。scalar 文本数据元自己的editable: false保护其内部文本;composite 文本数据元的editable: false保护它直接拥有的子结构不被增删或替换。- 父 composite 锁定不会级联覆盖后代自己的内容权限:子数据元
editable: true时仍可编辑其内部内容,但不能从锁定的父 composite 中删除该子节点;删除整个子节点要求直接父 composite 可编辑。
数据元的屏幕装饰会随模式变化,但不会改变文档结构或排版几何:
view中普通数据元默认透明,已显示的校验错误仍保留红色提示。design与form使用同一套结构视觉:普通为低对比虚线,悬停为主色虚线,聚焦为主色实线。- text/date/list 等文本型数据元使用底部线条;checkbox、图片、条码和医疗图形等原子数据元使用局部 outline。
Canvas 与屏幕 SVG 路径共享同一份视觉状态解析规则,但各自保留独立几何绘制。数据元显式下划线优先使用 valueTextProps.color,没有配置时才回退到文档默认文字颜色;这条文档格式与数据元的编辑辅助底线是两个不同概念。
Checkbox 的未选中状态是合法布尔值,不按“空字段”处理。若业务要求一组选项至少选择一项,应基于 groupName 在业务校验或选项组规则中判断,不能要求每个 checkbox 都被选中。
composite 文本数据元用于承载文本和嵌套数据元,不代替具体子字段本身的状态:
view中默认透明。design与form中默认绘制低对比虚线 outline,悬停时加深,自身或任一后代数据元聚焦时改为实线。- 空值、必填、错误和书写留痕不改变 composite 轮廓;校验错误由具体数据元的红色波浪线表达。
- 开启书写变化标记时,变化的 composite 或子数据元使用独立琥珀色角标;
view和打印输出不包含该角标。
composite 文本数据元换行时可对应同一 paragraph render 中的多个 Render Object fragments。轮廓几何只合并当前 paragraph render 中的 fragments,不把不同页面的坐标连成一个轮廓。
留痕和批注
留痕和批注是两个不同概念:
| 类型 | 作用 |
|---|---|
| 留痕 | 记录正文内容变更。 |
| 批注 | 对一段正文提出意见、质控问题或协作说明。 |
开启留痕后,日期和列表数据元的整体赋值按当前文档打开会话的净变化记录:会话初始值非空且最终值不同时,保留一条初始值删除留痕和一条最终值新增留痕;初始值为空时只记录最终新增;最终清空时只记录初始值删除;最终回到初始值时不保留无效留痕。同一会话内的中间赋值不会各自形成留痕,已存在的历史留痕不受影响。
推荐业务规则:
- 是否启用留痕由业务状态决定,例如归档后修改、上级医生修改或特定流程节点。
- 同一操作员连续修改同一字段时,可以在业务层合并保存轨迹,避免轨迹膨胀。
- 批注创建后,当前打开会话内继续编辑批注正文可合并到创建记录;不同打开会话再次修改应形成修改审计。
- 如果批注关联正文被修改,应标记
anchorChanged;如果锚点被删除,应标记orphaned。 - 批注状态流转应走业务命令,例如处理、解决、重开、关闭。
审阅侧栏由编辑器渲染,业务页面可以通过:
const editor = new RenderMedicalRecordEditor() editor.setReviewFilter({ type: 'comment', status: 'active',})editor.activateNextReview()
筛选类型:
| 类型 | 说明 |
|---|---|
all |
全部审阅项。 |
comment |
普通批注。 |
quality |
质控问题。 |
track |
留痕。 |
性能策略
病历编辑器的大文档性能依赖三点:
- 编辑器自己管理滚动和可视页绘制。
- 输入时优先尝试快速 patch,当前段落高度不变时不全量分页。
- 选区绘制基于全局范围坐标投影到可视页,不展开全量选区树。
接入时不要破坏这些前提:
- 不要在外层套同方向滚动容器。
- 不要每次输入都
loadDocument()。 - 不要 hover 时刷新整个业务页面。
- 不要把大文档、图片缓存或控制器放到全局上下文。
- 不要在 popup 或批注卡片里创建无法释放的订阅。
大文档问题优先用 Runtime Diagnostics 观察 layout、paint、frame 和事件耗时,再看编辑器自己的 debug state。
内存释放
页面关闭时建议执行:
class MedicalRecordPage { private localInput: HTMLInputElement | null = null readonly editor = new RenderMedicalRecordEditor() dispose(): void { if (this.localInput) { this.localInput.onchange = null this.localInput.remove() this.localInput = null } this.editor.dispose() }}
释放检查:
- 页面是否还被 workspace、tab manager 或业务缓存引用。
- editor 是否 dispose;默认由 editor 一并释放内部 controller。
- 本地文件 input、popup、context menu 是否移除。
- 图片、条码、二维码缓存是否属于文档上下文,而不是全局。
- 异步模板加载返回后是否判断页面已关闭。
打印和 SVG
Canvas 模式用于实时编辑;打印模式可以走 SVG。二者是不同输出路径:
| 模式 | 目标 |
|---|---|
| Canvas 编辑 | 输入、拖选、弹层、审阅交互必须流畅。 |
| SVG 打印 | 分页、纸张、条码、二维码、留痕和批注输出必须稳定。 |
使用:
function printMedicalRecord(printDocument: MedicalRecordDocument) { const controller = new MedicalRecordEditorController() try { controller.loadDocument(printDocument) return controller.printSvg({ ranges: null, view: { mode: 'view', showTrackChanges: false, showComments: false, }, }) } finally { controller.dispose() }}
这是无界面的打印服务,因此可以直接使用 controller。存在可交互编辑器时,仍以 editor 作为页面入口。
printSvg() 返回每页 SVG 字符串。打印运行时默认使用 view 模式并隐藏留痕、批注;它不会隐式复制编辑器的 viewOptions,也不会回写编辑状态。业务可以在同一 controller 上通过每次打印的 view 参数独立配置,也可以使用专用 controller 组织打印预览、PDF 转换、盖章、签名或归档。showComments: true 只在打印正文中输出批注锚定范围的背景标记,不输出编辑器的审阅侧栏、悬浮提示或批注卡片。传入页码数组的旧调用方式仍然可用,新代码推荐使用 options 对象。
调试入口
常用调试方法:
| 方法 | 说明 |
|---|---|
editor.debugState() |
查看页数、活动页/Section、视觉 Chrome、视窗、滚动、popup、审阅筛选等状态。 |
editor.state |
页面级只读状态,包括 dirty、内容修改序号、模式、禁用和焦点。 |
editor.isDirty |
自最近一次修改基线后是否发生过内容修改。 |
editor.contentRevision |
可序列化内容的单调修改序号。 |
controller.revision |
任意 UI 状态或选区变化版本。 |
controller.documentRevision |
文档刷新版本。 |
controller.contentRevision |
与 editor.contentRevision 相同,供高级 controller 接入使用。 |
controller.getToolbarState() |
当前工具栏状态。 |
controller.getSelectedText() |
当前语义化选区的纯文本;无有效内容时返回空字符串。 |
controller.getSelectedJSON() |
当前语义化选区的版本化元素片段 JSON;无有效内容时返回空字符串。 |
调试建议:
- 光标、hover、popup 卡顿先看运行时诊断面板。
- 输入卡顿先区分是 layout、paint、输入承接控件还是业务页面重建。
- 关闭页面后内存不下降,生成 heap snapshot 检查 controller、document、canvas、popup、input 是否仍被引用。
常见错误
把编辑器放在外层 ScrollViewer 里
病历编辑器自己管理滚动。外层同方向滚动会导致滚轮、popup 定位、审阅块和横向滚动范围不稳定。
每次切换模板都复用旧页面状态
加载新文档后应刷新工具栏、审阅筛选和焦点;如果业务希望滚动到顶部,可以调用 loadDoc(data) 或 nativeScrollTop(0)。
把页面草稿和当前文书放到应用级上下文
当前文书应放页面或 tab 上下文。应用级上下文只放登录用户、租户、权限服务、字典服务等长期共享对象。
批注只当作黄色背景
批注是业务协作信息,应该有作者、时间、状态、审计、锚点状态和后续处理动作。
日期数据元允许任意键盘输入
日期数据元应通过日期弹层或明确的日期输入段录入,避免普通文本输入破坏数据结构。
忽略打印模式验证
Canvas 编辑正常不代表打印正常。条码、二维码、分页、批注、留痕都要在打印 SVG 路径单独验证。