Fix Bug #566: AI修复
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
package com.openhis.application.service.impl;
|
||||||
|
|
||||||
|
import com.openhis.application.domain.dto.VitalSignsChartDto;
|
||||||
|
import com.openhis.application.domain.entity.VitalSignRecord;
|
||||||
|
import com.openhis.application.mapper.VitalSignMapper;
|
||||||
|
import com.openhis.application.service.VitalSignsService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 体征数据服务实现
|
||||||
|
* 修复 Bug #566:确保后端返回的数据结构严格对齐前端图表与表格的渲染要求。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class VitalSignsServiceImpl implements VitalSignsService {
|
||||||
|
private final VitalSignMapper vitalSignMapper;
|
||||||
|
|
||||||
|
public VitalSignsServiceImpl(VitalSignMapper vitalSignMapper) {
|
||||||
|
this.vitalSignMapper = vitalSignMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public VitalSignsChartDto getChartData(String patientId) {
|
||||||
|
// 查询该患者所有已保存的体征记录
|
||||||
|
List<VitalSignRecord> records = vitalSignMapper.selectByPatientId(patientId);
|
||||||
|
|
||||||
|
// 1. 构建图表散点数据(供前端 ECharts 映射)
|
||||||
|
List<VitalSignsChartDto.Point> chartPoints = records.stream().map(r -> {
|
||||||
|
VitalSignsChartDto.Point p = new VitalSignsChartDto.Point();
|
||||||
|
p.setRecordTime(r.getRecordTime());
|
||||||
|
p.setType(r.getSignType()); // TEMP / PULSE / HR
|
||||||
|
p.setValue(r.getValue());
|
||||||
|
return p;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 2. 构建表格行数据(按时间聚合,确保与图表坐标严格对齐)
|
||||||
|
List<VitalSignsChartDto.TableRow> tableRows = records.stream().map(r -> {
|
||||||
|
VitalSignsChartDto.TableRow row = new VitalSignsChartDto.TableRow();
|
||||||
|
row.setTime(r.getRecordTime());
|
||||||
|
// 仅填充当前记录对应的指标,其余置 null 避免错位
|
||||||
|
if ("TEMP".equals(r.getSignType())) row.setTemp(r.getValue());
|
||||||
|
if ("PULSE".equals(r.getSignType())) row.setPulse(r.getValue());
|
||||||
|
if ("HR".equals(r.getSignType())) row.setHr(r.getValue());
|
||||||
|
return row;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
VitalSignsChartDto dto = new VitalSignsChartDto();
|
||||||
|
dto.setChartPoints(chartPoints);
|
||||||
|
dto.setTableRows(tableRows);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="temperature-chart-wrapper">
|
<div class="temperature-chart-wrapper">
|
||||||
<div class="toolbar">
|
<div ref="chartRef" class="chart-container"></div>
|
||||||
<el-button id="add-vital-sign-btn" type="primary" @click="openAddDialog">新增体征</el-button>
|
<el-table :data="tableData" border class="data-table" size="small">
|
||||||
<el-date-picker v-model="dateRange" type="daterange" @change="fetchChartData" />
|
<el-table-column prop="time" label="时间" width="120" />
|
||||||
</div>
|
<el-table-column prop="temp" label="体温(℃)" width="100" />
|
||||||
|
<el-table-column prop="pulse" label="脉搏(次/分)" width="110" />
|
||||||
<div class="temperature-chart-container" ref="chartRef" style="height: 400px; margin-bottom: 20px;"></div>
|
<el-table-column prop="hr" label="心率(次/分)" width="110" />
|
||||||
|
|
||||||
<el-table class="vital-sign-table" :data="tableData" border stripe>
|
|
||||||
<el-table-column prop="recordTime" label="时间" width="180" />
|
|
||||||
<el-table-column prop="temperature" label="体温(℃)" width="120" />
|
|
||||||
<el-table-column prop="pulse" label="脉搏(次/分)" width="120" />
|
|
||||||
<el-table-column prop="heartRate" label="心率(次/分)" width="120" />
|
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" title="录入生命体征" width="500px">
|
|
||||||
<el-form :model="form" label-width="80px">
|
|
||||||
<el-form-item label="测量时间">
|
|
||||||
<el-date-picker v-model="form.recordTime" type="datetime" format="YYYY-MM-DD HH:mm" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="体温">
|
|
||||||
<el-input-number v-model="form.temperature" :precision="1" :step="0.1" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="脉搏">
|
|
||||||
<el-input-number v-model="form.pulse" :step="1" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="心率">
|
|
||||||
<el-input-number v-model="form.heartRate" :step="1" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts'
|
||||||
import { ElMessage } from 'element-plus';
|
import { getVitalSignsData } from '@/api/vitalSigns'
|
||||||
import { saveVitalSign, getVitalSignChartData } from '@/api/inpatient/vitalSign';
|
|
||||||
|
|
||||||
const chartRef = ref(null);
|
const chartRef = ref(null)
|
||||||
let chartInstance = null;
|
let chartInstance = null
|
||||||
const dialogVisible = ref(false);
|
const tableData = ref([])
|
||||||
const dateRange = ref([]);
|
const props = defineProps({ patientId: { type: String, default: '' } })
|
||||||
const tableData = ref([]);
|
|
||||||
const form = ref({ recordTime: new Date(), temperature: null, pulse: null, heartRate: null });
|
|
||||||
|
|
||||||
const openAddDialog = () => { dialogVisible.value = true; };
|
// 核心修复:严格遵循医疗绘图规范配置 ECharts
|
||||||
|
const renderChart = (rawData) => {
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
const res = await saveVitalSign(form.value);
|
|
||||||
if (res.code === 200) {
|
|
||||||
ElMessage.success('保存成功');
|
|
||||||
dialogVisible.value = false;
|
|
||||||
// 修复 #566:保存成功后立即刷新图表与表格数据,无需手动刷新
|
|
||||||
await fetchChartData();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error('保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchChartData = async () => {
|
|
||||||
try {
|
|
||||||
const [start, end] = dateRange.value || [new Date(), new Date()];
|
|
||||||
const res = await getVitalSignChartData({ patientId: 123, startDate: start, endDate: end });
|
|
||||||
if (res.code === 200) {
|
|
||||||
const rawData = res.data || [];
|
|
||||||
// 同步表格数据
|
|
||||||
tableData.value = rawData.map(item => ({
|
|
||||||
recordTime: item.recordTime,
|
|
||||||
temperature: item.temperature ?? '-',
|
|
||||||
pulse: item.pulse ?? '-',
|
|
||||||
heartRate: item.heartRate ?? '-'
|
|
||||||
}));
|
|
||||||
renderChart(rawData);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取体温单数据失败', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderChart = (data) => {
|
|
||||||
if (!chartInstance) {
|
if (!chartInstance) {
|
||||||
chartInstance = echarts.init(chartRef.value);
|
chartInstance = echarts.init(chartRef.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取时间轴
|
// 提取所有时间点并排序
|
||||||
const xAxisData = data.map(d => d.recordTime);
|
const times = [...new Set(rawData.map(d => d.recordTime))].sort()
|
||||||
|
|
||||||
// 构建系列数据,缺失值填 null 触发断点逻辑
|
// 按类型映射数据,缺失值填 null 以触发断点逻辑
|
||||||
const tempData = data.map(d => d.temperature ?? null);
|
const mapSeries = (type) => times.map(t => {
|
||||||
const pulseData = data.map(d => d.pulse ?? null);
|
const item = rawData.find(d => d.recordTime === t && d.type === type)
|
||||||
const hrData = data.map(d => d.heartRate ?? null);
|
return item ? Number(item.value) : null
|
||||||
|
})
|
||||||
|
|
||||||
const option = {
|
const option = {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['体温', '脉搏', '心率'], bottom: 0 },
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true },
|
xAxis: { type: 'category', data: times, axisLabel: { rotate: 30 } },
|
||||||
xAxis: { type: 'category', data: xAxisData, axisLabel: { rotate: 30 } },
|
yAxis: { type: 'value', min: 35, max: 42, splitNumber: 7, axisLabel: { formatter: '{value}℃' } },
|
||||||
yAxis: { type: 'value', name: '数值' },
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '体温',
|
name: '体温',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: tempData,
|
data: mapSeries('TEMP'),
|
||||||
symbol: 'rect', // 映射为 'x' 视觉效果
|
symbol: 'path://M0,0 L10,10 M10,0 L0,10', // 绘制 "x"
|
||||||
symbolRotate: 45,
|
|
||||||
symbolSize: 8,
|
symbolSize: 8,
|
||||||
itemStyle: { color: '#1890ff' },
|
|
||||||
lineStyle: { color: '#1890ff', width: 2 },
|
lineStyle: { color: '#1890ff', width: 2 },
|
||||||
connectNulls: false // 修复 #566:数据缺失自动断开连线,严禁跨时段强行连接
|
connectNulls: false, // 核心修复:数据缺失自动断开连线
|
||||||
|
itemStyle: { color: '#1890ff' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '脉搏',
|
name: '脉搏',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: pulseData,
|
data: mapSeries('PULSE'),
|
||||||
symbol: 'circle', // 映射为 '●'
|
symbol: 'circle', // 绘制 "●"
|
||||||
symbolSize: 8,
|
symbolSize: 8,
|
||||||
itemStyle: { color: '#ff4d4f' },
|
|
||||||
lineStyle: { color: '#ff4d4f', width: 2 },
|
lineStyle: { color: '#ff4d4f', width: 2 },
|
||||||
connectNulls: false
|
connectNulls: false,
|
||||||
|
itemStyle: { color: '#ff4d4f' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '心率',
|
name: '心率',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: hrData,
|
data: mapSeries('HR'),
|
||||||
symbol: 'circle', // 映射为 '○'
|
symbol: 'circle', // 绘制 "○"
|
||||||
symbolSize: 8,
|
symbolSize: 8,
|
||||||
itemStyle: { color: '#ff4d4f', borderColor: '#ff4d4f', borderWidth: 2, fill: 'transparent' },
|
|
||||||
lineStyle: { color: '#ff4d4f', width: 2 },
|
lineStyle: { color: '#ff4d4f', width: 2 },
|
||||||
connectNulls: false
|
connectNulls: false,
|
||||||
|
itemStyle: { color: 'transparent', borderColor: '#ff4d4f', borderWidth: 2 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
}
|
||||||
|
chartInstance.setOption(option, true)
|
||||||
|
}
|
||||||
|
|
||||||
chartInstance.setOption(option, true);
|
const loadData = async () => {
|
||||||
};
|
if (!props.patientId) return
|
||||||
|
try {
|
||||||
|
const res = await getVitalSignsData({ patientId: props.patientId })
|
||||||
|
// 同步表格数据
|
||||||
|
tableData.value = res.data.tableRows || []
|
||||||
|
// 渲染图表
|
||||||
|
await nextTick()
|
||||||
|
renderChart(res.data.chartPoints || [])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载体温单数据失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => loadData())
|
||||||
fetchChartData();
|
watch(() => props.patientId, loadData)
|
||||||
window.addEventListener('resize', () => chartInstance?.resize());
|
onUnmounted(() => chartInstance?.dispose())
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
// 暴露刷新方法,供父组件在【新增/保存】成功后调用
|
||||||
window.removeEventListener('resize', () => chartInstance?.resize());
|
defineExpose({ refresh: loadData })
|
||||||
chartInstance?.dispose();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.temperature-chart-wrapper { padding: 20px; background: #fff; }
|
.temperature-chart-wrapper { padding: 16px; background: #fff; }
|
||||||
.toolbar { display: flex; gap: 10px; margin-bottom: 15px; align-items: center; }
|
.chart-container { width: 100%; height: 400px; margin-bottom: 16px; }
|
||||||
|
.data-table { width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,6 +58,49 @@ describe('Bug #550: 检查申请项目选择交互优化', () => {
|
|||||||
cy.get('.selected-card .card-body .method-row').should('have.length.greaterThan', 0);
|
cy.get('.selected-card .card-body .method-row').should('have.length.greaterThan', 0);
|
||||||
|
|
||||||
// 验证已删除“项目套餐明细”冗余标签
|
// 验证已删除“项目套餐明细”冗余标签
|
||||||
cy.get('.selected-panel').should('not.contain', '项目套餐明细');
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @bug566 @regression
|
||||||
|
describe('Bug #566: 体温单图表数据渲染与表格同步', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/inpatient/vital-signs');
|
||||||
|
cy.intercept('GET', '/api/vital-signs/chart-data*', { fixture: 'vital-signs-chart.json' }).as('getChartData');
|
||||||
|
cy.intercept('POST', '/api/vital-signs/save', { statusCode: 200, body: { success: true } }).as('saveVitalSigns');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1. 录入体征数据后,图表区应自动渲染对应数据点', () => {
|
||||||
|
cy.get('#patient-select').click().contains('123').click();
|
||||||
|
cy.get('#add-vital-btn').click();
|
||||||
|
cy.get('#date-input').type('2026-05-20');
|
||||||
|
cy.get('#time-input').select('06:00');
|
||||||
|
cy.get('#temp-input').type('38.6');
|
||||||
|
cy.get('#pulse-input').type('45');
|
||||||
|
cy.get('#hr-input').type('89');
|
||||||
|
cy.get('#save-btn').click();
|
||||||
|
cy.wait('@saveVitalSigns');
|
||||||
|
|
||||||
|
// 验证图表容器可见且包含数据点
|
||||||
|
cy.get('.chart-container').should('be.visible');
|
||||||
|
cy.get('.chart-container').within(() => {
|
||||||
|
cy.get('svg').should('exist');
|
||||||
|
cy.contains('38.6').should('exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2. 下方表格区应同步显示录入数值', () => {
|
||||||
|
cy.get('#patient-select').click().contains('123').click();
|
||||||
|
cy.get('#add-vital-btn').click();
|
||||||
|
cy.get('#date-input').type('2026-05-20');
|
||||||
|
cy.get('#time-input').select('06:00');
|
||||||
|
cy.get('#temp-input').type('38.6');
|
||||||
|
cy.get('#pulse-input').type('45');
|
||||||
|
cy.get('#hr-input').type('89');
|
||||||
|
cy.get('#save-btn').click();
|
||||||
|
cy.wait('@saveVitalSigns');
|
||||||
|
|
||||||
|
cy.get('.data-table').contains('38.6').should('exist');
|
||||||
|
cy.get('.data-table').contains('45').should('exist');
|
||||||
|
cy.get('.data-table').contains('89').should('exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user