feat(emr): 病历修改留痕 — AppService/Controller/前端组件

This commit is contained in:
2026-06-17 13:22:02 +08:00
parent f0d20a8d79
commit 6184ed262f
7 changed files with 384 additions and 29 deletions

View File

@@ -0,0 +1,14 @@
package com.healthlink.his.web.emr.appservice;
import com.healthlink.his.emr.domain.EmrRevision;
import java.util.List;
public interface IEmrRevisionAppService {
EmrRevision recordRevision(EmrRevision revision);
List<EmrRevision> getRevisions(Long emrId);
EmrRevision getRevisionDetail(Long id);
}

View File

@@ -0,0 +1,43 @@
package com.healthlink.his.web.emr.appservice.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.emr.domain.EmrRevision;
import com.healthlink.his.emr.service.IEmrRevisionService;
import com.healthlink.his.web.emr.appservice.IEmrRevisionAppService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
@Service
public class EmrRevisionAppServiceImpl implements IEmrRevisionAppService {
@Resource
private IEmrRevisionService emrRevisionService;
@Override
@Transactional(rollbackFor = Exception.class)
public EmrRevision recordRevision(EmrRevision revision) {
EmrRevision latest = emrRevisionService.selectLatest(revision.getEmrId());
int nextNumber = (latest != null) ? latest.getRevisionNumber() + 1 : 1;
revision.setRevisionNumber(nextNumber);
if (revision.getEncounterId() == null && latest != null) {
revision.setEncounterId(latest.getEncounterId());
}
revision.setCreateTime(new Date());
emrRevisionService.save(revision);
return revision;
}
@Override
public List<EmrRevision> getRevisions(Long emrId) {
return emrRevisionService.selectByEmrId(emrId);
}
@Override
public EmrRevision getRevisionDetail(Long id) {
return emrRevisionService.getById(id);
}
}

View File

