feat(P2): 电子病历增强 — 修改留痕+打印归档

- EmrRevisionController: 修订记录查询/对比/自动版本号
- EmrArchiveController: 打印记录/归档/补打/24h归档率统计
- EmrArchiveRecord: 归档记录实体+V27 Flyway迁移
- 前端revision-history: 版本列表+详情弹窗
- 前端archive: 归档统计卡片+归档操作
- 后端编译通过,前端构建通过
This commit is contained in:
2026-06-06 20:49:23 +08:00
parent cf26554f60
commit 454f717bac
11 changed files with 391 additions and 41 deletions

View File

@@ -0,0 +1,102 @@
package com.healthlink.his.web.emr.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.common.core.domain.R;
import com.healthlink.his.emr.domain.EmrArchiveRecord;
import com.healthlink.his.emr.service.IEmrArchiveRecordService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 病历打印归档 Controller
*/
@RestController
@RequestMapping("/emr-archive")
@Slf4j
@AllArgsConstructor
public class EmrArchiveController {
private final IEmrArchiveRecordService archiveService;
@GetMapping("/page")
public R<?> getPage(
@RequestParam(value = "patientName", required = false) String patientName,
@RequestParam(value = "emrType", required = false) String emrType,
@RequestParam(value = "archiveStatus", required = false) String archiveStatus,
@RequestParam(value = "encounterId", required = false) Long encounterId,
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
LambdaQueryWrapper<EmrArchiveRecord> w = new LambdaQueryWrapper<>();
w.like(StringUtils.hasText(patientName), EmrArchiveRecord::getPatientName, patientName)
.eq(StringUtils.hasText(emrType), EmrArchiveRecord::getEmrType, emrType)
.eq(StringUtils.hasText(archiveStatus), EmrArchiveRecord::getArchiveStatus, archiveStatus)
.eq(encounterId != null, EmrArchiveRecord::getEncounterId, encounterId)
.orderByDesc(EmrArchiveRecord::getCreateTime);
return R.ok(archiveService.page(new Page<>(pageNo, pageSize), w));
}
@PostMapping("/print")
@Transactional(rollbackFor = Exception.class)
public R<?> recordPrint(@RequestBody EmrArchiveRecord record) {
record.setArchiveType("PRINT");
record.setArchiveStatus("PRINTED");
record.setPrintTime(new Date());
record.setPrintCount(1);
record.setCreateTime(new Date());
archiveService.save(record);
return R.ok(record);
}
@PutMapping("/archive/{id}")
@Transactional(rollbackFor = Exception.class)
public R<?> archive(@PathVariable Long id, @RequestParam("archivedBy") String archivedBy) {
EmrArchiveRecord record = archiveService.getById(id);
if (record == null) return R.fail("归档记录不存在");
record.setArchiveStatus("ARCHIVED");
record.setArchiveTime(new Date());
record.setArchivedBy(archivedBy);
archiveService.updateById(record);
return R.ok(record);
}
@PutMapping("/reprint/{id}")
@Transactional(rollbackFor = Exception.class)
public R<?> reprint(@PathVariable Long id) {
EmrArchiveRecord record = archiveService.getById(id);
if (record == null) return R.fail("归档记录不存在");
record.setPrintCount(record.getPrintCount() + 1);
archiveService.updateById(record);
return R.ok(record);
}
@GetMapping("/stats")
public R<?> getArchiveStats(@RequestParam(required = false) Long encounterId) {
Map<String, Object> stats = new HashMap<>();
LambdaQueryWrapper<EmrArchiveRecord> w = new LambdaQueryWrapper<>();
if (encounterId != null) w.eq(EmrArchiveRecord::getEncounterId, encounterId);
stats.put("total", archiveService.count(w));
w.eq(EmrArchiveRecord::getArchiveStatus, "ARCHIVED");
stats.put("archived", archiveService.count(w));
w.eq(EmrArchiveRecord::getArchiveStatus, "PRINTED");
stats.put("printed", archiveService.count(w));
// 24h归档率
LambdaQueryWrapper<EmrArchiveRecord> w24 = new LambdaQueryWrapper<>();
if (encounterId != null) w24.eq(EmrArchiveRecord::getEncounterId, encounterId);
w24.ge(EmrArchiveRecord::getCreateTime, new Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000));
long total24h = archiveService.count(w24);
w24.eq(EmrArchiveRecord::getArchiveStatus, "ARCHIVED");
long archived24h = archiveService.count(w24);
stats.put("archiveRate24h", total24h > 0 ? Math.round(archived24h * 100.0 / total24h) : 100);
return R.ok(stats);
}
}

