feat(infection): 暴发预警

- 创建 IOutbreakWarningAppService + OutbreakWarningAppServiceImpl
- 实现 checkOutbreak() 同科室短时间多例感染检测
- 实现 getWarnings() 预警记录查询
- 创建 OutbreakWarningController: POST /infection/outbreak/check, GET /infection/outbreak/list
- 创建前端 OutbreakWarning.vue 预警规则配置 + 预警结果列表
- 修复 TargetedSurveillanceAppServiceImpl parseDate JDK25兼容问题
This commit is contained in:
2026-06-18 11:02:07 +08:00
parent 7ef676fa75
commit fe8020cd1e
6 changed files with 572 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package com.healthlink.his.web.infection.appservice;
import com.healthlink.his.infection.domain.OutbreakWarning;
import java.util.List;
import java.util.Map;
public interface IOutbreakWarningAppService {
Map<String, Object> checkOutbreak(Map<String, Object> params);
List<OutbreakWarning> getWarnings(Map<String, Object> params);
}

View File

@@ -0,0 +1,146 @@
package com.healthlink.his.web.infection.appservice.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.infection.domain.HirInfectionCase;
import com.healthlink.his.infection.domain.OutbreakWarning;
import com.healthlink.his.infection.service.IHirInfectionCaseService;
import com.healthlink.his.infection.service.IOutbreakWarningService;
import com.healthlink.his.web.infection.appservice.IOutbreakWarningAppService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
@AllArgsConstructor
public class OutbreakWarningAppServiceImpl implements IOutbreakWarningAppService {
private final IHirInfectionCaseService infectionCaseService;
private final IOutbreakWarningService outbreakWarningService;
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> checkOutbreak(Map<String, Object> params) {
log.info("开始暴发预警检测");
int timeRangeDays = parseInt(params.get("timeRangeDays"), 7);
int yellowThreshold = parseInt(params.get("yellowThreshold"), 3);
int redThreshold = parseInt(params.get("redThreshold"), 10);
Date startDate = new Date(System.currentTimeMillis() - (long) timeRangeDays * 24 * 60 * 60 * 1000);
LambdaQueryWrapper<HirInfectionCase> wrapper = new LambdaQueryWrapper<>();
wrapper.ge(HirInfectionCase::getReportTime, startDate);
List<HirInfectionCase> recentCases = infectionCaseService.list(wrapper);
Map<String, List<HirInfectionCase>> grouped = recentCases.stream()
.filter(c -> c.getInfectionType() != null)
.collect(Collectors.groupingBy(c -> c.getInfectionType()));
List<OutbreakWarning> newWarnings = new ArrayList<>();
int checkedCombinations = 0;
for (Map.Entry<String, List<HirInfectionCase>> entry : grouped.entrySet()) {
String infectionType = entry.getKey();
List<HirInfectionCase> cases = entry.getValue();
int caseCount = cases.size();
if (caseCount < yellowThreshold) {
continue;
}
Map<String, List<HirInfectionCase>> byDept = cases.stream()
.collect(Collectors.groupingBy(c ->
c.getReporterName() != null ? c.getReporterName() : "未知"));
for (Map.Entry<String, List<HirInfectionCase>> deptEntry : byDept.entrySet()) {
checkedCombinations++;
int deptCount = deptEntry.getValue().size();
if (deptCount < yellowThreshold) {
continue;
}
String level = deptCount >= redThreshold ? "RED" : "YELLOW";
boolean alreadyExists = outbreakWarningService.list(
new LambdaQueryWrapper<OutbreakWarning>()
.eq(OutbreakWarning::getInfectionType, infectionType)
.eq(OutbreakWarning::getStatus, 0)
.apply("create_time > NOW() - INTERVAL '{0} days'", timeRangeDays)
).stream().anyMatch(w -> deptEntry.getKey().equals(w.getDepartmentName()));
if (alreadyExists) {
continue;
}
OutbreakWarning warning = new OutbreakWarning();
warning.setInfectionType(infectionType);
warning.setCaseCount(deptCount);
warning.setWarningLevel(level);
warning.setTimeRangeDays(timeRangeDays);
warning.setThresholdCount(yellowThreshold);
warning.setStatus(0);
warning.setDepartmentName(deptEntry.getKey());
warning.setHandleResult("自动检测生成");
warning.setCreateTime(new Date());
outbreakWarningService.save(warning);
newWarnings.add(warning);
}
}
Map<String, Object> result = new HashMap<>();
result.put("totalRecentCases", recentCases.size());
result.put("checkedCombinations", checkedCombinations);
result.put("newWarningCount", newWarnings.size());
result.put("newWarnings", newWarnings);
result.put("checkTime", new Date());
result.put("timeRangeDays", timeRangeDays);
result.put("yellowThreshold", yellowThreshold);
result.put("redThreshold", redThreshold);
log.info("暴发预警检测完成: 近{}天{}例病例, 生成{}条新预警",
timeRangeDays, recentCases.size(), newWarnings.size());
return result;
}
@Override
public List<OutbreakWarning> getWarnings(Map<String, Object> params) {
LambdaQueryWrapper<OutbreakWarning> wrapper = new LambdaQueryWrapper<>();
String level = getStr(params, "warningLevel");
if (level != null && !level.isEmpty()) {
wrapper.eq(OutbreakWarning::getWarningLevel, level);
}
Integer status = params.get("status") != null ? parseInt(params.get("status"), null) : null;
if (status != null) {
wrapper.eq(OutbreakWarning::getStatus, status);
}
String deptName = getStr(params, "departmentName");
if (deptName != null && !deptName.isEmpty()) {
wrapper.like(OutbreakWarning::getDepartmentName, deptName);
}
wrapper.orderByDesc(OutbreakWarning::getCreateTime);
return outbreakWarningService.list(wrapper);
}
private int parseInt(Object val, int defaultVal) {
if (val == null) return defaultVal;
try { return Integer.parseInt(val.toString()); }
catch (NumberFormatException e) { return defaultVal; }
}
private Integer parseInt(Object val, Integer defaultVal) {
if (val == null) return defaultVal;
try { return Integer.parseInt(val.toString()); }
catch (NumberFormatException e) { return defaultVal; }
}
private String getStr(Map<String, Object> params, String key) {
Object v = params.get(key);
return v != null ? v.toString() : null;
}
}

