feat(V40): EMPI患者主索引 — 完整前端+DB修复+5/6 API通过

前端:
- Patient页面: 注册/查询(全局ID/身份证)/统计卡片
- Merge页面: 合并操作+合并日志列表+撤销
- Statistics页面: EMPI统计概览

数据库修复:
- 创建empi_person表(global_id/patient_name/gender/birth_date/id_card_no等)
- 创建empi_id_mapping表
- 修复empi_patient_photo: 添加create_time列
- 修复empi_family_member/merge_log: 添加delete_flag/create_by/update_by列
- empi_person: 添加merge_status列

后端修复:
- EmpiPerson实体: name→patient_name列映射修复

测试: 5/6 API通过(注册/查询/照片/家庭/合并日志)
This commit is contained in:
2026-06-07 13:12:20 +08:00
parent 330bc14c6f
commit 9ca86f7a6c
5 changed files with 214 additions and 16 deletions

View File

@@ -1,14 +1,37 @@
package com.healthlink.his.empi.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.*;
import com.core.common.core.domain.HisBaseEntity;
import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.util.Date;
@Data @TableName("empi_person") @Accessors(chain = true) @EqualsAndHashCode(callSuper = false)
@Data
@TableName("empi_person")
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
public class EmpiPerson extends HisBaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String globalId; private String idCardNo; private String name;
private String gender; private Date birthDate; private String phone;
private String mergeStatus; private String sourceSystem;
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField("global_id")
private String globalId;
@TableField("id_card_no")
private String idCardNo;
@TableField("patient_name")
private String name;
@TableField("gender")
private String gender;
@TableField("birth_date")
@JsonFormat(pattern = "yyyy-MM-dd")
private Date birthDate;
@TableField("phone")
private String phone;
@TableField("address")
private String address;
@TableField("merge_status")
private String mergeStatus;
@TableField("source_system")
private String sourceSystem;
}

View File

@@ -1,8 +1,17 @@
import request from '@/utils/request'
export function getPhotos(p){return request({url:'/empi-enhanced/photo/list',method:'get',params:p})}
export function addPhoto(d){return request({url:'/empi-enhanced/photo/add',method:'post',data:d})}
export function getFamilyMembers(p){return request({url:'/empi-enhanced/family/list',method:'get',params:p})}
export function addFamilyMember(d){return request({url:'/empi-enhanced/family/add',method:'post',data:d})}
export function deleteFamilyMember(id){return request({url:'/empi-enhanced/family/delete',method:'delete',params:{id}})}
export function getMergeLogPage(p){return request({url:'/empi-enhanced/merge-log/page',method:'get',params:p})}
export function addMergeLog(d){return request({url:'/empi-enhanced/merge-log/add',method:'post',data:d})}
export function registerPerson(data) { return request({ url: '/healthlink-his/api/v1/empi/person', method: 'post', data }) }
export function mergePersons(primaryId, secondaryIds) { return request({ url: '/healthlink-his/api/v1/empi/merge', method: 'post', params: { primaryId, secondaryIds: secondaryIds.join(',') } }) }
export function findByGlobalId(globalId) { return request({ url: '/healthlink-his/api/v1/empi/person/global/' + globalId, method: 'get' }) }
export function findByIdCard(idCardNo) { return request({ url: '/healthlink-his/api/v1/empi/person/idcard/' + idCardNo, method: 'get' }) }
export function getMappings(globalId) { return request({ url: '/healthlink-his/api/v1/empi/mappings/' + globalId, method: 'get' }) }
export function getStatistics() { return request({ url: '/healthlink-his/api/v1/empi/statistics', method: 'get' }) }
export function getPhotos(patientId) { return request({ url: '/empi-enhanced/photo/list', method: 'get', params: { patientId } }) }
export function addPhoto(data) { return request({ url: '/empi-enhanced/photo/add', method: 'post', data }) }
export function getFamilyMembers(patientId) { return request({ url: '/empi-enhanced/family/list', method: 'get', params: { patientId } }) }
export function addFamilyMember(data) { return request({ url: '/empi-enhanced/family/add', method: 'post', data }) }
export function deleteFamilyMember(id) { return request({ url: '/empi-enhanced/family/delete', method: 'delete', params: { id } }) }
export function getMergeLogPage(params) { return request({ url: '/empi-enhanced/merge-log/page', method: 'get', params }) }
export function addMergeLog(data) { return request({ url: '/empi-enhanced/merge-log/add', method: 'post', data }) }
export function undoMergeLog(data) { return request({ url: '/empi-enhanced/merge-log/undo', method: 'post', data }) }

View File

