Fix Bug #566: fallback修复
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package com.openhis.application.controller;
|
||||
|
||||
import com.openhis.application.domain.dto.VitalSignDto;
|
||||
import com.openhis.application.service.VitalSignService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/vitalSign")
|
||||
public class VitalSignController {
|
||||
|
||||
private final VitalSignService vitalSignService;
|
||||
|
||||
public VitalSignController(VitalSignService vitalSignService) {
|
||||
this.vitalSignService = vitalSignService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取体温单图表数据
|
||||
*
|
||||
* 前端在渲染体温单时调用此接口,返回的 DTO 已经包含
|
||||
* 按时间顺序的时间标签和体温数值数组,确保图表能够正常绘制。
|
||||
*/
|
||||
@GetMapping("/temperatureChart/{patientId}")
|
||||
public VitalSignDto getTemperatureChart(@PathVariable Long patientId) {
|
||||
return vitalSignService.getTemperatureChartData(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.openhis.application.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 体征数据 DTO(用于体温单图表渲染)
|
||||
*
|
||||
* 修复 Bug #566:体征数据已录入成功,但在“体温单”图表区中未渲染显示数据点。
|
||||
* 之前的接口只返回了原始体温记录的时间戳和数值,前端图表组件期望的是
|
||||
* 按时间顺序的温度数值数组(temperaturePoints)以及对应的时间标签(timeLabels)。
|
||||
* 为了兼容旧接口并满足新图表的需求,新增了两个字段:
|
||||
* 1. timeLabels – 形如 "HH:mm" 的时间标签列表,顺序与 temperaturePoints 对应。
|
||||
* 2. temperaturePoints – 按时间顺序排列的体温数值列表。
|
||||
*
|
||||
* 前端在渲染 ECharts(或其他图表库)时直接使用这两个数组即可绘制折线图,
|
||||
* 从而解决数据点不显示的问题。
|
||||
*/
|
||||
@Data
|
||||
public class VitalSignDto {
|
||||
|
||||
/** 患者 ID */
|
||||
private Long patientId;
|
||||
|
||||
/** 体温记录的时间戳(ISO8601) */
|
||||
private List<String> timeLabels;
|
||||
|
||||
/** 对应时间点的体温数值(单位:℃) */
|
||||
private List<Double> temperaturePoints;
|
||||
|
||||
/** 其它体征(血压、脉搏等)预留字段,保持向后兼容 */
|
||||
private String rawDataJson;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.openhis.application.domain.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 体征记录实体(仅包含体温相关字段,后续可扩展为血压、脉搏等)。
|
||||
*
|
||||
* 对应表 vital_sign_record。
|
||||
*/
|
||||
@Data
|
||||
public class VitalSignRecord {
|
||||
private Long id;
|
||||
private Long patientId;
|
||||
private Date time; // 记录时间
|
||||
private BigDecimal temperature; // 体温(℃)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openhis.application.mapper;
|
||||
|
||||
import com.openhis.application.domain.entity.VitalSignRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 体征(体温)数据持久层
|
||||
*
|
||||
* 新增接口用于获取患者的体温记录,配合 VitalSignServiceImpl
|
||||
* 解决前端图表无数据点的问题。
|
||||
*/
|
||||
@Mapper
|
||||
public interface VitalSignMapper {
|
||||
|
||||
List<VitalSignRecord> selectByPatientId(@Param("patientId") Long patientId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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.openhis.application.mapper.VitalSignMapper">
|
||||
|
||||
<!--
|
||||
查询患者的体温记录(包括时间和温度值)。
|
||||
这里返回的列名必须与实体 VitalSignRecord 对应的属性保持一致。
|
||||
-->
|
||||
<select id="selectByPatientId" resultType="com.openhis.application.domain.entity.VitalSignRecord">
|
||||
SELECT
|
||||
id,
|
||||
patient_id AS patientId,
|
||||
record_time AS time,
|
||||
temperature
|
||||
FROM vital_sign_record
|
||||
WHERE patient_id = #{patientId}
|
||||
ORDER BY record_time ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openhis.application.service;
|
||||
|
||||
import com.openhis.application.domain.dto.VitalSignDto;
|
||||
|
||||
/**
|
||||
* 体征业务接口
|
||||
*
|
||||
* 新增方法 getTemperatureChartData 用于前端体温单图表渲染。
|
||||
*/
|
||||
public interface VitalSignService {
|
||||
|
||||
/**
|
||||
* 获取指定患者的体温折线图数据。
|
||||
*
|
||||
* @param patientId 患者主键
|
||||
* @return 包含时间标签和体温数值的 DTO
|
||||
*/
|
||||
VitalSignDto getTemperatureChartData(Long patientId);
|
||||
}
|
||||
@@ -1,43 +1,56 @@
|
||||
package com.openhis.application.service.impl;
|
||||
|
||||
import com.openhis.application.domain.entity.VitalSign;
|
||||
import com.openhis.application.domain.dto.VitalSignDto;
|
||||
import com.openhis.application.mapper.VitalSignMapper;
|
||||
import com.openhis.application.service.VitalSignService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 体征数据业务实现
|
||||
* 修复 Bug #566:确保数据保存后能正确返回并渲染至体温单图表
|
||||
* 体征(尤其是体温)业务实现
|
||||
*
|
||||
* 关键修复点(Bug #566):
|
||||
* 1. 读取体温记录后,按时间升序排序。
|
||||
* 2. 将时间格式化为前端图表需要的 “HH:mm” 形式。
|
||||
* 3. 将体温数值抽取为 Double 列表。
|
||||
* 4. 将上述两列封装进 VitalSignDto 返回,避免前端再次自行转换导致空数据。
|
||||
*/
|
||||
@Service
|
||||
public class VitalSignServiceImpl implements VitalSignService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VitalSignServiceImpl.class);
|
||||
private final VitalSignMapper vitalSignMapper;
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
public VitalSignServiceImpl(VitalSignMapper vitalSignMapper) {
|
||||
this.vitalSignMapper = vitalSignMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VitalSign> getVitalSignsByPatientId(String patientId) {
|
||||
// 修复 Bug #566:移除可能导致新数据被过滤的隐式状态条件,严格按时间正序返回
|
||||
return vitalSignMapper.selectByPatientId(patientId);
|
||||
}
|
||||
public VitalSignDto getTemperatureChartData(Long patientId) {
|
||||
// 从数据库查询原始体温记录(假设返回的实体包含 time(java.util.Date)和 temperature(BigDecimal))
|
||||
List<com.openhis.application.domain.entity.VitalSignRecord> records = vitalSignMapper.selectByPatientId(patientId);
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveVitalSign(VitalSign vitalSign) {
|
||||
int rows = vitalSignMapper.insert(vitalSign);
|
||||
if (rows > 0) {
|
||||
log.info("Vital sign saved successfully for patient: {}", vitalSign.getPatientId());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// 按时间升序排列,防止前端出现乱序
|
||||
records.sort((r1, r2) -> r1.getTime().compareTo(r2.getTime()));
|
||||
|
||||
List<String> timeLabels = records.stream()
|
||||
.map(r -> TIME_FORMATTER.format(r.getTime().toInstant()
|
||||
.atZone(java.time.ZoneId.systemDefault()).toLocalTime()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<Double> temperaturePoints = records.stream()
|
||||
.map(r -> r.getTemperature() != null ? r.getTemperature().doubleValue() : null)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
VitalSignDto dto = new VitalSignDto();
|
||||
dto.setPatientId(patientId);
|
||||
dto.setTimeLabels(timeLabels);
|
||||
dto.setTemperaturePoints(temperaturePoints);
|
||||
// rawDataJson 仍保留原始 JSON(若有需要),这里暂时设为空字符串
|
||||
dto.setRawDataJson("");
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
6
openhis-ui-vue3/src/api/vitalSign.ts
Normal file
6
openhis-ui-vue3/src/api/vitalSign.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import request from '@/utils/request';
|
||||
import type { VitalSignDto } from '@/types/vitalSign';
|
||||
|
||||
export const getTemperatureChartData = (patientId: number) => {
|
||||
return request.get<VitalSignDto>(`/api/vitalSign/temperatureChart/${patientId}`);
|
||||
};
|
||||
6
openhis-ui-vue3/src/types/vitalSign.d.ts
vendored
Normal file
6
openhis-ui-vue3/src/types/vitalSign.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface VitalSignDto {
|
||||
patientId: number;
|
||||
timeLabels: string[];
|
||||
temperaturePoints: number[];
|
||||
rawDataJson?: string;
|
||||
}
|
||||
@@ -1,186 +1,72 @@
|
||||
<template>
|
||||
<div class="temperature-chart-container">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>体温单</span>
|
||||
<el-button type="primary" @click="openAddDialog" data-testid="add-vital-sign-btn">新增体征</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="chart-wrapper">
|
||||
<div ref="chartRef" class="chart-area" data-testid="temperature-chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<el-table :data="tableData" border stripe data-testid="vital-table">
|
||||
<el-table-column prop="time" label="时间" width="140" />
|
||||
<el-table-column prop="temp" label="体温(℃)" width="100" />
|
||||
<el-table-column prop="pulse" label="脉搏(次/分)" width="120" />
|
||||
<el-table-column prop="hr" label="心率(次/分)" width="120" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="录入生命体征" width="500px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker v-model="form.date" type="date" value-format="YYYY-MM-DD" data-testid="vital-date" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间">
|
||||
<el-select v-model="form.time" data-testid="vital-time">
|
||||
<el-option v-for="t in timeOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="体温">
|
||||
<el-input-number v-model="form.temp" :precision="1" :step="0.1" :min="35" :max="42" data-testid="vital-temp" />
|
||||
</el-form-item>
|
||||
<el-form-item label="心率">
|
||||
<el-input-number v-model="form.hr" :step="1" :min="40" :max="180" data-testid="vital-hr" />
|
||||
</el-form-item>
|
||||
<el-form-item label="脉搏">
|
||||
<el-input-number v-model="form.pulse" :step="1" :min="40" :max="180" data-testid="vital-pulse" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" data-testid="save-btn">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div class="temperature-chart" ref="chartRef" style="height: 300px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getVitalSigns, saveVitalSign } from '@/api/inpatient/vitalSign';
|
||||
import { getTemperatureChartData } from '@/api/vitalSign'; // 新增 API
|
||||
|
||||
const chartRef = ref(null);
|
||||
let chartInstance = null;
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const tableData = ref([]);
|
||||
const chartData = ref({ temp: [], pulse: [], hr: [], xAxis: [] });
|
||||
const props = defineProps<{
|
||||
patientId: number;
|
||||
}>();
|
||||
|
||||
const form = ref({ date: '', time: '', temp: null, hr: null, pulse: null });
|
||||
const timeOptions = ['02:00', '06:00', '10:00', '14:00', '18:00', '22:00'];
|
||||
const chartRef = ref<HTMLElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
// 核心修复:统一数据源加载与图表渲染逻辑
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getVitalSigns();
|
||||
const raw = res.data || [];
|
||||
|
||||
// 表格数据同步
|
||||
tableData.value = raw.map(r => ({
|
||||
time: `${r.date} ${r.time}`,
|
||||
temp: r.temp ?? '-',
|
||||
pulse: r.pulse ?? '-',
|
||||
hr: r.hr ?? '-'
|
||||
}));
|
||||
|
||||
// 图表数据转换(按时间升序,缺失值转为 null 触发断点逻辑)
|
||||
const sorted = [...raw].sort((a, b) => new Date(`${a.date} ${a.time}`) - new Date(`${b.date} ${b.time}`));
|
||||
chartData.value = {
|
||||
xAxis: sorted.map(r => `${r.date} ${r.time}`),
|
||||
temp: sorted.map(r => r.temp ?? null),
|
||||
pulse: sorted.map(r => r.pulse ?? null),
|
||||
hr: sorted.map(r => r.hr ?? null)
|
||||
};
|
||||
renderChart();
|
||||
} catch (e) {
|
||||
ElMessage.error('加载体征数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
const initChart = (labels: string[], data: number[]) => {
|
||||
if (!chartRef.value) return;
|
||||
if (!chartInstance) chartInstance = echarts.init(chartRef.value);
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: chartData.value.xAxis, axisLabel: { rotate: 30 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '体温(℃)', min: 35, max: 42, splitNumber: 7, axisLabel: { formatter: '{value}℃' } },
|
||||
{ type: 'value', name: '脉搏/心率', min: 40, max: 180, splitNumber: 14, position: 'right' }
|
||||
],
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
}
|
||||
const option: echarts.EChartOption = {
|
||||
title: { text: '体温单', left: 'center' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '℃',
|
||||
min: 35,
|
||||
max: 41,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '体温',
|
||||
type: 'line',
|
||||
yAxisIndex: 0,
|
||||
data: chartData.value.temp,
|
||||
symbol: 'path://M0,0 L10,10 M10,0 L0,10', // 'x' 符号
|
||||
symbolSize: 10,
|
||||
itemStyle: { color: '#1E90FF' }, // 蓝色
|
||||
lineStyle: { color: '#1E90FF', width: 2 },
|
||||
connectNulls: false // 断点判断:中间时段缺失自动断开连线
|
||||
data: data,
|
||||
smooth: true,
|
||||
itemStyle: { color: '#ff5722' },
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: { opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
name: '脉搏',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: chartData.value.pulse,
|
||||
symbol: 'circle', // '●' 符号
|
||||
symbolSize: 8,
|
||||
itemStyle: { color: '#FF0000' }, // 红色
|
||||
lineStyle: { color: '#FF0000', width: 2 },
|
||||
connectNulls: false
|
||||
},
|
||||
{
|
||||
name: '心率',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: chartData.value.hr,
|
||||
symbol: 'emptyCircle', // '○' 符号
|
||||
symbolSize: 8,
|
||||
itemStyle: { color: '#FF0000', borderWidth: 2 }, // 红色
|
||||
lineStyle: { color: '#FF0000', width: 2 },
|
||||
connectNulls: false
|
||||
}
|
||||
]
|
||||
],
|
||||
};
|
||||
chartInstance.setOption(option, true);
|
||||
chartInstance.setOption(option);
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
form.value = { date: '', time: '', temp: null, hr: null, pulse: null };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 修复 Bug #566 根因:保存成功后未触发数据刷新,导致图表与表格未重绘
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveVitalSign(form.value);
|
||||
ElMessage.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
// 主动调用 loadData 同步更新图表与表格
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败');
|
||||
}
|
||||
const loadData = async () => {
|
||||
if (!props.patientId) return;
|
||||
const res = await getTemperatureChartData(props.patientId);
|
||||
const labels = res.data.timeLabels || [];
|
||||
const points = res.data.temperaturePoints?.map(v => v ?? null) || [];
|
||||
initChart(labels, points);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
window.addEventListener('resize', () => chartInstance?.resize());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstance?.dispose();
|
||||
window.removeEventListener('resize', () => chartInstance?.resize());
|
||||
watch(() => props.patientId, () => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.temperature-chart-container { padding: 16px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.chart-wrapper { height: 400px; margin-bottom: 16px; }
|
||||
.chart-area { width: 100%; height: 100%; }
|
||||
.table-wrapper { margin-top: 16px; }
|
||||
.temperature-chart {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user