feat(aidiagnosis): add AI-assisted diagnosis suggestion module

This commit is contained in:
2026-06-19 07:17:13 +08:00
parent 8bc80efe2c
commit 91236c5499
8 changed files with 368 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
-- V87: AI辅助诊疗 - AI诊断建议表
CREATE TABLE ai_diagnosis_suggestion (
id BIGSERIAL PRIMARY KEY,
encounter_id BIGINT NOT NULL,
patient_id BIGINT NOT NULL,
symptom_text TEXT,
diagnosis_suggestions TEXT,
confidence_score DECIMAL(5,2),
suggestion_source VARCHAR(32),
accepted BOOLEAN DEFAULT FALSE,
tenant_id BIGINT DEFAULT 0,
delete_flag CHAR(1) DEFAULT '0',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
create_by VARCHAR(64),
update_time TIMESTAMP,
update_by VARCHAR(64)
);
COMMENT ON TABLE ai_diagnosis_suggestion IS 'AI辅助诊疗建议';
COMMENT ON COLUMN ai_diagnosis_suggestion.id IS '建议ID';
COMMENT ON COLUMN ai_diagnosis_suggestion.encounter_id IS '就诊ID';
COMMENT ON COLUMN ai_diagnosis_suggestion.patient_id IS '患者ID';
COMMENT ON COLUMN ai_diagnosis_suggestion.symptom_text IS '症状描述';
COMMENT ON COLUMN ai_diagnosis_suggestion.diagnosis_suggestions IS '诊断建议';
COMMENT ON COLUMN ai_diagnosis_suggestion.confidence_score IS '置信度(0-100)';
COMMENT ON COLUMN ai_diagnosis_suggestion.suggestion_source IS '建议来源(llm/rule/manual)';
COMMENT ON COLUMN ai_diagnosis_suggestion.accepted IS '是否采纳';
CREATE INDEX idx_ai_diag_encounter ON ai_diagnosis_suggestion(encounter_id);
CREATE INDEX idx_ai_diag_patient ON ai_diagnosis_suggestion(patient_id);
CREATE INDEX idx_ai_diag_source ON ai_diagnosis_suggestion(suggestion_source);

View File

@@ -0,0 +1,28 @@
<?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.aidiagnosis.mapper.AiDiagnosisSuggestionMapper">
<resultMap type="com.healthlink.his.aidiagnosis.domain.AiDiagnosisSuggestion" id="AiDiagnosisSuggestionResult">
<id column="id" property="id"/>
<result column="encounter_id" property="encounterId"/>
<result column="patient_id" property="patientId"/>
<result column="symptom_text" property="symptomText"/>
<result column="diagnosis_suggestions" property="diagnosisSuggestions"/>
<result column="confidence_score" property="confidenceScore"/>
<result column="suggestion_source" property="suggestionSource"/>
<result column="accepted" property="accepted"/>
<result column="tenant_id" property="tenantId"/>
<result column="create_by" property="createBy"/>
<result column="create_time" property="createTime"/>
<result column="update_by" property="updateBy"/>
<result column="update_time" property="updateTime"/>
<result column="delete_flag" property="deleteFlag"/>
</resultMap>
<sql id="Base_Column_List">
id, encounter_id, patient_id, symptom_text, diagnosis_suggestions,
confidence_score, suggestion_source, accepted,
tenant_id, create_by, create_time, update_by, update_time, delete_flag
</sql>
</mapper>

View File