View File

@@ -0,0 +1,79 @@
package com.healthlink.his.web.emr.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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 lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.Map;
/**
* 病历修改留痕 Controller
*/
@RestController
@RequestMapping("/emr-revision")
@Slf4j
@AllArgsConstructor
public class EmrRevisionController {
private final IEmrRevisionService revisionService;
@GetMapping("/page")
public R<?> getPage(
@RequestParam(value = "emrId", required = false) Long emrId,
@RequestParam(value = "encounterId", required = false) Long encounterId,
@RequestParam(value = "operatorName", required = false) String operatorName,
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
LambdaQueryWrapper<EmrRevision> w = new LambdaQueryWrapper<>();
w.eq(emrId != null, EmrRevision::getEmrId, emrId)
.eq(encounterId != null, EmrRevision::getEncounterId, encounterId)
.like(operatorName != null, EmrRevision::getOperatorName, operatorName)
.orderByDesc(EmrRevision::getCreateTime);
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}")
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);
}
@GetMapping("/compare")
public R<?> compareRevisions(
@RequestParam("revisionId1") Long id1,
@RequestParam("revisionId2") Long id2) {
EmrRevision r1 = revisionService.getById(id1);
EmrRevision r2 = revisionService.getById(id2);
if (r1 == null || r2 == null) return R.fail("修订记录不存在");
return R.ok(Map.of("revision1", r1, "revision2", r2));
}
}

View File