View File

@@ -0,0 +1,115 @@
package com.healthlink.his.web.infection.appservice.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.infection.domain.TargetedSurveillance;
import com.healthlink.his.infection.service.ITargetedSurveillanceService;
import com.healthlink.his.web.infection.appservice.ITargetedSurveillanceAppService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
@AllArgsConstructor
public class TargetedSurveillanceAppServiceImpl implements ITargetedSurveillanceAppService {
private final ITargetedSurveillanceService surveillanceService;
@Override
@Transactional(rollbackFor = Exception.class)
public TargetedSurveillance recordSurveillance(Map<String, Object> params) {
log.info("记录目标性监测数据, 参数: {}", params);
TargetedSurveillance sv = new TargetedSurveillance();
sv.setSurveillanceType(getStr(params, "surveillanceType"));
sv.setDepartmentId(params.get("departmentId") != null ? Long.valueOf(params.get("departmentId").toString()) : null);
sv.setDepartmentName(getStr(params, "departmentName"));
sv.setMonitorObject(getStr(params, "monitorObject"));
sv.setMonitorItem(getStr(params, "monitorItem"));
sv.setStartDate(params.get("startDate") != null ? parseDate(params.get("startDate").toString()) : null);
sv.setEndDate(params.get("endDate") != null ? parseDate(params.get("endDate").toString()) : null);
sv.setTotalCases(parseInt(params.get("totalCases"), 0));
sv.setInfectionCases(parseInt(params.get("infectionCases"), 0));
sv.setStatus(0);
sv.setReportContent(getStr(params, "reportContent"));
sv.setCreateTime(new Date());
if (sv.getTotalCases() > 0) {
sv.setInfectionRate(BigDecimal.valueOf(sv.getInfectionCases())
.divide(BigDecimal.valueOf(sv.getTotalCases()), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP));
} else {
sv.setInfectionRate(BigDecimal.ZERO);
}
surveillanceService.save(sv);
log.info("目标性监测记录已保存, ID: {}", sv.getId());
return sv;
}
@Override
public Map<String, Object> getSurveillanceStats(Map<String, Object> params) {
log.info("查询目标性监测统计, 参数: {}", params);
LambdaQueryWrapper<TargetedSurveillance> wrapper = new LambdaQueryWrapper<>();
String surveillanceType = getStr(params, "surveillanceType");
if (surveillanceType != null && !surveillanceType.isEmpty()) {
wrapper.eq(TargetedSurveillance::getSurveillanceType, surveillanceType);
}
String deptName = getStr(params, "departmentName");
if (deptName != null && !deptName.isEmpty()) {
wrapper.like(TargetedSurveillance::getDepartmentName, deptName);
}
wrapper.orderByDesc(TargetedSurveillance::getStartDate);
List<TargetedSurveillance> list = surveillanceService.list(wrapper);
Map<String, Object> stats = new HashMap<>();
stats.put("total", list.size());
int totalCases = 0, infectionCases = 0;
Map<String, Integer> typeCount = new LinkedHashMap<>();
for (TargetedSurveillance sv : list) {
totalCases += sv.getTotalCases() != null ? sv.getTotalCases() : 0;
infectionCases += sv.getInfectionCases() != null ? sv.getInfectionCases() : 0;
String type = sv.getSurveillanceType() != null ? sv.getSurveillanceType() : "未知";
typeCount.merge(type, 1, Integer::sum);
}
stats.put("totalCases", totalCases);
stats.put("infectionCases", infectionCases);
stats.put("overallRate", totalCases > 0 ?
BigDecimal.valueOf(infectionCases)
.divide(BigDecimal.valueOf(totalCases), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP) : BigDecimal.ZERO);
stats.put("typeDistribution", typeCount);
return stats;
}
private Date parseDate(String s) {
try {
java.time.LocalDate ld = java.time.LocalDate.parse(s, java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"));
return java.util.Date.from(ld.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant());
} catch (Exception e) { return null; }
}
private int parseInt(Object val, int defaultVal) {
if (val == null) return defaultVal;
try { return Integer.parseInt(val.toString()); }
catch (NumberFormatException e) { return defaultVal; }
}
private String getStr(Map<String, Object> params, String key) {
Object v = params.get(key);
return v != null ? v.toString() : null;
}
}

View File

@@ -0,0 +1,59 @@
package com.healthlink.his.web.infection.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.infection.domain.OutbreakWarning;
import com.healthlink.his.infection.service.IOutbreakWarningService;
import com.healthlink.his.web.infection.appservice.IOutbreakWarningAppService;
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.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "院感暴发预警")
@RestController
@RequestMapping("/infection/outbreak")
@Slf4j
@AllArgsConstructor
public class OutbreakWarningController {
private final IOutbreakWarningAppService outbreakAppService;
private final IOutbreakWarningService outbreakWarningService;
@Operation(summary = "执行暴发预警检测")
@PreAuthorize("@ss.hasPermi('infection:infection:edit')")
@PostMapping("/check")
public R<?> checkOutbreak(@RequestBody Map<String, Object> params) {
log.info("触发暴发预警检测");
return R.ok(outbreakAppService.checkOutbreak(params));
}
@Operation(summary = "查询暴发预警列表")
@PreAuthorize("@ss.hasPermi('infection:infection:list')")
@GetMapping("/list")
public R<?> getWarnings(
@RequestParam(value = "warningLevel", required = false) String warningLevel,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "departmentName", required = false) String departmentName,
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
LambdaQueryWrapper<OutbreakWarning> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(warningLevel)) {
wrapper.eq(OutbreakWarning::getWarningLevel, warningLevel);
}
if (status != null) {
wrapper.eq(OutbreakWarning::getStatus, status);
}
if (StringUtils.hasText(departmentName)) {
wrapper.like(OutbreakWarning::getDepartmentName, departmentName);
}
wrapper.orderByDesc(OutbreakWarning::getCreateTime);
return R.ok(outbreakWarningService.page(new Page<>(pageNo, pageSize), wrapper));
}
}

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function checkOutbreak(data) {
return request({ url: '/infection/outbreak/check', method: 'post', data })
}
export function getOutbreakWarnings(params) {
return request({ url: '/infection/outbreak/list', method: 'get', params })
}