@@ -0,0 +1,40 @@
package com.healthlink.his.aidiagnosis.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.core.common.core.domain.HisBaseEntity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("ai_diagnosis_suggestion")
public class AiDiagnosisSuggestion extends HisBaseEntity {
@TableId(type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@JsonSerialize(using = ToStringSerializer.class)
private Long encounterId;
@JsonSerialize(using = ToStringSerializer.class)
private Long patientId;
private String symptomText;
private String diagnosisSuggestions;
private BigDecimal confidenceScore;
private String suggestionSource;
private Boolean accepted;
}

View File

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

View File

@@ -0,0 +1,13 @@
package com.healthlink.his.aidiagnosis.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.aidiagnosis.domain.AiDiagnosisSuggestion;
import java.util.List;
public interface IAiDiagnosisService extends IService<AiDiagnosisSuggestion> {
List<AiDiagnosisSuggestion> findByPatientId(Long patientId);
List<AiDiagnosisSuggestion> findByEncounterId(Long encounterId);
}

View File

@@ -0,0 +1,30 @@
package com.healthlink.his.aidiagnosis.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.aidiagnosis.domain.AiDiagnosisSuggestion;
import com.healthlink.his.aidiagnosis.mapper.AiDiagnosisSuggestionMapper;
import com.healthlink.his.aidiagnosis.service.IAiDiagnosisService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AiDiagnosisServiceImpl extends ServiceImpl<AiDiagnosisSuggestionMapper, AiDiagnosisSuggestion> implements IAiDiagnosisService {
@Override
public List<AiDiagnosisSuggestion> findByPatientId(Long patientId) {
LambdaQueryWrapper<AiDiagnosisSuggestion> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiDiagnosisSuggestion::getPatientId, patientId)
.orderByDesc(AiDiagnosisSuggestion::getCreateTime);
return baseMapper.selectList(wrapper);
}
@Override
public List<AiDiagnosisSuggestion> findByEncounterId(Long encounterId) {
LambdaQueryWrapper<AiDiagnosisSuggestion> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiDiagnosisSuggestion::getEncounterId, encounterId)
.orderByDesc(AiDiagnosisSuggestion::getCreateTime);
return baseMapper.selectList(wrapper);
}
}

View File

@@ -0,0 +1,30 @@
import request from '@/utils/request'
export function aiDiagnosisSuggest(data) {
return request({
url: '/ai-diagnosis/suggest',
method: 'post',
params: data
})
}
export function getAiDiagnosisHistory(patientId) {
return request({
url: '/ai-diagnosis/history/' + patientId,
method: 'get'
})
}
export function getAiDiagnosisHistoryByEncounter(encounterId) {
return request({
url: '/ai-diagnosis/history/encounter/' + encounterId,
method: 'get'
})
}
export function acceptAiDiagnosis(id) {
return request({
url: '/ai-diagnosis/accept/' + id,
method: 'post'
})
}

View File