@@ -0,0 +1,54 @@
<template>
<div class="app-container">
<el-card>
<template #header><span>患者合并管理</span></template>
<el-form :inline="true" class="mb8">
<el-form-item label="主患者ID"><el-input v-model="primaryId" placeholder="主患者ID" /></el-form-item>
<el-form-item label="待合并ID"><el-input v-model="secondaryIds" placeholder="逗号分隔多个ID" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="handleMerge">合并</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt8">
<template #header><span>合并日志</span></template>
<el-table v-loading="loading" :data="mergeLogs">
<el-table-column label="主患者" prop="primaryPatientName" width="120" />
<el-table-column label="被合并患者" prop="secondaryPatientName" width="120" />
<el-table-column label="合并原因" prop="mergeReason" width="200" show-overflow-tooltip />
<el-table-column label="操作人" prop="operatorName" width="100" />
<el-table-column label="合并时间" prop="mergeTime" width="170" />
<el-table-column label="状态" prop="status" width="90">
<template #default="s"><el-tag :type="s.row.status==='ACTIVE'?'success':'info'">{{ s.row.status === 'ACTIVE' ? '有效' : '已撤销' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="s">
<el-button link type="warning" v-if="s.row.status==='ACTIVE'" @click="handleUndo(s.row)">撤销</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { mergePersons, getMergeLogPage, undoMergeLog } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false); const mergeLogs = ref([])
const primaryId = ref(''); const secondaryIds = ref('')
const loadLogs = async () => { loading.value = true; const res = await getMergeLogPage({ pageNo: 1, pageSize: 20 }); mergeLogs.value = res.data?.records || []; loading.value = false }
const handleMerge = async () => {
if (!primaryId.value || !secondaryIds.value) { ElMessage.warning('请填写ID'); return }
await ElMessageBox.confirm('确认合并?', '提示', { type: 'warning' })
await mergePersons(primaryId.value, secondaryIds.value.split(',').map(Number))
ElMessage.success('合并成功'); primaryId.value = ''; secondaryIds.value = ''; loadLogs()
}
const handleUndo = async (row) => {
await ElMessageBox.confirm('确认撤销合并?', '提示', { type: 'warning' })
await undoMergeLog({ id: row.id, operatorName: '管理员' }); ElMessage.success('已撤销'); loadLogs()
}
onMounted(() => loadLogs())
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div class="app-container">
<el-row :gutter="20" class="mb8">
<el-col :span="6"><el-card shadow="hover"><el-statistic title="总患者数" :value="stats.totalPatients || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="已合并" :value="stats.mergedPatients || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="待合并" :value="stats.pendingMerges || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="重复率" :value="stats.duplicateRate || 0" suffix="%" /></el-card></el-col>
</el-row>
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>患者主索引管理</span>
<el-button type="primary" icon="Plus" @click="handleRegister">注册患者</el-button>
</div>
</template>
<el-form :model="searchForm" :inline="true" class="mb8">
<el-form-item label="全局ID"><el-input v-model="searchForm.globalId" placeholder="全局ID" clearable /></el-form-item>
<el-form-item label="身份证号"><el-input v-model="searchForm.idCardNo" placeholder="身份证号" clearable /></el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="searchForm={};patientData=null">重置</el-button>
</el-form-item>
</el-form>
<el-descriptions v-if="patientData" :column="2" border>
<el-descriptions-item label="全局ID">{{ patientData.globalId }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ patientData.patientName }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ patientData.gender === 'M' ? '男' : '女' }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ patientData.birthDate }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ patientData.idCardNo }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ patientData.phone }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ patientData.address }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="请输入查询条件" />
</el-card>
<el-dialog title="注册患者" v-model="dialogVisible" width="600px">
<el-form :model="formData" label-width="100px">
<el-row :gutter="20">
<el-col :span="12"><el-form-item label="姓名"><el-input v-model="formData.patientName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="性别">
<el-select v-model="formData.gender"><el-option label="男" value="M" /><el-option label="女" value="F" /></el-select>
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="出生日期"><el-date-picker v-model="formData.birthDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="身份证号"><el-input v-model="formData.idCardNo" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="手机号"><el-input v-model="formData.phone" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="地址"><el-input v-model="formData.address" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { registerPerson, findByGlobalId, findByIdCard, getStatistics } from '../api'
import { ElMessage } from 'element-plus'
const stats = ref({})
const searchForm = reactive({ globalId: '', idCardNo: '' })
const patientData = ref(null)
const dialogVisible = ref(false)
const formData = ref({})
const loadStats = async () => { const res = await getStatistics(); stats.value = res.data || {} }
const handleSearch = async () => {
if (searchForm.globalId) { const res = await findByGlobalId(searchForm.globalId); patientData.value = res.data }
else if (searchForm.idCardNo) { const res = await findByIdCard(searchForm.idCardNo); patientData.value = res.data }
else { ElMessage.warning('请输入查询条件') }
}
const handleRegister = () => { formData.value = {}; dialogVisible.value = true }
const submitForm = async () => { await registerPerson(formData.value); ElMessage.success('注册成功'); dialogVisible.value = false; loadStats() }
onMounted(() => loadStats())
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="app-container">
<el-row :gutter="20" class="mb8">
<el-col :span="6"><el-card shadow="hover"><el-statistic title="总患者数" :value="stats.totalPatients || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="已合并" :value="stats.mergedPatients || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="待合并" :value="stats.pendingMerges || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="重复率" :value="stats.duplicateRate || 0" suffix="%" /></el-card></el-col>
</el-row>
<el-card>
<template #header><span>EMPI统计概览</span></template>
<el-table :data="statsDetails">
<el-table-column label="指标" prop="metric" />
<el-table-column label="值" prop="value" />
<el-table-column label="说明" prop="description" />
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getStatistics } from '../api'
const stats = ref({}); const statsDetails = ref([])
const loadStats = async () => {
const res = await getStatistics(); stats.value = res.data || {}
statsDetails.value = [
{ metric: '总患者数', value: stats.value.totalPatients || 0, description: 'EMPI中注册的患者总数' },
{ metric: '已合并数', value: stats.value.mergedPatients || 0, description: '已执行合并的患者数' },
{ metric: '待合并数', value: stats.value.pendingMerges || 0, description: '疑似重复待合并的患者数' },
{ metric: '重复率', value: (stats.value.duplicateRate || 0) + '%', description: '疑似重复患者占比' },
]
}
onMounted(() => loadStats())
</script>