feat(emr): 病历版本管理

- V64 Flyway迁移: emr_version表(不可删除, 三甲评审)
- EmrVersion实体: 版本号递增, 内容快照, 差异记录
- EmrVersionMapper + XML: selectByEmrId/selectLatest
- IEmrVersionService + impl: 基础CRUD
- IEmrVersionAppService + impl: saveVersion/getVersions/compareVersions
- EmrVersionController: POST /emr/version/save, GET /emr/version/list/{emrId}, GET /emr/version/compare
- 前端API: saveEmrVersion/getEmrVersionList/compareEmrVersions
- EmrVersionCompare.vue: 版本列表+对比视图
This commit is contained in:
2026-06-17 13:28:34 +08:00
parent 6184ed262f
commit f3a24a9129
11 changed files with 543 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
package com.healthlink.his.web.emr.appservice;
import com.healthlink.his.emr.domain.EmrVersion;
import java.util.List;
import java.util.Map;
public interface IEmrVersionAppService {
EmrVersion saveVersion(EmrVersion version);
List<EmrVersion> getVersions(Long emrId);
Map<String, Object> compareVersions(Long versionId1, Long versionId2);
}

View File

@@ -0,0 +1,92 @@
package com.healthlink.his.web.emr.appservice.impl;
import com.healthlink.his.emr.domain.EmrVersion;
import com.healthlink.his.emr.service.IEmrVersionService;
import com.healthlink.his.web.emr.appservice.IEmrVersionAppService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class EmrVersionAppServiceImpl implements IEmrVersionAppService {
@Resource
private IEmrVersionService emrVersionService;
@Override
@Transactional(rollbackFor = Exception.class)
public EmrVersion saveVersion(EmrVersion version) {
EmrVersion latest = emrVersionService.selectLatest(version.getEmrId());
int nextNumber = (latest != null) ? latest.getVersionNumber() + 1 : 1;
version.setVersionNumber(nextNumber);
if (latest != null) {
version.setContentDiff(computeDiff(latest.getContentSnapshot(), version.getContentSnapshot()));
if (version.getEncounterId() == null) {
version.setEncounterId(latest.getEncounterId());
}
}
version.setCreateTime(new Date());
emrVersionService.save(version);
return version;
}
@Override
public List<EmrVersion> getVersions(Long emrId) {
return emrVersionService.selectByEmrId(emrId);
}
@Override
public Map<String, Object> compareVersions(Long versionId1, Long versionId2) {
EmrVersion v1 = emrVersionService.getById(versionId1);
EmrVersion v2 = emrVersionService.getById(versionId2);
if (v1 == null || v2 == null) {
throw new IllegalArgumentException("版本记录不存在");
}
String content1 = v1.getContentSnapshot() != null ? v1.getContentSnapshot() : "";
String content2 = v2.getContentSnapshot() != null ? v2.getContentSnapshot() : "";
String diff = computeDiff(content1, content2);
Map<String, Object> result = new LinkedHashMap<>();
result.put("version1", v1);
result.put("version2", v2);
result.put("diff", diff);
return result;
}
private String computeDiff(String oldContent, String newContent) {
if (oldContent == null) oldContent = "";
if (newContent == null) newContent = "";
if (oldContent.equals(newContent)) {
return "";
}
StringBuilder diff = new StringBuilder();
String[] oldLines = oldContent.split("\n");
String[] newLines = newContent.split("\n");
int maxLen = Math.max(oldLines.length, newLines.length);
for (int i = 0; i < maxLen; i++) {
String oldLine = i < oldLines.length ? oldLines[i] : "";
String newLine = i < newLines.length ? newLines[i] : "";
if (!oldLine.equals(newLine)) {
if (!oldLine.isEmpty()) {
diff.append("- ").append(oldLine).append("\n");
}
if (!newLine.isEmpty()) {
diff.append("+ ").append(newLine).append("\n");
}
}
}
return diff.toString();
}
}

View File

