【EMR 编辑器 · 留痕与关键词详解】
谁改的、何时改的、改了什么 · 不可篡改审计日志 · 痕迹预览 · 关键词质控
医疗文书有合规要求:修改必须可追溯。编辑器内置两大质控能力——操作留痕(审计)与关键词检查,都随文档 JSON 持久化,不需要后端额外建表。
【一、操作留痕:开启就会记账】
api.setAuditTrail(true); // 工具栏「留痕▾」面板也可开
开启后,任何对文档的修改都会自动记一条日志:谁、何时、在哪、把什么改成了什么。连「开启留痕」这个动作本身也会被记录——防止有人先关掉留痕再改。
日志的四个关键性质(合规的底气所在):
仅追加:没有任何操作能改写或删除已有日志条目(对象冻结);
undo 不回滚:Ctrl+Z 撤销正文内容,但日志里「改过」的事实永远保留;
随文档持久化:日志在 Doc.auditLog 字段里,保存 JSON = 保存日志,加载即恢复;
时间窗合并:同一块 5 秒内的连续同类操作(如连打一串字)合并为一条、保留首次快照,日志不会被刷屏。
记录的身份从哪来?来自 user prop(或 api.setUser)传入的当前登录用户 { id, name }。用户切换时传入新值即可,日志按人分开记。
【二、日志长什么样】
const log = api.getAuditLog(); // 只读副本,可直接渲染成审计报表
// 一条典型的日志条目:
{
"id": "...", "seq": 7, // 唯一 id + 文档内递增序号
"at": 1784600000000, // 操作毫秒时间戳
"by": "1001", "name": "张医生", // 操作人(user prop)
"action": "text-change", // 操作类型(见下表)
"blockId": "blk_12", "where": "表格(2,1)",
"oldText": "体温36.5", "newText": "体温37.2"
}
action 的 10 种取值:text-change(改字)、block-insert / block-remove / block-update(增 / 删 / 改块)、data-apply(数据赋值 / 回填)、source-change(数据源定义变化)、config-change(改页面配置)、doc-load(加载文档)、undo / redo(撤销重做)。文本快照截断至 200 字。
【三、痕迹预览:把日志画回正文】
api.setAuditPreview(true);
开启后正文直接标注修改痕迹:按操作类型着色(插入绿、删除红、修改橙等),鼠标悬浮显示「修改人 / 时间 / 原文 → 新文」——像 Word 修订模式一样直观。预览只是渲染层效果,不影响日志数据,也不影响打印导出(导出的是干净版本)。工具栏「留痕▾」面板可开关预览、查看日志列表。
【四、关键词检查:写错立刻红】
质控规则举例:文书里出现「发热」就必须写明体温数值。关键词检查就是这类规则的载体——正文命中关键词的文本红色标记 + 红色下划线,鼠标悬浮弹出提示词,提醒医生补写。
api.setKeywordCheck(true); // 开启(配置随文档保存)
// {kw} 占位符会被替换为实际命中的词
api.addKeyword({ keyword: '发热', tip: '出现「{kw}」请记录具体体温数值' });
api.addKeyword({ keyword: '过敏', tip: '出现「{kw}」须注明过敏原' });
const hits = api.keywordHits(); // ['发热'] 正文实际命中的词(去重)
长词优先匹配、重叠去重(「药物过敏」和「过敏」同时配置时命中长词);
仅编辑模式显示,打印 / PDF 导出不受影响(导出的永远是干净文书);
关键词列表随文档持久化(config.keywordList);管理 API:getKeywords / updateKeyword / removeKeyword;
keywordHits() 适合「保存前校验」:命中非空时弹确认框,防止漏项归档。
【五、保存前质控组合拳】
把留痕 + 关键词 + 录入域组合成「保存守门员」的完整示例(提交前查三件事:关键词命中、必填域空缺、留痕开启):
async function beforeSubmit() {
const hits = api.keywordHits();
if (hits.length && !confirm(`命中质控关键词:${hits.join('、')},仍要提交?`))
return false;
const entry = api.getEntryValues(); // 检查必填录入域
if (!entry.chief?.chiefComplaint) { alert('主诉未填写'); return false; }
if (!api.isAuditTrailEnabled()) api.setAuditTrail(true); // 兜底开留痕
return true;
}