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:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 } }) }
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user