@@ -0,0 +1,29 @@
-- V27: 电子病历增强 — 修改留痕+打印归档
-- 病历打印归档记录表
CREATE TABLE IF NOT EXISTS emr_archive_record (
id BIGSERIAL PRIMARY KEY,
emr_id BIGINT NOT NULL,
encounter_id BIGINT NOT NULL,
patient_id BIGINT NOT NULL,
patient_name VARCHAR(50),
emr_type VARCHAR(50),
emr_title VARCHAR(200),
archive_type VARCHAR(20) NOT NULL,
print_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
print_by VARCHAR(64),
print_count INT DEFAULT 1,
archive_status VARCHAR(20) DEFAULT 'PRINTED',
archive_time TIMESTAMP,
archived_by VARCHAR(64),
file_path VARCHAR(500),
tenant_id BIGINT DEFAULT 0,
is_deleted INT NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE emr_archive_record IS '病历打印归档记录';
COMMENT ON COLUMN emr_archive_record.archive_type IS '归档类型(PRINT打印/ARCHIVE归档/REPRINT补打)';
COMMENT ON COLUMN emr_archive_record.archive_status IS '状态(PRINTED已打印/ARCHIVED已归档/LOST遗失)';
CREATE INDEX idx_ear_encounter ON emr_archive_record(encounter_id);
CREATE INDEX idx_ear_patient ON emr_archive_record(patient_id);
CREATE INDEX idx_ear_status ON emr_archive_record(archive_status);

View File

@@ -0,0 +1,33 @@
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 java.util.Date;
/**
* 病历打印归档记录
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("emr_archive_record")
public class EmrArchiveRecord extends HisBaseEntity {
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
private Long emrId;
private Long encounterId;
private Long patientId;
private String patientName;
private String emrType;
private String emrTitle;
private String archiveType;
private Date printTime;
private String printBy;
private Integer printCount;
private String archiveStatus;
private Date archiveTime;
private String archivedBy;
private String filePath;
}

View File

@@ -0,0 +1,9 @@
package com.healthlink.his.emr.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.healthlink.his.emr.domain.EmrArchiveRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmrArchiveRecordMapper extends BaseMapper<EmrArchiveRecord> {
}

View File

@@ -0,0 +1,7 @@
package com.healthlink.his.emr.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.emr.domain.EmrArchiveRecord;
public interface IEmrArchiveRecordService extends IService<EmrArchiveRecord> {
}

View File

@@ -0,0 +1,13 @@
package com.healthlink.his.emr.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.emr.domain.EmrArchiveRecord;
import com.healthlink.his.emr.mapper.EmrArchiveRecordMapper;
import com.healthlink.his.emr.service.IEmrArchiveRecordService;
import org.springframework.stereotype.Service;
@Service
public class EmrArchiveRecordServiceImpl
extends ServiceImpl<EmrArchiveRecordMapper, EmrArchiveRecord>
implements IEmrArchiveRecordService {
}

View File

@@ -0,0 +1,6 @@
import request from '@/utils/request'
export function getArchivePage(p){return request({url:'/emr-archive/page',method:'get',params:p})}
export function recordPrint(d){return request({url:'/emr-archive/print',method:'post',data:d})}
export function archive(id,archivedBy){return request({url:'/emr-archive/archive/'+id,method:'put',params:{archivedBy}})}
export function reprint(id){return request({url:'/emr-archive/reprint/'+id,method:'put'})}
export function getArchiveStats(p){return request({url:'/emr-archive/stats',method:'get',params:p})}

View File

@@ -0,0 +1,64 @@
<template>
<div style="padding:16px">
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:bold">病历打印归档</span>
<el-button type="primary" @click="loadStats">刷新统计</el-button>
</div>
<el-row :gutter="12" style="margin-bottom:16px">
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:22px;font-weight:bold;color:#409eff">{{ stats.total||0 }}</div><div style="font-size:12px;color:#999">总记录</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:22px;font-weight:bold;color:#67c23a">{{ stats.archived||0 }}</div><div style="font-size:12px;color:#999">已归档</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:22px;font-weight:bold;color:#e6a23c">{{ stats.printed||0 }}</div><div style="font-size:12px;color:#999">待归档</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:22px;font-weight:bold" :style="{color:stats.archiveRate24h>=90?'#67c23a':'#f56c6c'}">{{ stats.archiveRate24h||0 }}%</div><div style="font-size:12px;color:#999">24h归档率</div></div></el-card></el-col>
</el-row>
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap">
<el-input v-model="q.patientName" placeholder="患者姓名" clearable style="width:140px"/>
<el-select v-model="q.archiveStatus" placeholder="状态" clearable style="width:120px">
<el-option label="已打印" value="PRINTED"/>
<el-option label="已归档" value="ARCHIVED"/>
<el-option label="已遗失" value="LOST"/>
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="patientName" label="患者" width="100"/>
<el-table-column prop="emrType" label="病历类型" width="100"/>
<el-table-column prop="emrTitle" label="标题" width="150" show-overflow-tooltip/>
<el-table-column prop="archiveType" label="操作" width="80">
<template #default="{row}">
<el-tag v-if="row.archiveType==='PRINT'" type="info" size="small">打印</el-tag>
<el-tag v-else-if="row.archiveType==='ARCHIVE'" type="success" size="small">归档</el-tag>
<el-tag v-else type="warning" size="small">补打</el-tag>
</template>
</el-table-column>
<el-table-column prop="printBy" label="打印人" width="80"/>
<el-table-column prop="printCount" label="打印次数" width="80" align="center"/>
<el-table-column prop="archiveStatus" label="状态" width="90">
<template #default="{row}">
<el-tag v-if="row.archiveStatus==='ARCHIVED'" type="success" size="small">已归档</el-tag>
<el-tag v-else-if="row.archiveStatus==='PRINTED'" type="warning" size="small">待归档</el-tag>
<el-tag v-else type="info" size="small">{{ row.archiveStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="时间" width="170"/>
<el-table-column label="操作" width="160">
<template #default="{row}">
<el-button v-if="row.archiveStatus==='PRINTED'" type="success" link size="small" @click="doArchive(row)">归档</el-button>
<el-button type="primary" link size="small" @click="doReprint(row)">补打</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px;justify-content:flex-end" v-model:current-page="q.pageNo" v-model:page-size="q.pageSize" :total="total" layout="total,prev,pager,next" @current-change="loadData"/>
</div>
</template>
<script setup>
import {ref,onMounted} from 'vue'
import {ElMessage,ElMessageBox} from 'element-plus'
import {getArchivePage,archive,reprint,getArchiveStats} from './api'
const tableData=ref([]);const total=ref(0);const stats=ref({})
const q=ref({pageNo:1,pageSize:20,patientName:'',archiveStatus:''})
const loadData=async()=>{const r=await getArchivePage(q.value);tableData.value=r.data?.records||[];total.value=r.data?.total||0}
const loadStats=async()=>{const r=await getArchiveStats();stats.value=r.data||{}}
const doArchive=async(row)=>{const {value}=await ElMessageBox.prompt('归档人','确认归档');if(value){await archive(row.id,value);ElMessage.success('已归档');loadData();loadStats()}}
const doReprint=async(row)=>{await reprint(row.id);ElMessage.success('补打记录已添加');loadData()}
onMounted(()=>{loadData();loadStats()})
</script>

View File

@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getRevisionPage(p){return request({url:'/emr-revision/page',method:'get',params:p})}
export function getRevisionList(p){return request({url:'/emr-revision/list',method:'get',params:p})}
export function recordRevision(d){return request({url:'/emr-revision/record',method:'post',data:d})}
export function compareRevisions(id1,id2){return request({url:'/emr-revision/compare',method:'get',params:{revisionId1:id1,revisionId2:id2}})}

View File

@@ -1,49 +1,52 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch">
<el-form-item label="病历ID" prop="emrId">
<el-input v-model="queryParams.emrId" placeholder="请输入病历ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="dataList">
<el-table-column label="版本号" prop="revisionNumber" width="100" />
<el-table-column label="操作人" prop="operatorName" width="120" />
<el-table-column label="操作类型" prop="operationType" width="120">
<template #default="scope">
<el-tag :type="operationTypeMap[scope.row.operationType]?.type">{{ operationTypeMap[scope.row.operationType]?.label }}</el-tag>
<div style="padding:16px">
<div style="margin-bottom:16px"><span style="font-size:18px;font-weight:bold">病历修改留痕</span></div>
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap">
<el-input v-model="q.emrId" placeholder="病历ID" clearable style="width:120px"/>
<el-input v-model="q.operatorName" placeholder="操作人" clearable style="width:120px"/>
<el-button type="primary" @click="loadData">查询</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="emrId" label="病历ID" width="100"/>
<el-table-column prop="revisionNumber" label="版本" width="70" align="center">
<template #default="{row}"><el-tag size="small">V{{ row.revisionNumber }}</el-tag></template>
</el-table-column>
<el-table-column prop="operatorName" label="操作人" width="100"/>
<el-table-column prop="operationType" label="操作类型" width="100"/>
<el-table-column prop="diffContent" label="变更内容" min-width="200" show-overflow-tooltip/>
<el-table-column prop="createTime" label="修改时间" width="170"/>
<el-table-column label="操作" width="100">
<template #default="{row}">
<el-button type="primary" link size="small" @click="viewDetail(row)">查看</el-button>
</template>
</el-table-column>
<el-table-column label="操作时间" prop="createTime" width="180" />
<el-table-column label="变更内容" prop="diffContent" show-overflow-tooltip />
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<el-pagination style="margin-top:12px;justify-content:flex-end" v-model:current-page="q.pageNo" v-model:page-size="q.pageSize" :total="total" layout="total,prev,pager,next" @current-change="loadData"/>
<el-dialog v-model="detailVisible" title="修订详情" width="700px">
<el-descriptions :column="2" border>
<el-descriptions-item label="版本">V{{ detail.revisionNumber }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ detail.operatorName }}</el-descriptions-item>
<el-descriptions-item label="操作类型">{{ detail.operationType }}</el-descriptions-item>
<el-descriptions-item label="时间">{{ detail.createTime }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:12px">
<div style="font-weight:bold;margin-bottom:8px">变更内容:</div>
<pre style="background:#f5f7fa;padding:12px;border-radius:4px;max-height:300px;overflow:auto">{{ detail.diffContent }}</pre>
</div>
<div style="margin-top:12px">
<div style="font-weight:bold;margin-bottom:8px">内容快照:</div>
<pre style="background:#f5f7fa;padding:12px;border-radius:4px;max-height:300px;overflow:auto">{{ detail.snapshotContent }}</pre>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getRevisionHistory } from '@/api/emr'
const loading = ref(true)
const showSearch = ref(true)
const dataList = ref([])
const total = ref(0)
const queryParams = reactive({ emrId: undefined, pageNum: 1, pageSize: 10 })
const operationTypeMap = { CREATE: { label: '创建', type: 'success' }, EDIT: { label: '编辑', type: '' }, APPROVE: { label: '审批', type: 'warning' }, SIGN: { label: '签名', type: 'primary' } }
const getList = async () => {
if (!queryParams.emrId) { loading.value = false; return }
loading.value = true
const res = await getRevisionHistory(queryParams.emrId)
dataList.value = res.rows || res.data || []
total.value = res.total || dataList.value.length
loading.value = false
}
const handleQuery = () => { queryParams.pageNum = 1; getList() }
const resetQuery = () => { queryParams.emrId = undefined; dataList.value = []; total.value = 0 }
onMounted(() => {})
import {ref,onMounted} from 'vue'
import {getRevisionPage} from './api'
const tableData=ref([]);const total=ref(0)
const q=ref({pageNo:1,pageSize:20,emrId:'',operatorName:''})
const detailVisible=ref(false);const detail=ref({})
const loadData=async()=>{const r=await getRevisionPage(q.value);tableData.value=r.data?.records||[];total.value=r.data?.total||0}
const viewDetail=(row)=>{detail.value=row;detailVisible.value=true}
onMounted(()=>loadData())
</script>