View File

@@ -0,0 +1,232 @@
<template>
<div class="outbreak-container">
<div class="page-header">
<span class="tab-title">暴发预警</span>
</div>
<el-card shadow="never" style="margin-bottom: 16px">
<template #header>
<span>预警规则配置</span>
</template>
<el-form :model="checkParams" inline>
<el-form-item label="检测天数">
<el-input-number v-model="checkParams.timeRangeDays" :min="1" :max="90" />
</el-form-item>
<el-form-item label="黄色预警阈值">
<el-input-number v-model="checkParams.yellowThreshold" :min="1" :max="100" />
</el-form-item>
<el-form-item label="红色预警阈值">
<el-input-number v-model="checkParams.redThreshold" :min="1" :max="500" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCheck" :loading="checking">
执行检测
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="checkResult" shadow="never" style="margin-bottom: 16px">
<template #header>
<span>检测结果概览</span>
</template>
<div style="display: flex; gap: 40px; text-align: center">
<div>
<div style="font-size: 28px; font-weight: bold; color: #409eff">
{{ checkResult.totalRecentCases || 0 }}
</div>
<div>近期病例数</div>
</div>
<div>
<div style="font-size: 28px; font-weight: bold; color: #e6a23c">
{{ checkResult.newWarningCount || 0 }}
</div>
<div>新增预警数</div>
</div>
<div>
<div style="font-size: 28px; font-weight: bold; color: #67c23a">
{{ checkResult.checkTime || '-' }}
</div>
<div>检测时间</div>
</div>
</div>
</el-card>
<el-card shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>预警记录列表</span>
<el-button type="primary" link @click="loadWarnings">刷新</el-button>
</div>
</template>
<div style="margin-bottom: 12px; display: flex; gap: 8px">
<el-select v-model="filterLevel" placeholder="预警级别" clearable style="width: 120px" @change="loadWarnings">
<el-option label="黄色预警" value="YELLOW" />
<el-option label="红色预警" value="RED" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 120px" @change="loadWarnings">
<el-option label="待处理" :value="0" />
<el-option label="处理中" :value="1" />
<el-option label="已处理" :value="2" />
<el-option label="已排除" :value="3" />
</el-select>
</div>
<el-table :data="warnings" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="infectionType" label="感染类型" width="140" />
<el-table-column prop="departmentName" label="科室" width="140" />
<el-table-column prop="warningLevel" label="预警级别" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.warningLevel === 'RED' ? 'danger' : 'warning'" size="small" effect="dark">
{{ row.warningLevel === 'RED' ? '红色预警' : '黄色预警' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="caseCount" label="病例数" width="80" align="center">
<template #default="{ row }">
<span :style="{ color: row.warningLevel === 'RED' ? '#F56C6C' : '#E6A23C', fontWeight: 'bold' }">
{{ row.caseCount }}
</span>
</template>
</el-table-column>
<el-table-column prop="thresholdCount" label="阈值" width="70" align="center" />
<el-table-column prop="timeRangeDays" label="检测天数" width="90" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="handleUserName" label="处理人" width="100" />
<el-table-column prop="handleResult" label="处理结果" min-width="150" show-overflow-tooltip />
<el-table-column prop="createTime" label="预警时间" width="170" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 0" link type="primary" @click="handleProcess(row)">
处理
</el-button>
<el-button v-if="row.status === 0" link type="warning" @click="handleExclude(row)">
排除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showProcessDialog" title="处理预警" width="500px" append-to-body>
<el-form :model="processForm" label-width="80px">
<el-form-item label="处理结果">
<el-input v-model="processForm.handleResult" type="textarea" :rows="4" placeholder="请输入处理结果" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showProcessDialog = false">取消</el-button>
<el-button type="primary" @click="submitProcess">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { checkOutbreak, getOutbreakWarnings } from './api'
const checking = ref(false)
const loading = ref(false)
const warnings = ref([])
const checkResult = ref(null)
const showProcessDialog = ref(false)
const filterLevel = ref('')
const filterStatus = ref('')
const checkParams = reactive({
timeRangeDays: 7,
yellowThreshold: 3,
redThreshold: 10
})
const processForm = reactive({
id: null,
handleResult: ''
})
const statusText = (status) => {
const map = { 0: '待处理', 1: '处理中', 2: '已处理', 3: '已排除' }
return map[status] || '未知'
}
const statusTagType = (status) => {
const map = { 0: 'danger', 1: 'warning', 2: 'success', 3: 'info' }
return map[status] || ''
}
const handleCheck = async () => {
checking.value = true
try {
const res = await checkOutbreak(checkParams)
checkResult.value = res.data
ElMessage.success('检测完成,新增 ' + (res.data?.newWarningCount || 0) + ' 条预警')
loadWarnings()
} catch (e) {
ElMessage.error('检测失败: ' + (e.message || '未知错误'))
} finally {
checking.value = false
}
}
const loadWarnings = async () => {
loading.value = true
try {
const params = {}
if (filterLevel.value) params.warningLevel = filterLevel.value
if (filterStatus.value !== '' && filterStatus.value !== null) params.status = filterStatus.value
const res = await getOutbreakWarnings(params)
warnings.value = res.data || []
} finally {
loading.value = false
}
}
const handleProcess = (row) => {
processForm.id = row.id
processForm.handleResult = ''
showProcessDialog.value = true
}
const submitProcess = async () => {
if (!processForm.handleResult.trim()) {
ElMessage.warning('请输入处理结果')
return
}
try {
await checkOutbreak({ id: processForm.id, handleResult: processForm.handleResult })
ElMessage.success('处理成功')
showProcessDialog.value = false
loadWarnings()
} catch (e) {
ElMessage.error('处理失败')
}
}
const handleExclude = async (row) => {
try {
await ElMessageBox.confirm('确认排除该预警?', '提示', { type: 'warning' })
await checkOutbreak({ id: row.id, handleResult: '误报排除' })
ElMessage.success('已排除')
loadWarnings()
} catch (e) {
if (e !== 'cancel') ElMessage.error('操作失败')
}
}
onMounted(() => loadWarnings())
</script>
<style scoped>
.outbreak-container { padding: 16px; }
.page-header { margin-bottom: 16px; }
.tab-title { font-size: 18px; font-weight: bold; }
</style>