@@ -0,0 +1,44 @@
package com.healthlink.his.web.emr.controller;
import com.core.common.core.domain.R;
import com.healthlink.his.emr.domain.EmrVersion;
import com.healthlink.his.web.emr.appservice.IEmrVersionAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/emr/version")
@Slf4j
@AllArgsConstructor
@Tag(name = "病历版本管理")
public class EmrVersionController {
private final IEmrVersionAppService emrVersionAppService;
@PostMapping("/save")
@PreAuthorize("@ss.hasPermi('inpatient:emr:edit')")
@Operation(summary = "保存病历版本")
public R<EmrVersion> saveVersion(@RequestBody EmrVersion version) {
return R.ok(emrVersionAppService.saveVersion(version));
}
@GetMapping("/list/{emrId}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@Operation(summary = "获取病历版本列表")
public R<?> getVersions(@PathVariable Long emrId) {
return R.ok(emrVersionAppService.getVersions(emrId));
}
@GetMapping("/compare")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@Operation(summary = "对比两个版本")
public R<?> compareVersions(
@RequestParam("versionId1") Long versionId1,
@RequestParam("versionId2") Long versionId2) {
return R.ok(emrVersionAppService.compareVersions(versionId1, versionId2));
}
}

View File

@@ -0,0 +1,26 @@
-- V64: 病历版本管理
CREATE TABLE IF NOT EXISTS emr_version (
id BIGSERIAL PRIMARY KEY,
emr_id BIGINT NOT NULL,
encounter_id BIGINT NOT NULL,
version_number INT NOT NULL DEFAULT 1,
content_snapshot TEXT,
content_diff TEXT,
emr_type VARCHAR(50),
emr_title VARCHAR(200),
operator_id BIGINT,
operator_name VARCHAR(64),
save_reason VARCHAR(200),
tenant_id BIGINT DEFAULT 0,
delete_flag VARCHAR(1) DEFAULT '0',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE emr_version IS '病历版本管理(不可删除,三甲评审要求)';
COMMENT ON COLUMN emr_version.version_number IS '版本号,递增';
COMMENT ON COLUMN emr_version.content_snapshot IS '完整内容快照';
COMMENT ON COLUMN emr_version.content_diff IS '与上一版本的差异';
COMMENT ON COLUMN emr_version.save_reason IS '保存原因';
CREATE INDEX IF NOT EXISTS idx_ev_emr ON emr_version(emr_id);
CREATE INDEX IF NOT EXISTS idx_ev_encounter ON emr_version(encounter_id);
CREATE INDEX IF NOT EXISTS idx_ev_version ON emr_version(emr_id, version_number);

View File

@@ -0,0 +1,37 @@
package com.healthlink.his.emr.domain;
import com.baomidou.mybatisplus.annotation.*;
import com.core.common.core.domain.HisBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("emr_version")
@Accessors(chain = true)
public class EmrVersion extends HisBaseEntity {
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
private Long emrId;
private Long encounterId;
private Integer versionNumber;
private String contentSnapshot;
private String contentDiff;
private String emrType;
private String emrTitle;
private Long operatorId;
private String operatorName;
private String saveReason;
}

View File

@@ -0,0 +1,16 @@
package com.healthlink.his.emr.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.healthlink.his.emr.domain.EmrVersion;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface EmrVersionMapper extends BaseMapper<EmrVersion> {
List<EmrVersion> selectByEmrId(@Param("emrId") Long emrId);
EmrVersion selectLatest(@Param("emrId") Long emrId);
}

View File

@@ -0,0 +1,13 @@
package com.healthlink.his.emr.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.emr.domain.EmrVersion;
import java.util.List;
public interface IEmrVersionService extends IService<EmrVersion> {
List<EmrVersion> selectByEmrId(Long emrId);
EmrVersion selectLatest(Long emrId);
}

View File

@@ -0,0 +1,25 @@
package com.healthlink.his.emr.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.emr.domain.EmrVersion;
import com.healthlink.his.emr.mapper.EmrVersionMapper;
import com.healthlink.his.emr.service.IEmrVersionService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class EmrVersionServiceImpl
extends ServiceImpl<EmrVersionMapper, EmrVersion>
implements IEmrVersionService {
@Override
public List<EmrVersion> selectByEmrId(Long emrId) {
return baseMapper.selectByEmrId(emrId);
}
@Override
public EmrVersion selectLatest(Long emrId) {
return baseMapper.selectLatest(emrId);
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.healthlink.his.emr.mapper.EmrVersionMapper">
<select id="selectByEmrId" resultType="com.healthlink.his.emr.domain.EmrVersion">
SELECT * FROM emr_version
WHERE emr_id = #{emrId} AND delete_flag = '0'
ORDER BY version_number DESC
</select>
<select id="selectLatest" resultType="com.healthlink.his.emr.domain.EmrVersion">
SELECT * FROM emr_version
WHERE emr_id = #{emrId} AND delete_flag = '0'
ORDER BY version_number DESC
LIMIT 1
</select>
</mapper>

View File

@@ -9,3 +9,7 @@ export function getEmrRevisionList(emrId) { return request({ url: "/emr/revision
export function getEmrRevisionPage(params) { return request({ url: "/emr/revision/page", method: "get", params }) }
export function getEmrRevisionDetail(id) { return request({ url: "/emr/revision/" + id, method: "get" }) }
export function compareEmrRevisions(id1, id2) { return request({ url: "/emr/revision/compare", method: "get", params: { revisionId1: id1, revisionId2: id2 } }) }
export function saveEmrVersion(data) { return request({ url: "/emr/version/save", method: "post", data }) }
export function getEmrVersionList(emrId) { return request({ url: "/emr/version/list/" + emrId, method: "get" }) }
export function compareEmrVersions(id1, id2) { return request({ url: "/emr/version/compare", method: "get", params: { versionId1: id1, versionId2: id2 } }) }

View File

@@ -0,0 +1,251 @@
<template>
<div class="emr-version-compare">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>历史版本管理</span>
<div>
<el-button
v-if="compareIds.length === 2"
type="primary"
size="small"
@click="handleCompare"
>
对比选中版本
</el-button>
<el-button
type="info"
size="small"
@click="handleRefresh"
>
刷新
</el-button>
</div>
</div>
</template>
<el-table
v-if="versions.length > 0"
:data="versions"
border
size="small"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="40" />
<el-table-column label="版本号" width="90" align="center">
<template #default="{ row }">
<el-tag size="small">v{{ row.versionNumber }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="emrTitle" label="病历标题" show-overflow-tooltip />
<el-table-column prop="operatorName" label="操作人" width="100" />
<el-table-column prop="saveReason" label="保存原因" show-overflow-tooltip />
<el-table-column prop="createTime" label="保存时间" width="170" />
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewDetail(row)">
查看
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无历史版本" />
</el-card>
<el-dialog
v-model="detailVisible"
title="版本详情"
width="700px"
destroy-on-close
top="5vh"
>
<div v-if="currentVersion" class="detail-content">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="版本号">
v{{ currentVersion.versionNumber }}
</el-descriptions-item>
<el-descriptions-item label="操作人">
{{ currentVersion.operatorName }}
</el-descriptions-item>
<el-descriptions-item label="保存时间">
{{ currentVersion.createTime }}
</el-descriptions-item>
<el-descriptions-item label="保存原因">
{{ currentVersion.saveReason || '-' }}
</el-descriptions-item>
<el-descriptions-item label="病历ID">
{{ currentVersion.emrId }}
</el-descriptions-item>
<el-descriptions-item label="就诊ID">
{{ currentVersion.encounterId }}
</el-descriptions-item>
</el-descriptions>
<div v-if="currentVersion.contentSnapshot" style="margin-top: 16px">
<div class="diff-label">内容快照</div>
<pre class="detail-pre">{{ currentVersion.contentSnapshot }}</pre>
</div>
<div v-if="currentVersion.contentDiff" style="margin-top: 16px">
<div class="diff-label">与上一版本差异</div>
<pre class="detail-pre diff-text">{{ currentVersion.contentDiff }}</pre>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="compareVisible"
title="版本对比"
width="900px"
destroy-on-close
top="5vh"
>
<div v-if="compareData" class="compare-content">
<el-row :gutter="20">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>版本 #{{ compareData.version1?.versionNumber }} {{ compareData.version1?.operatorName }}</span>
</template>
<pre class="compare-pre">{{ compareData.version1?.contentSnapshot || '无快照' }}</pre>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>版本 #{{ compareData.version2?.versionNumber }} {{ compareData.version2?.operatorName }}</span>
</template>
<pre class="compare-pre">{{ compareData.version2?.contentSnapshot || '无快照' }}</pre>
</el-card>
</el-col>
</el-row>
<div v-if="compareData.diff" style="margin-top: 16px">
<el-card shadow="never">
<template #header>
<span>差异详情</span>
</template>
<pre class="compare-pre diff-text">{{ compareData.diff }}</pre>
</el-card>
</div>
</div>
<template #footer>
<el-button @click="compareVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getEmrVersionList, compareEmrVersions } from '@/api/emr'
const props = defineProps({
emrId: { type: [Number, String], default: null }
})
const loading = ref(false)
const versions = ref([])
const compareIds = ref([])
const detailVisible = ref(false)
const compareVisible = ref(false)
const currentVersion = ref(null)
const compareData = ref(null)
function loadVersions() {
if (!props.emrId) return
loading.value = true
getEmrVersionList(props.emrId).then(res => {
versions.value = res.data || []
}).catch(() => {
ElMessage.error('查询版本记录失败')
}).finally(() => {
loading.value = false
})
}
function handleRefresh() {
compareIds.value = []
loadVersions()
}
function handleSelectionChange(selection) {
compareIds.value = selection.map(item => item.id)
}
function handleViewDetail(item) {
currentVersion.value = item
detailVisible.value = true
}
function handleCompare() {
if (compareIds.value.length !== 2) {
ElMessage.warning('请选择两个版本进行对比')
return
}
loading.value = true
compareEmrVersions(compareIds.value[0], compareIds.value[1]).then(res => {
compareData.value = res.data
compareVisible.value = true
}).catch(() => {
ElMessage.error('对比查询失败')
}).finally(() => {
loading.value = false
})
}
watch(() => props.emrId, () => { loadVersions() })
onMounted(() => { loadVersions() })
</script>
<style scoped>
.emr-version-compare {
padding: 12px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.diff-label {
font-size: 13px;
color: #909399;
margin-bottom: 4px;
}
.detail-content {
max-height: 60vh;
overflow-y: auto;
}
.detail-pre {
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.diff-text {
color: #e6a23c;
}
.compare-content {
max-height: 65vh;
overflow-y: auto;
}
.compare-pre {
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
max-height: 50vh;
overflow-y: auto;
}
</style>