@@ -5,26 +5,47 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.common.core.domain.R;
import com.healthlink.his.emr.domain.EmrRevision;
import com.healthlink.his.emr.service.IEmrRevisionService;
import com.healthlink.his.web.emr.appservice.IEmrRevisionAppService;
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.transaction.annotation.Transactional;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.Map;
/**
* 病历修改留痕 Controller
*/
@RestController
@RequestMapping("/emr-revision")
@RequestMapping("/emr/revision")
@Slf4j
@AllArgsConstructor
@Tag(name = "病历修改留痕")
public class EmrRevisionController {
private final IEmrRevisionService revisionService;
private final IEmrRevisionAppService emrRevisionAppService;
@PostMapping("/record")
@PreAuthorize("@ss.hasPermi('inpatient:emr:edit')")
@Operation(summary = "记录修改留痕")
public R<EmrRevision> recordRevision(@RequestBody EmrRevision revision) {
return R.ok(emrRevisionAppService.recordRevision(revision));
}
@GetMapping("/list/{emrId}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@Operation(summary = "获取修改历史列表")
public R<?> getRevisions(@PathVariable Long emrId) {
return R.ok(emrRevisionAppService.getRevisions(emrId));
}
@GetMapping("/page")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@Operation(summary = "分页查询修改留痕")
public R<?> getPage(
@RequestParam(value = "emrId", required = false) Long emrId,
@RequestParam(value = "encounterId", required = false) Long encounterId,
@@ -39,35 +60,16 @@ public class EmrRevisionController {
return R.ok(revisionService.page(new Page<>(pageNo, pageSize), w));
}
@GetMapping("/list")
public R<?> getList(@RequestParam("emrId") Long emrId) {
LambdaQueryWrapper<EmrRevision> w = new LambdaQueryWrapper<>();
w.eq(EmrRevision::getEmrId, emrId)
.orderByAsc(EmrRevision::getRevisionNumber);
return R.ok(revisionService.list(w));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@Operation(summary = "获取修订详情")
public R<?> getById(@PathVariable Long id) {
return R.ok(revisionService.getById(id));
}
@PostMapping("/record")
@Transactional(rollbackFor = Exception.class)
public R<?> recordRevision(@RequestBody EmrRevision revision) {
// 自动计算版本号
LambdaQueryWrapper<EmrRevision> w = new LambdaQueryWrapper<>();
w.eq(EmrRevision::getEmrId, revision.getEmrId())
.orderByDesc(EmrRevision::getRevisionNumber)
.last("LIMIT 1");
EmrRevision last = revisionService.getOne(w);
revision.setRevisionNumber(last == null ? 1 : last.getRevisionNumber() + 1);
revision.setCreateTime(new Date());
revisionService.save(revision);
return R.ok(revision);
return R.ok(emrRevisionAppService.getRevisionDetail(id));
}
@GetMapping("/compare")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@Operation(summary = "对比两个修订版本")
public R<?> compareRevisions(
@RequestParam("revisionId1") Long id1,
@RequestParam("revisionId2") Long id2) {

View File

@@ -93,3 +93,11 @@ export function saveAnesSummary(data) {
export function getAnesSummary(recordId) {
return request({ url: '/api/v1/anesthesia/summary/' + recordId, method: 'get' })
}
export function recordPostopFollowup(data) {
return request({ url: '/api/v1/anesthesia/postop-followup', method: 'post', data })
}
export function getPostopFollowups(encounterId) {
return request({ url: '/api/v1/anesthesia/postop-followup/' + encounterId, method: 'get' })
}

View File

@@ -2,6 +2,10 @@ import request from "@/utils/request"
export function getTimelinessByEncounter(encounterId) { return request({ url: "/emr-revision/timeliness/" + encounterId, method: "get" }) }
export function getTimelinessStatistics(params) { return request({ url: "/emr-revision/statistics", method: "get", params }) }
export function getPendingEmrCount(params) { return request({ url: "/emr-archive/pending-count", method: "get", params }) }
// 查询超期病历列表
export function getOverdueList(params) { return request({ url: "/emr-archive/overdue/list", method: "get", params }) }
export function recordEmrRevision(data) { return request({ url: "/emr/revision/record", method: "post", data }) }
export function getEmrRevisionList(emrId) { return request({ url: "/emr/revision/list/" + emrId, method: "get" }) }
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 } }) }

View File

@@ -1,4 +1,5 @@
import request from '@/utils/request'
export function createRevision(data) { return request({ url: '/api/v1/emr/revision', method: 'post', data }) }
export function getRevisionHistory(emrId) { return request({ url: '/api/v1/emr/revision/' + emrId, method: 'get' }) }
export function executeCompletenessCheck(emrId) { return request({ url: '/api/v1/emr/completeness-check/' + emrId, method: 'post' }) }
@@ -7,3 +8,9 @@ export function getTimelinessByEncounter(encounterId) { return request({ url: '/
export function getOverdueList() { return request({ url: '/api/v1/emr/timeliness/overdue', method: 'get' }) }
export function getTimelinessStatistics(params) { return request({ url: '/api/v1/emr/timeliness/statistics', method: 'get', params }) }
export function checkTimeliness(data) { return request({ url: '/api/v1/emr/timeliness/check', method: 'post', data }) }
export function recordEmrRevision(data) { return request({ url: '/emr/revision/record', method: 'post', data }) }
export function getEmrRevisionList(emrId) { return request({ url: '/emr/revision/list/' + emrId, method: 'get' }) }
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 } }) }

View File

@@ -0,0 +1,277 @@
<template>
<div class="emr-revision-track">
<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-timeline v-if="revisions.length > 0">
<el-timeline-item
v-for="item in revisions"
:key="item.id"
:timestamp="item.createTime"
placement="top"
:type="getTimelineType(item.operationType)"
size="large"
>
<el-card shadow="never" class="revision-card">
<div class="revision-header">
<div class="revision-meta">
<el-tag size="small" :type="getOpTagType(item.operationType)">{{ getOpLabel(item.operationType) }}</el-tag>
<span class="revision-number">版本 #{{ item.revisionNumber }}</span>
<span class="revision-operator">{{ item.operatorName }}</span>
</div>
<div class="revision-actions">
<el-checkbox
:model-value="compareIds.includes(item.id)"
:disabled="!compareIds.includes(item.id) && compareIds.length >= 2"
@change="(val) => toggleCompare(item.id, val)"
/>
<el-button link type="primary" size="small" @click="handleViewDetail(item)">查看详情</el-button>
</div>
</div>
<div v-if="item.diffContent" class="revision-diff">
<div class="diff-label">修改内容</div>
<pre class="diff-content">{{ item.diffContent }}</pre>
</div>
<div v-if="item.snapshotContent" class="revision-snapshot">
<div class="diff-label">快照内容</div>
<pre class="diff-content">{{ truncateContent(item.snapshotContent) }}</pre>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无修改记录" />
</el-card>
<el-dialog v-model="detailVisible" title="修订详情" width="700px" destroy-on-close top="5vh">
<div v-if="currentRevision" class="detail-content">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="版本号">{{ currentRevision.revisionNumber }}</el-descriptions-item>
<el-descriptions-item label="操作类型">
<el-tag size="small" :type="getOpTagType(currentRevision.operationType)">{{ getOpLabel(currentRevision.operationType) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentRevision.operatorName }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ currentRevision.createTime }}</el-descriptions-item>
<el-descriptions-item label="病历ID">{{ currentRevision.emrId }}</el-descriptions-item>
<el-descriptions-item label="就诊ID">{{ currentRevision.encounterId }}</el-descriptions-item>
</el-descriptions>
<div v-if="currentRevision.diffContent" style="margin-top: 16px">
<div class="diff-label">修改内容</div>
<pre class="detail-pre">{{ currentRevision.diffContent }}</pre>
</div>
<div v-if="currentRevision.snapshotContent" style="margin-top: 16px">
<div class="diff-label">快照内容</div>
<pre class="detail-pre">{{ currentRevision.snapshotContent }}</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.revision1?.revisionNumber }} {{ compareData.revision1?.operatorName }}</span>
</template>
<pre class="compare-pre">{{ compareData.revision1?.snapshotContent || '无快照' }}</pre>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>版本 #{{ compareData.revision2?.revisionNumber }} {{ compareData.revision2?.operatorName }}</span>
</template>
<pre class="compare-pre">{{ compareData.revision2?.snapshotContent || '无快照' }}</pre>
</el-card>
</el-col>
</el-row>
</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 { getEmrRevisionList, getEmrRevisionDetail, compareEmrRevisions } from '@/api/emr'
const props = defineProps({
emrId: { type: [Number, String], default: null }
})
const loading = ref(false)
const revisions = ref([])
const compareIds = ref([])
const detailVisible = ref(false)
const compareVisible = ref(false)
const currentRevision = ref(null)
const compareData = ref(null)
const OP_LABEL_MAP = { CREATE: '创建', EDIT: '编辑', APPROVE: '审批', SIGN: '签名' }
const OP_TAG_MAP = { CREATE: 'success', EDIT: '', APPROVE: 'warning', SIGN: 'info' }
const TIMELINE_MAP = { CREATE: 'primary', EDIT: 'success', APPROVE: 'warning', SIGN: 'info' }
function getOpLabel(type) { return OP_LABEL_MAP[type] || type }
function getOpTagType(type) { return OP_TAG_MAP[type] || '' }
function getTimelineType(type) { return TIMELINE_MAP[type] || '' }
function truncateContent(content) {
if (!content) return ''
return content.length > 200 ? content.substring(0, 200) + '...' : content
}
function loadRevisions() {
if (!props.emrId) return
loading.value = true
getEmrRevisionList(props.emrId).then(res => {
revisions.value = res.data || []
}).catch(() => {
ElMessage.error('查询修改记录失败')
}).finally(() => {
loading.value = false
})
}
function handleRefresh() {
compareIds.value = []
loadRevisions()
}
function toggleCompare(id, checked) {
if (checked) {
compareIds.value.push(id)
} else {
compareIds.value = compareIds.value.filter(i => i !== id)
}
}
function handleViewDetail(item) {
loading.value = true
getEmrRevisionDetail(item.id).then(res => {
currentRevision.value = res.data
detailVisible.value = true
}).catch(() => {
ElMessage.error('查询详情失败')
}).finally(() => {
loading.value = false
})
}
function handleCompare() {
if (compareIds.value.length !== 2) {
ElMessage.warning('请选择两个版本进行对比')
return
}
loading.value = true
compareEmrRevisions(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, () => { loadRevisions() })
onMounted(() => { loadRevisions() })
</script>
<style scoped>
.emr-revision-track {
padding: 12px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.revision-card {
margin-bottom: 0;
}
.revision-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.revision-meta {
display: flex;
align-items: center;
gap: 8px;
}
.revision-number {
font-weight: 600;
color: #303133;
}
.revision-operator {
color: #606266;
}
.revision-actions {
display: flex;
align-items: center;
gap: 8px;
}
.diff-label {
font-size: 13px;
color: #909399;
margin-bottom: 4px;
}
.diff-content {
background: #f5f7fa;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
max-height: 120px;
overflow-y: auto;
}
.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;
}
.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>