Fix Bug #566: AI修复

This commit is contained in:
2026-05-27 07:15:25 +08:00
parent f6662ae689
commit 028bea7d3a
4 changed files with 194 additions and 172 deletions

View File

@@ -0,0 +1,26 @@
package com.openhis.application.mapper;
import com.openhis.application.domain.entity.VitalSignsRecord;
import org.apache.ibatis.annotations.*;
import java.util.List;
/**
* 体征记录 Mapper
* 修复 Bug #566 数据查询层:确保按时间正序返回,且仅查询有效状态数据。
*/
@Mapper
public interface VitalSignsMapper {
@Insert("INSERT INTO hisdev.vital_signs_record " +
"(patient_id, record_time, temperature, heart_rate, pulse, status, create_time) " +
"VALUES (#{patientId}, #{recordTime}, #{temperature}, #{heartRate}, #{pulse}, #{status}, #{createTime})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(VitalSignsRecord record);
@Select("SELECT id, patient_id, record_time, temperature, heart_rate, pulse, status " +
"FROM hisdev.vital_signs_record " +
"WHERE patient_id = #{patientId} AND status = '1' " +
"ORDER BY record_time ASC")
List<VitalSignsRecord> selectByPatientId(@Param("patientId") Long patientId);
}

View File

@@ -1,9 +1,11 @@
package com.openhis.application.service.impl; package com.openhis.application.service.impl;
import com.openhis.application.domain.dto.VitalSignsChartDto; import com.openhis.application.domain.dto.VitalSignsDto;
import com.openhis.application.domain.entity.VitalSignRecord; import com.openhis.application.domain.entity.VitalSignsRecord;
import com.openhis.application.mapper.VitalSignMapper; import com.openhis.application.mapper.VitalSignsMapper;
import com.openhis.application.service.VitalSignsService; import com.openhis.application.service.VitalSignsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -11,46 +13,59 @@ import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 体征数据务实现 * 体征数据务实现
* 修复 Bug #566确保后端返回的数据结构严格对齐前端图表与表格的渲染要求。 *
* 修复 Bug #566
* 根因1. 保存成功后前端未触发图表数据重载;
* 2. 后端查询 SQL 未正确按 record_time 排序,且未过滤已删除/无效状态数据;
* 3. 返回 DTO 字段映射缺失,导致前端 ECharts 无法识别 null 值断点。
* 修复方案:
* - 规范 Mapper 查询逻辑,增加 ORDER BY record_time ASC 及有效状态过滤。
* - 完善 DTO 转换,确保缺失值显式返回 null 而非 0触发前端 connectNulls: false 断点逻辑。
* - 提供标准列表查询接口供前端图表/表格共用。
*/ */
@Service @Service
public class VitalSignsServiceImpl implements VitalSignsService { public class VitalSignsServiceImpl implements VitalSignsService {
private final VitalSignMapper vitalSignMapper;
public VitalSignsServiceImpl(VitalSignMapper vitalSignMapper) { private static final Logger logger = LoggerFactory.getLogger(VitalSignsServiceImpl.class);
this.vitalSignMapper = vitalSignMapper; private final VitalSignsMapper vitalSignsMapper;
public VitalSignsServiceImpl(VitalSignsMapper vitalSignsMapper) {
this.vitalSignsMapper = vitalSignsMapper;
} }
@Override @Override
@Transactional(readOnly = true) @Transactional(rollbackFor = Exception.class)
public VitalSignsChartDto getChartData(String patientId) { public boolean saveRecord(VitalSignsDto dto) {
// 查询该患者所有已保存的体征记录 VitalSignsRecord record = new VitalSignsRecord();
List<VitalSignRecord> records = vitalSignMapper.selectByPatientId(patientId); record.setPatientId(dto.getPatientId());
record.setRecordTime(dto.getRecordTime());
// 1. 构建图表散点数据(供前端 ECharts 映射) record.setTemperature(dto.getTemperature());
List<VitalSignsChartDto.Point> chartPoints = records.stream().map(r -> { record.setHeartRate(dto.getHeartRate());
VitalSignsChartDto.Point p = new VitalSignsChartDto.Point(); record.setPulse(dto.getPulse());
p.setRecordTime(r.getRecordTime()); record.setStatus("1"); // 1: 有效
p.setType(r.getSignType()); // TEMP / PULSE / HR record.setCreateTime(new java.util.Date());
p.setValue(r.getValue()); int rows = vitalSignsMapper.insert(record);
return p; logger.info("体征数据保存成功, patientId={}, rows={}", dto.getPatientId(), rows);
}).collect(Collectors.toList()); return rows > 0;
}
// 2. 构建表格行数据(按时间聚合,确保与图表坐标严格对齐) @Override
List<VitalSignsChartDto.TableRow> tableRows = records.stream().map(r -> { public List<VitalSignsDto> listByPatient(Long patientId) {
VitalSignsChartDto.TableRow row = new VitalSignsChartDto.TableRow(); List<VitalSignsRecord> records = vitalSignsMapper.selectByPatientId(patientId);
row.setTime(r.getRecordTime()); return records.stream().map(this::toDto).collect(Collectors.toList());
// 仅填充当前记录对应的指标,其余置 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(); private VitalSignsDto toDto(VitalSignsRecord record) {
dto.setChartPoints(chartPoints); VitalSignsDto dto = new VitalSignsDto();
dto.setTableRows(tableRows); dto.setId(record.getId());
dto.setPatientId(record.getPatientId());
// 格式化时间轴显示MM-dd HH:mm
dto.setRecordTime(record.getRecordTime() != null ?
new java.text.SimpleDateFormat("MM-dd HH:mm").format(record.getRecordTime()) : null);
dto.setTemperature(record.getTemperature());
dto.setHeartRate(record.getHeartRate());
dto.setPulse(record.getPulse());
return dto; return dto;
} }
} }

View File

@@ -1,122 +1,132 @@
<template> <template>
<div class="vital-signs-container"> <div class="vital-signs-container">
<div ref="chartRef" class="vital-signs-chart" style="height: 400px; width: 100%;"></div> <el-card shadow="never" class="chart-card">
<el-table :data="tableData" class="vital-signs-table" border style="margin-top: 12px;"> <div ref="chartRef" class="chart-container" data-connect-nulls="false"></div>
<el-table-column prop="time" label="时间" width="160" align="center" /> </el-card>
<el-table-column prop="temperature" label="体温(℃)" align="center" /> <el-card shadow="never" class="table-card">
<el-table-column prop="heartRate" label="心率(次/分)" align="center" /> <el-table :data="tableData" border stripe style="width: 100%">
<el-table-column prop="pulse" label="脉搏(次/分)" align="center" /> <el-table-column prop="recordTime" label="时间" width="180" align="center" />
</el-table> <el-table-column prop="temperature" label="体温(℃)" align="center">
<template #default="{ row }">{{ row.temperature ?? '-' }}</template>
</el-table-column>
<el-table-column prop="heartRate" label="心率(次/分)" align="center">
<template #default="{ row }">{{ row.heartRate ?? '-' }}</template>
</el-table-column>
<el-table-column prop="pulse" label="脉搏(次/分)" align="center">
<template #default="{ row }">{{ row.pulse ?? '-' }}</template>
</el-table-column>
</el-table>
</el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, onMounted, nextTick, watch } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { getVitalSigns } from '@/api/inpatient' import { getVitalSignsList } from '@/api/inpatient'
import { ElMessage } from 'element-plus'
const props = defineProps({ const props = defineProps({
patientId: { type: Number, required: true } patientId: { type: [String, Number], required: true },
refreshTrigger: { type: Boolean, default: false }
}) })
const chartRef = ref(null) const chartRef = ref(null)
let chartInstance = null let chartInstance = null
const tableData = ref([]) const tableData = ref([])
const chartData = reactive({
xAxis: [],
series: {
temperature: [],
heartRate: [],
pulse: []
}
})
const initChart = () => { const initChart = () => {
if (!chartRef.value) return if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value) chartInstance = echarts.init(chartRef.value)
window.addEventListener('resize', handleResize)
}
const handleResize = () => chartInstance?.resize()
const renderChart = (rawData) => {
if (!chartInstance) return
// 1. 按时间升序排序
const sorted = [...rawData].sort((a, b) => new Date(a.recordTime) - new Date(b.recordTime))
const timeAxis = sorted.map(d => d.timeStr)
// 2. 提取指标序列,缺失值填 null 触发 ECharts 断线逻辑
const tempData = sorted.map(d => d.temperature != null ? Number(d.temperature) : null)
const hrData = sorted.map(d => d.heartRate != null ? Number(d.heartRate) : null)
const pulseData = sorted.map(d => d.pulse != null ? Number(d.pulse) : null)
// 3. 重叠处理同一时间点三值完全一致时Y轴轻微偏移避免视觉重合
const adjustOverlap = (series, type) => {
return series.map((val, idx) => {
if (val === null) return null
const t = tempData[idx], h = hrData[idx], p = pulseData[idx]
if (t !== null && h !== null && p !== null && t === h && h === p) {
return type === 'temp' ? val + 0.15 : type === 'hr' ? val + 0.08 : val
}
return val
})
}
const option = { const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: timeAxis, axisLabel: { rotate: 30, interval: 0 } }, xAxis: { type: 'category', data: chartData.xAxis, axisLabel: { rotate: 30 } },
yAxis: [ yAxis: { type: 'value', min: 35, max: 42, splitNumber: 7 },
{ type: 'value', name: '体温(℃)', min: 35, max: 42, splitNumber: 7, axisLine: { lineStyle: { color: '#1890ff' } } },
{ type: 'value', name: '心率/脉搏', min: 40, max: 160, splitNumber: 12, axisLine: { lineStyle: { color: '#f5222d' } } }
],
series: [ series: [
{ {
name: '体温', type: 'line', yAxisIndex: 0, data: adjustOverlap(tempData, 'temp'), name: '体温',
symbol: 'path://M0,0 L10,10 M10,0 L0,10', symbolSize: 10, type: 'line',
lineStyle: { color: '#1890ff', width: 2 }, connectNulls: false data: chartData.series.temperature,
symbol: 'x',
symbolSize: 8,
itemStyle: { color: '#1890ff' },
lineStyle: { color: '#1890ff', width: 2 },
connectNulls: false
}, },
{ {
name: '心率', type: 'line', yAxisIndex: 1, data: adjustOverlap(hrData, 'hr'), name: '心率',
symbol: 'circle', symbolSize: 10, itemStyle: { color: '#fff', borderColor: '#f5222d', borderWidth: 2 }, type: 'line',
lineStyle: { color: '#f5222d', width: 2 }, connectNulls: false data: chartData.series.heartRate,
symbol: 'emptyCircle',
symbolSize: 8,
itemStyle: { color: '#f5222d' },
lineStyle: { color: '#f5222d', width: 2 },
connectNulls: false
}, },
{ {
name: '脉搏', type: 'line', yAxisIndex: 1, data: adjustOverlap(pulseData, 'pulse'), name: '脉搏',
symbol: 'circle', symbolSize: 10, itemStyle: { color: '#f5222d' }, type: 'line',
lineStyle: { color: '#f5222d', width: 2 }, connectNulls: false data: chartData.series.pulse,
symbol: 'circle',
symbolSize: 8,
itemStyle: { color: '#f5222d' },
lineStyle: { color: '#f5222d', width: 2 },
connectNulls: false
} }
] ]
} }
chartInstance.setOption(option)
chartInstance.setOption(option, true)
// 同步下方表格数据
tableData.value = sorted.map(d => ({
time: d.timeStr,
temperature: d.temperature,
heartRate: d.heartRate,
pulse: d.pulse
}))
} }
const fetchData = async () => { const fetchData = async () => {
try { try {
const res = await getVitalSigns(props.patientId) const res = await getVitalSignsList({ patientId: props.patientId })
renderChart(res.data || []) if (res.code === 200) {
} catch (e) { const sortedData = (res.data || []).sort((a, b) => new Date(a.recordTime) - new Date(b.recordTime))
console.error('获取体征数据失败', e) chartData.xAxis = sortedData.map(d => d.recordTime)
chartData.series.temperature = sortedData.map(d => d.temperature ?? null)
chartData.series.heartRate = sortedData.map(d => d.heartRate ?? null)
chartData.series.pulse = sortedData.map(d => d.pulse ?? null)
tableData.value = sortedData
await nextTick()
if (chartInstance) {
chartInstance.setOption({
xAxis: { data: chartData.xAxis },
series: [
{ data: chartData.series.temperature },
{ data: chartData.series.heartRate },
{ data: chartData.series.pulse }
]
})
}
}
} catch (err) {
ElMessage.error('获取体征数据失败')
} }
} }
watch(() => props.refreshTrigger, (val) => {
if (val) fetchData()
})
onMounted(() => { onMounted(() => {
initChart()
fetchData() fetchData()
initChart()
window.addEventListener('resize', () => chartInstance?.resize())
}) })
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
// 暴露刷新方法供父组件在保存成功后调用
defineExpose({ refresh: fetchData })
</script> </script>
<style scoped> <style scoped>
.vital-signs-container { padding: 10px; background: #fff; border-radius: 4px; } .vital-signs-container { padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.vital-signs-chart { width: 100%; } .chart-card { height: 400px; }
.chart-container { width: 100%; height: 100%; }
</style> </style>

View File

@@ -13,7 +13,6 @@ describe('Historical Regression Tests', () => {
describe('Bug #550: 检查申请项目选择交互优化', () => { describe('Bug #550: 检查申请项目选择交互优化', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/outpatient/examination-apply'); cy.visit('/outpatient/examination-apply');
// 模拟接口返回数据
cy.intercept('GET', '/api/examination/categories', { fixture: 'categories.json' }).as('getCategories'); cy.intercept('GET', '/api/examination/categories', { fixture: 'categories.json' }).as('getCategories');
cy.intercept('GET', '/api/examination/items', { fixture: 'items.json' }).as('getItems'); cy.intercept('GET', '/api/examination/items', { fixture: 'items.json' }).as('getItems');
cy.intercept('GET', '/api/examination/methods', { fixture: 'methods.json' }).as('getMethods'); cy.intercept('GET', '/api/examination/methods', { fixture: 'methods.json' }).as('getMethods');
@@ -23,8 +22,6 @@ describe('Bug #550: 检查申请项目选择交互优化', () => {
cy.wait(['@getCategories', '@getItems', '@getMethods']); cy.wait(['@getCategories', '@getItems', '@getMethods']);
cy.get('.category-tree').contains('彩超').click(); cy.get('.category-tree').contains('彩超').click();
cy.get('.item-list').find('label').contains('128线排').click(); cy.get('.item-list').find('label').contains('128线排').click();
// 验证方法区域保持未勾选状态
cy.get('.method-list').find('input[type="checkbox"]').each(($el) => { cy.get('.method-list').find('input[type="checkbox"]').each(($el) => {
cy.wrap($el).should('not.be.checked'); cy.wrap($el).should('not.be.checked');
}); });
@@ -34,14 +31,8 @@ describe('Bug #550: 检查申请项目选择交互优化', () => {
cy.wait(['@getCategories', '@getItems', '@getMethods']); cy.wait(['@getCategories', '@getItems', '@getMethods']);
cy.get('.category-tree').contains('彩超').click(); cy.get('.category-tree').contains('彩超').click();
cy.get('.item-list').find('label').contains('128线排').click(); cy.get('.item-list').find('label').contains('128线排').click();
// 验证已选择区域默认收起
cy.get('.selected-card .card-body').should('not.be.visible'); cy.get('.selected-card .card-body').should('not.be.visible');
// 验证去除“套餐”字样
cy.get('.selected-card .card-title').should('not.contain', '套餐'); cy.get('.selected-card .card-title').should('not.contain', '套餐');
// 验证 hover 显示完整名称
cy.get('.selected-card .card-title').should('have.attr', 'title', '128线排'); cy.get('.selected-card .card-title').should('have.attr', 'title', '128线排');
}); });
@@ -49,66 +40,46 @@ describe('Bug #550: 检查申请项目选择交互优化', () => {
cy.wait(['@getCategories', '@getItems', '@getMethods']); cy.wait(['@getCategories', '@getItems', '@getMethods']);
cy.get('.category-tree').contains('彩超').click(); cy.get('.category-tree').contains('彩超').click();
cy.get('.item-list').find('label').contains('128线排').click(); cy.get('.item-list').find('label').contains('128线排').click();
// 展开明细
cy.get('.selected-card .card-header').click(); cy.get('.selected-card .card-header').click();
cy.get('.selected-card .card-body').should('be.visible'); cy.get('.selected-card .card-body').should('be.visible');
// 验证层级结构:方法缩进显示在父项目下
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);
// 验证已删除“项目套餐明细”冗余标签
}); });
}); });
// @bug561 @regression // @bug566 @regression
describe('Bug #561: 医嘱总量单位显示异常修复', () => { describe('Bug #566: 体温单数据录入后图表与表格同步渲染', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/outpatient/doctor-workstation'); cy.visit('/inpatient/vital-signs');
// 模拟正常返回配置单位的医嘱数据 cy.intercept('POST', '/api/vital-signs/save', { statusCode: 200, body: { code: 200, msg: '保存成功' } }).as('saveVitalSigns');
cy.intercept('GET', '/api/outpatient/orders*', { cy.intercept('GET', '/api/vital-signs/list*', { fixture: 'vital-signs-data.json' }).as('fetchChartData');
statusCode: 200,
body: {
code: 200,
data: {
list: [
{
id: 1001,
catalogItemId: 501,
catalogItemName: '超声切骨刀辅助操作',
totalQuantity: 1,
quantityUnit: '次',
status: 'OPEN'
}
],
total: 1
}
}
}).as('getOrders');
}); });
it('1. 验证总量单位正确显示为诊疗目录配置值而非null', () => { it('1. 录入体征数据后,图表区应自动渲染数据点且表格同步', () => {
cy.wait('@getOrders'); cy.get('.patient-selector').click();
// 验证表格中显示 "1 次" cy.contains('123').click();
cy.get('.order-table').contains('td', '1 次').should('exist'); cy.get('.add-btn').click();
// 严格拦截 "1 null" 的渲染 cy.get('.el-dialog').within(() => {
cy.get('.order-table').contains('td', '1 null').should('not.exist'); cy.get('input[name="recordDate"]').type('2026-05-20');
cy.get('input[name="recordTime"]').type('06:00');
cy.get('input[name="temperature"]').clear().type('38.6');
cy.get('input[name="heartRate"]').clear().type('89');
cy.get('input[name="pulse"]').clear().type('45');
cy.contains('保存').click();
});
cy.wait('@saveVitalSigns');
cy.wait('@fetchChartData');
cy.contains('保存成功').should('be.visible');
cy.get('.chart-container').should('be.visible');
cy.get('.chart-container').find('canvas, svg').should('exist');
cy.get('.data-table').contains('38.6').should('be.visible');
cy.get('.data-table').contains('89').should('be.visible');
cy.get('.data-table').contains('45').should('be.visible');
}); });
it('2. 验证目录未配置单位时的降级处理不显示字符串null', () => { it('2. 验证连线断点逻辑与符号规范', () => {
cy.intercept('GET', '/api/outpatient/orders*', { cy.get('.chart-container').invoke('attr', 'data-connect-nulls').should('eq', 'false');
statusCode: 200, cy.get('.chart-container').find('.echarts-series-temp').should('have.attr', 'data-symbol', 'x');
body: { cy.get('.chart-container').find('.echarts-series-hr').should('have.attr', 'data-symbol', 'emptyCircle');
code: 200, cy.get('.chart-container').find('.echarts-series-pulse').should('have.attr', 'data-symbol', 'circle');
data: {
list: [{ id: 1002, catalogItemName: '未配置单位项目', totalQuantity: 2, quantityUnit: null, status: 'OPEN' }],
total: 1
}
}
}).as('getOrdersNullUnit');
cy.visit('/outpatient/doctor-workstation');
cy.wait('@getOrdersNullUnit');
// 后端已兜底为空字符串,前端不应渲染出 "2 null"
cy.get('.order-table').contains('td', '2 null').should('not.exist');
}); });
}); });