病历编辑器接入
本文示例使用以下公共入口;业务类型和服务仍由你的应用定义:
import { MedicalRecordEditorController, RenderMedicalRecordEditor, type MedicalRecordDocument,} from 'ds-ui'
病历编辑器是 DirectSurface UI 中最复杂的业务组件之一。它不是普通富文本控件,而是面向电子病历场景的文档编辑器,包含分页排版、数据元、表格、批注、留痕、审阅侧栏、弹层编辑、打印 SVG 和大文档性能优化。
本文面向业务系统开发者,说明如何把编辑器接入页面、如何组织文档数据、哪些能力由编辑器负责、哪些规则应由业务系统负责。
能力边界
编辑器负责:
- Canvas 实时编辑和绘制。
- 病历文档分页、缩放、滚动和可视页绘制。
- 文本输入、中文 IME、粘贴、拖选、复制和光标。
- 段落格式、字体、颜色、项目符号、编号、缩进。
- 表格插入、合并、拆分、行列增删、边框和斜线。
- 日期、下拉、多选、数据组等数据元的弹层编辑。
- 批注和质控问题的创建、展示、状态流转和锚点状态。
- 留痕显示、留痕提示、审阅项导航和筛选。
- 打印模式下输出 SVG 节点。
业务系统负责:
- 登录用户、当前患者、当前就诊、文书状态和权限判断。
- 是否允许编辑、是否启用留痕、是否显示批注和审阅块。
- 文档 JSON 的加载、保存、归档、版本和服务端审计。
- 模板库、病历分类树、文书实例列表和接口错误处理。
- 打印流程、打印预览、PDF 生成、归档签名和外部质控流程。
编辑器本身不判断病历业务状态。业务应根据流程状态和权限设置 controller/view options,而不是把“已归档不能编辑”“上级医生才能审核”等规则写进底层编辑器。
最小接入
const controller = new MedicalRecordEditorController()const editor = new RenderMedicalRecordEditor({ controller,})
页面关闭时必须释放 controller。推荐页面持有 controller,编辑器只作为页面 render tree 的子节点:
class MedicalRecordPage { readonly controller = new MedicalRecordEditorController() readonly editor = new RenderMedicalRecordEditor({ controller: this.controller, }) dispose(): void { this.controller.dispose() }}
如果页面本身也是 RenderObject,通常在页面 dispose() 里先清理页面创建的 DOM、订阅和异步请求,再调用 super.dispose(),最后释放 controller。不要把 controller 放到应用级单例里长期复用。
页面结构
典型病历编辑页面由“模板树 + 工具栏 + 编辑器”组成:
RenderStackPanel vertical
RenderToolbar
RenderSplitter horizontal
left: 模板树 / 文书目录 / 大纲
right: RenderMedicalRecordEditor
实现要点:
- 病历编辑器自己管理滚动,不要再套一层同方向
ScrollViewer。 - 左侧模板树适合用
RenderTreeView,长文本要有省略和 tooltip。 - 工具栏应读取
controller.getToolbarState(),并在 controllerchange事件后刷新。 - 大文档下不要在每次输入后重建整个页面 render tree。
文档数据
MedicalRecordDocument 可以是:
| 输入类型 | 说明 |
|---|---|
| 已反序列化的病历 JSON 对象 | 业务接口和模板库最常用。 |
| JSON 字符串 | 适合本地文件或旧接口返回字符串。 |
| 底层文档模型对象 | 高级扩展场景使用,普通业务优先传入已反序列化的病历 JSON 对象。 |
加载文档:
async function loadMedicalRecord(url: string): Promise<void> { const controller = new MedicalRecordEditorController() const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status}`) const document = await response.json() as MedicalRecordDocument controller.loadDocument(document)}
加载本地 JSON:
async function loadLocalMedicalRecord(file: File): Promise<void> { const controller = new MedicalRecordEditorController() const text = await file.text() const document = JSON.parse(text) as MedicalRecordDocument controller.loadDocument(document)}
保存文档:
const controller = new MedicalRecordEditorController()const schema = controller.serialize()const json = JSON.stringify(schema)
推荐保存口径:
- 页面只保存
controller.serialize()的结果。 - 不保存临时可视状态,例如滚动条位置、当前 hover、popup 状态。
- 是否保存留痕、批注、审计轨迹取决于文档 JSON 结构和业务服务约定。
- 保存前可以先执行
controller.validate(),把必填数据元或业务校验问题展示给用户。
模板库接入
模板库通常不是编辑器能力,而是业务页面组合:
模板索引接口
-> TreeView roots
-> 点击模板节点
-> fetch 模板 JSON
-> controller.loadDocument(document)
推荐模板索引字段:
| 字段 | 说明 |
|---|---|
id |
模板唯一标识。 |
name |
模板显示名称。 |
children |
子分类或子模板。 |
hasDocument |
当前节点是否能加载文档。 |
documentPath |
静态模板 JSON 或接口地址。 |
parseError |
模板解析失败时用于提示。 |
模板树只负责选择模板。真正加载文档后,应调用 controller.loadDocument(),然后把焦点交回编辑器。
工具栏和状态同步
工具栏不要自己猜当前选区状态,应使用:
const controller = new MedicalRecordEditorController()const state = controller.getToolbarState()
常用状态:
| 字段 | 说明 |
|---|---|
canUndo / canRedo |
撤销重做是否可用。 |
fontName / fontSize |
当前输入或选区字体。 |
color / background |
当前文字颜色和背景色。 |
bold / italic / underline / linethrough |
当前文本样式。 |
subscript / superscript |
上下标状态。 |
textAlign |
当前段落对齐。 |
paragraphNumberType |
none、ul、ol。 |
enableTrackChanges |
是否启用留痕。 |
showTrackChanges |
是否显示留痕块。 |
showTrackChangesTip |
是否显示留痕提示。 |
showComment |
是否显示批注。 |
documentMode |
当前文档模式。 |
监听变化:
const controller = new MedicalRecordEditorController()const subscription = controller.on('change', () => { const state = controller.getToolbarState() state.canUndo}) subscription.unsubscribe()
页面 dispose 时要释放订阅。也可以直接在页面 dispose 中调用 controller.off('change') 清理页面注册的 handler。
常用编辑 API
文档和历史
| API | 说明 |
|---|---|
loadDocument(document) |
加载病历文档,重建分页、清理历史基线。 |
loadDoc(data, { autoScrollToTop? }) |
兼容旧 API,默认加载后滚动到顶部。 |
serialize() |
序列化当前文档。 |
serializeString() |
序列化为字符串。 |
validate() |
执行文档校验。 |
undo() / redo() |
撤销、重做。 |
canUndo() / canRedo() |
历史状态判断。 |
beginHistoryStep() / commitHistoryStep() |
需要把一组外部操作合并成一步历史时使用。 |
文本和段落
| API | 说明 |
|---|---|
setTextFormat(props) |
设置选区或当前输入文字属性。 |
setTextFormatByFn(setter) |
按函数修改文字属性。 |
adjustFontSize(delta) |
增减字号。 |
setParaStyle(props) |
设置段落属性。 |
setParaStyleByFn(setter) |
按函数修改段落属性。 |
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 | 说明 |
|---|---|
insertElementSchema(schema) |
插入一个序列化元素,例如文本数据元、日期数据元、列表数据元。 |
getCurrentDataElement() |
获取当前数据元。 |
getCurrentDataGroupElement() |
获取当前数据组。 |
setCurrentDataElementValue(value) |
设置当前数据元值。 |
moveFocusToNextDataElement() |
跳到下一个数据元。 |
getDataElementPosition(element) |
获取数据元装饰位置,用于弹层或按钮定位。 |
日期、下拉、多选、树形选择等数据元的编辑 UI 由编辑器内置弹层处理。业务如果需要自定义弹层,应基于实际 render rect 定位,不能基于理想文本宽度定位。
视图和打印
| API | 说明 |
|---|---|
setDocumentMode(mode) |
设置 design、edit、form、view。 |
setTrackChangesEnabled(enabled) |
启用或关闭留痕。 |
setShowTrackChanges(enabled) |
显示或隐藏留痕块。 |
setShowTrackChangesTip(enabled) |
显示或隐藏留痕提示。 |
setShowComment(enabled) |
显示或隐藏批注。 |
scale(scale) |
设置缩放,范围由编辑器限制。 |
switchPageLayout(mode) |
切换页面布局模式。 |
setPaperOrient(orientation) |
设置纸张方向。 |
setPaperSize(name) / setPaperSize(width, height) |
设置纸张大小。 |
setDocumentMargin(margin) |
设置页边距。 |
printSvg(ranges?) |
生成打印 SVG 页面。 |
文档模式
| 模式 | 用途 |
|---|---|
design |
模板设计,允许插入数据元、表格、批注等结构。 |
edit |
业务编辑,适合医生书写。 |
form |
表单填写,强调数据元录入。 |
view |
只读查看。 |
业务系统应根据文书状态切换模式。例如新建模板用 design,医生书写用 edit 或 form,归档查看用 view。
留痕和批注
留痕和批注是两个不同概念:
| 类型 | 作用 |
|---|---|
| 留痕 | 记录正文内容变更。 |
| 批注 | 对一段正文提出意见、质控问题或协作说明。 |
推荐业务规则:
- 是否启用留痕由业务状态决定,例如归档后修改、上级医生修改或特定流程节点。
- 同一操作员连续修改同一字段时,可以在业务层合并保存轨迹,避免轨迹膨胀。
- 批注创建后,当前打开会话内继续编辑批注正文可合并到创建记录;不同打开会话再次修改应形成修改审计。
- 如果批注关联正文被修改,应标记
anchorChanged;如果锚点被删除,应标记orphaned。 - 批注状态流转应走业务命令,例如处理、解决、重开、关闭。
审阅侧栏由编辑器渲染,业务页面可以通过:
const controller = new MedicalRecordEditorController()const editor = new RenderMedicalRecordEditor({ controller,}) 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 controller = new MedicalRecordEditorController() dispose(): void { if (this.localInput) { this.localInput.onchange = null this.localInput.remove() this.localInput = null } this.controller.dispose() }}
释放检查:
- 页面是否还被 workspace、tab manager 或业务缓存引用。
- controller 是否 dispose。
- 本地文件 input、popup、context menu 是否移除。
- 图片、条码、二维码缓存是否属于文档上下文,而不是全局。
- 异步模板加载返回后是否判断页面已关闭。
打印和 SVG
Canvas 模式用于实时编辑;打印模式可以走 SVG。二者是不同输出路径:
| 模式 | 目标 |
|---|---|
| Canvas 编辑 | 输入、拖选、弹层、审阅交互必须流畅。 |
| SVG 打印 | 分页、纸张、条码、二维码、留痕和批注输出必须稳定。 |
使用:
const controller = new MedicalRecordEditorController()const pages = controller.printSvg()
printSvg() 返回每页 SVG 字符串。业务系统可以继续做打印预览、PDF 转换、盖章、签名或上传归档。
调试入口
常用调试方法:
| 方法 | 说明 |
|---|---|
editor.debugState() |
查看页数、视窗、滚动、popup、审阅筛选等状态。 |
controller.revision |
任意 UI 状态或选区变化版本。 |
controller.documentRevision |
文档刷新版本。 |
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 路径单独验证。