@@ -0,0 +1,186 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryFormRef" :inline="true" v-show="showSearch" label-width="100px">
<el-form-item label="患者ID" prop="patientId">
<el-input v-model="queryParams.patientId" placeholder="请输入患者ID" clearable style="width: 180px" @keyup.enter="handleQuery" />
</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-button type="success" icon="MagicStick" @click="handleNewSuggest">AI辅助诊断</el-button>
</el-form-item>
</el-form>
<vxe-table :data="historyList" :loading="loading" border stripe height="auto">
<vxe-column type="seq" title="序号" width="70" />
<vxe-column field="encounterId" title="就诊ID" width="100" />
<vxe-column field="symptomText" title="症状描述" min-width="200" show-overflow />
<vxe-column field="diagnosisSuggestions" title="AI诊断建议" min-width="300" show-overflow />
<vxe-column field="confidenceScore" title="置信度" width="100" align="center">
<template #default="{ row }">
<el-progress :percentage="Number(row.confidenceScore) || 0" :stroke-width="12" :text-inside="true" />
</template>
</vxe-column>
<vxe-column field="suggestionSource" title="来源" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.suggestionSource === 'llm' ? 'success' : 'info'">{{ row.suggestionSource }}</el-tag>
</template>
</vxe-column>
<vxe-column field="accepted" title="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.accepted ? 'success' : 'warning'" size="small">{{ row.accepted ? '已采纳' : '待处理' }}</el-tag>
</template>
</vxe-column>
<vxe-column field="createTime" title="创建时间" width="170" />
<vxe-column title="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="!row.accepted" link type="primary" icon="Check" v-hasPermi="['cdss:alert:acknowledge']" @click="handleAccept(row)">采纳</el-button>
<span v-else class="text-gray">-</span>
</template>
</vxe-column>
</vxe-table>
<el-dialog title="AI辅助诊断" v-model="suggestDialogVisible" width="600px" append-to-body>
<el-form :model="suggestForm" label-width="100px">
<el-form-item label="就诊ID" required>
<el-input v-model="suggestForm.encounterId" placeholder="请输入就诊ID" />
</el-form-item>
<el-form-item label="患者ID" required>
<el-input v-model="suggestForm.patientId" placeholder="请输入患者ID" />
</el-form-item>
<el-form-item label="症状描述" required>
<el-input v-model="suggestForm.symptomText" type="textarea" :rows="4" placeholder="请输入患者症状描述发热3天伴咳嗽" />
</el-form-item>
<el-form-item label="建议来源">
<el-select v-model="suggestForm.source" placeholder="请选择" clearable>
<el-option label="AI大模型" value="llm" />
<el-option label="规则引擎" value="rule" />
</el-select>
</el-form-item>
</el-form>
<div v-if="suggestResult" class="suggest-result">
<el-divider content-position="left">AI诊断结果</el-divider>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="诊断建议">{{ suggestResult.diagnosisSuggestions }}</el-descriptions-item>
<el-descriptions-item label="置信度">
<el-progress :percentage="Number(suggestResult.confidenceScore) || 0" :stroke-width="14" :text-inside="true" />
</el-descriptions-item>
<el-descriptions-item label="建议来源">{{ suggestResult.suggestionSource }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="suggestDialogVisible = false">关闭</el-button>
<el-button v-if="!suggestResult" type="primary" :loading="suggestLoading" @click="submitSuggest">获取建议</el-button>
<el-button v-if="suggestResult" type="success" @click="handleQuery">查看历史</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="AiDiagnosisSuggest">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { aiDiagnosisSuggest, getAiDiagnosisHistory, acceptAiDiagnosis } from '@/api/aidiagnosis/aiDiagnosis'
const historyList = ref([])
const loading = ref(false)
const showSearch = ref(true)
const queryParams = reactive({
patientId: ''
})
const suggestDialogVisible = ref(false)
const suggestLoading = ref(false)
const suggestResult = ref(null)
const suggestForm = reactive({
encounterId: '',
patientId: '',
symptomText: '',
source: 'llm'
})
const getList = async () => {
if (!queryParams.patientId) {
historyList.value = []
return
}
loading.value = true
try {
const res = await getAiDiagnosisHistory(queryParams.patientId)
if (res.code === 200) {
historyList.value = res.data || []
}
} finally {
loading.value = false
}
}
const handleQuery = () => {
getList()
}
const resetQuery = () => {
queryParams.patientId = ''
historyList.value = []
}
const handleNewSuggest = () => {
suggestForm.encounterId = ''
suggestForm.patientId = ''
suggestForm.symptomText = ''
suggestForm.source = 'llm'
suggestResult.value = null
suggestDialogVisible.value = true
}
const submitSuggest = async () => {
if (!suggestForm.encounterId || !suggestForm.patientId) {
ElMessage.error('就诊ID和患者ID不能为空')
return
}
if (!suggestForm.symptomText.trim()) {
ElMessage.error('症状描述不能为空')
return
}
suggestLoading.value = true
try {
const res = await aiDiagnosisSuggest({
encounterId: suggestForm.encounterId,
patientId: suggestForm.patientId,
symptomText: suggestForm.symptomText,
source: suggestForm.source
})
if (res.code === 200) {
suggestResult.value = res.data
ElMessage.success('AI诊断建议已生成')
} else {
ElMessage.error(res.msg || '生成建议失败')
}
} catch (e) {
ElMessage.error('生成建议失败')
} finally {
suggestLoading.value = false
}
}
const handleAccept = async (row) => {
try {
const res = await acceptAiDiagnosis(row.id)
if (res.code === 200) {
ElMessage.success('已采纳该建议')
getList()
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
}
</script>
<style scoped>
.suggest-result {
margin-top: 16px;
}
</style>