2026-02-09 17:34:49 +08:00

638 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file index.vue 质控图westGard
* @author: w
* @since: 2026-02-04
*/
<template>
<div class="app-container">
<el-row :gutter="10">
<el-col :span="5">
<div class="mb5">
<el-button type="primary" @click="getList">画图</el-button>
<el-button type="warning">打印</el-button>
</div>
<el-form :model="queryParams" ref="queryRef" size="small">
<el-form-item label="组别:" prop="instrGroup">
<SelectTable v-model:data="queryParams.instrGroup" :tableData="instrGroupOptions" placeholder="请选择"
@getDataValue="getYqConfig" size="small" />
</el-form-item>
<el-form-item label="仪器:" prop="yq">
<SelectTable v-model:data="queryParams.yq" :tableData="instrOptions" placeholder="请选择仪器"
@getDataValue="getXmList" size="small" />
</el-form-item>
<el-form-item label="月份:" prop="zkrq1">
<el-date-picker v-model="queryParams.zkrq1" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
style="width: 47%;" @change="handletime" />&nbsp;-&nbsp;
<el-date-picker v-model="queryParams.zkrq2" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
@change="handletime" style="width: 47%;" />
</el-form-item>
<el-form-item label="次数:" prop="cs">
<SelectTable v-model:data="queryParams.cs" :tableData="csData" placeholder="请选择"
@getDataValue="() => { queryParams.zkph = '' }" size="small" />
</el-form-item>
</el-form>
</el-col>
<el-col :span="19">
<CustomTable ref="tableRef" :data="tableData" :columns="columns" :config="tableConfig" @row-click="rowClick"
@selection-change="selectChange">
<template #action="scope">
<el-button type="text" @click="handleEdit(scope.row, $event)">计算均值</el-button>
<el-button type="text" @click="handlePj(scope.row, $event)">评价</el-button>
</template>
</CustomTable>
</el-col>
</el-row>
<el-row :gutter="10" class="table-row">
<el-col :span="5" class="table-col">
<div class="table-main">
<el-table ref="xmRef" :data="xmList" height="100%" border highlightCurrentRow @row-click="xmHandle"
show-overflow-tooltip>
<el-table-column label="项目代号" prop="xmdh">
<template #default="{ row }">
{{ row.xmdh }} {{ row.xmmc }}
</template>
</el-table-column>
</el-table>
</div>
</el-col>
<el-col :span="19" class="table-col2">
<div ref="chartRef" style="width: 100%; height:300px"></div>
<div>
<div class="tips">标准:</div>
<el-table :data="zklist" height="150" border>
<el-table-column label="质控品" prop="zkph" align="center">
<template #default="{ row, $index }">
{{ row.zkph }} &nbsp; <span class="radio" :style="{ backgroundColor: colorMap[$index] }"></span>
</template>
</el-table-column>
<el-table-column label="𝑋̄" prop="avg0" align="center" />
<el-table-column label="SD" prop="sd0" align="center" />
<el-table-column label="CV%" prop="cv0" align="center" />
<el-table-column label="本月𝑋̄" prop="avg1" align="center" />
<el-table-column label="本月SD" prop="sd1" align="center" />
<el-table-column label="本月CV" prop="cv1" align="center" />
<el-table-column label="批号" prop="zkpph" align="center" />
<el-table-column label="累计𝑋̄" prop="avg3" align="center" />
<el-table-column label="累计SD" prop="sd3" align="center" />
<el-table-column label="累计CV" prop="cv3" align="center" />
<el-table-column label="失控判断" prop="skpd" align="center" />
</el-table>
</div>
<div>
<div class="tips">当前图表数据:</div>
<div class="result-box">
<div v-for="item in resultData" class="item">
<div class="time">{{ item.zkrqMonth }}</div>
<div v-for="v in zkKeys">
<div :class="[item.flag == v ? 'red_title' : '']">{{ item['zkjg' + v] }}</div>
</div>
<div>{{ item.yhdh }}</div>
</div>
</div>
</div>
</el-col>
</el-row>
<!-- 质控值、SD计算 -->
<el-dialog v-model="dialogVisible" title="质控靶值、SD计算" draggable :close-on-click-modal="false" width="500px">
<el-form :model="form" ref="queryRef" label-width="90px">
<el-form-item label="计算时间:" prop="xmdh">
<el-date-picker v-model="form.sqsj1" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
style="width: 47%;" @change="handletime" />&nbsp;-&nbsp;
<el-date-picker v-model="form.sqsj2" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
@change="handletime" style="width: 47%;" />
</el-form-item>
<el-form-item label="质控项目:" prop="xmdh">
<el-input v-model="form.xmdh" readonly />
</el-form-item>
<el-form-item label="质控批号:" prop="zkph">
<el-input v-model="form.zkph" readonly />
</el-form-item>
<el-form-item label="计算靶值:" prop="zkbz">
<el-input v-model="form.zkbz" readonly />
</el-form-item>
<el-form-item label="SD:" prop="sd">
<el-input v-model="form.sd" readonly />
</el-form-item>
</el-form>
<div class="tips">
提示:请选择计算起始和截止日期,系统自动计算出靶值和SD,计算完毕后可以按“确定”按钮返回计算值,也可以按“取消”按钮直接关闭窗口。
</div>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="subHandle">确定</el-button>
</div>
</el-dialog>
<el-dialog v-model="pjShow" title="本月质控评价" draggable :close-on-click-modal="false" width="500px">
<el-form :model="pjForm" label-width="90px">
<el-form-item label="质控批号:" prop="zkph">
<el-input v-model="pjForm.zkph" readonly />
</el-form-item>
<el-form-item label="评价:" prop="zkph">
<el-input v-model="pjForm.bz1" type="textarea" :rows="10" />
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="pjShow = false">取消</el-button>
<el-button type="primary" @click="pjHandle">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { getComDicts } from "@/api/liswork/dict/ComDict";
import { getGroupInstrdList } from "@/api/liswork/work/LisWork";
import { qcgraphList, querySample } from "@/api/liswork/qualityControl/index";
import { classCom } from "@/utils/classCom";
import { useCommonStore } from '@/store/modules/commonStore';
import dayjs from 'dayjs';
import { ElMessage } from 'element-plus'
import { queryBatch, queryBatchXm, } from '@/api/liswork/qualityControl'
import * as echarts from 'echarts';
import { use } from "vxe-pc-ui";
const mbStore = useCommonStore();
const pjShow = ref(false)
const pjForm = ref({
zkph: '',
bz1: '',
})
const dialogVisible = ref(false)
const form = ref({
xmdh: '',
sqsj1: '',
sqsj2: '',
zkph: '',
zkbz: '',
sd: '',
})
const queryParams = ref({
// instrGroup: mbStore.defaultConfig.lisgroupid || 'ALL',
instrGroup: 'ALL',
// yq: mbStore.defaultConfig.defaultinstr || '1',
yq: '46',
zkrq1: '',
zkrq2: '',
cs: '0',
xmdh: '',
zkph: '',
})
const chartRef = useTemplateRef('chartRef')
const tableRef = useTemplateRef('tableRef')
const tableData = ref([])
const columns = ref([
{ type: 'selection', label: '画图', width: 30, visible: true, align: 'center' },
{ label: '质控品', prop: 'zkph', visible: true, align: 'center' },
{ label: '起始日期', prop: 'ksrq', visible: true, align: 'center' },
{ label: '质控批号', prop: 'zkpph', visible: true, align: 'center' },
{ label: '浓度', prop: 'conc', visible: true, align: 'center' },
{ label: '平均值', prop: 'zkbz', visible: true, align: 'center' },
{ label: '试剂', prop: 'sjph', visible: true, align: 'center' },
{ label: '波长', prop: 'bc', visible: true, align: 'center' },
{ label: '方法', prop: 'ff', visible: true, align: 'center' },
{ label: '备注', prop: 'bz1', visible: true, align: 'center' },
])
const tableConfig = ref({
border: true,
height: '156px',
actionWidth: 150,
highlightCurrentRow: true,
})
const instrGroupOptions = ref([])
const instrOptions = ref([])
const csData = ref([
{ value: '9', label: '最好1次' },
{ value: '0', label: '平均值' },
{ value: '8', label: '最后一次' },
{ value: '10', label: '所有点' },
{ value: '1', label: '第1次' },
{ value: '2', label: '第2次' },
{ value: '3', label: '第3次' },
{ value: '4', label: '第4次' },
{ value: '5', label: '第5次' },
{ value: '6', label: '第6次' },
{ value: '7', label: '第7次' },
])
const zklist = ref([])
const qcData = ref([])
const resultData = ref([])
const zkKeys = ref([])
const xmList = ref([])
const colorMap = ['#333', '#00b42a', '#ff9900', '#ff0', '#2f6dd1', '#a65']
let mockRawDataMap = {};
const loadinstrGroupOptions = () => {
getComDicts({ zdlb: 'GROUP' }).then((res) => {
instrGroupOptions.value = res.data.map((item) => ({
value: item.zddh,
label: item.zdmc
}))
instrGroupOptions.value.push({ value: 'ALL', label: '所有仪器' })
getYqConfig()
});
}
// 仪器
const getYqConfig = () => {
queryParams.value.zkph = ''
getGroupInstrdList({ lisgroup: queryParams.value.instrGroup })
.then((res) => {
if (res.code == 0) {
instrOptions.value = res.data.map((item) => ({
value: item.yq.trim(),
label: item.yqmc
}))
}
})
}
const handleEdit = (row, event) => {
event.stopPropagation();
form.value.xmdh = row.xmdh
form.value.zkbz = row.zkbz
form.value.zkph = row.zkph
form.value.sd = row.sd
dialogVisible.value = true
}
const handlePj = (row, event) => {
event.stopPropagation();
pjForm.value = {
zkph: row.zkph,
bz1: row.bz1,
}
pjShow.value = true
}
const pjHandle = () => {
pjShow.value = false
}
const subHandle = () => {
}
const rowClick = (row) => {
queryParams.value.zkph = row.zkph
getList()
}
const selectChange = (value) => {
const arr = value.map(item => item.zkph)
queryParams.value.zkph = arr.join(',')
}
const xmHandle = (row) => {
queryParams.value.xmdh = row.xmdh
queryParams.value.zkph = ''
getSampleList()
}
const handletime = () => {
queryParams.value.zkph = ''
const dayFlag = classCom.getDayDiff(queryParams.value.zkrq1, queryParams.value.zkrq2)
if (dayFlag === -1 || dayFlag === -2) return ElMessage.warning('请选择有效的日期范围')
}
const xmRef = useTemplateRef('xmRef')
const getXmList = () => {
queryParams.value.zkph = ''
queryBatchXm({ yq: queryParams.value.yq }).then((res) => {
xmList.value = res.data
queryParams.value.xmdh = res.data[0].xmdh
xmRef.value.setCurrentRow(res.data[0])
getSampleList()
})
}
const getSampleList = () => {
const data = {
yq: queryParams.value.yq,
xmdh: queryParams.value.xmdh,
zkrq1: queryParams.value.zkrq1,
zkrq2: queryParams.value.zkrq2,
cs: queryParams.value.cs
}
querySample(data).then((res) => {
tableData.value = res.data || []
getList()
nextTick(() => {
tableRef.value.toggleAllSelection()
})
})
};
let myChart = null
// 初始化图表
const initChart = () => {
if (!chartRef.value) return;
if (myChart) myChart.dispose();
const el = chartRef.value;
if (!el) return;
myChart = echarts.init(el);
// 将原始值转换为SD映射值(-4~4)
const handleDataToSD = (rawData, X, sd) => {
const renderSDData = [];
const originValue = [];
rawData.forEach(val => {
if (val === null) {
// 无数据时,SD值也设为 null,折线跳过该点
renderSDData.push(null);
originValue.push(null);
return;
}
originValue.push(val);
const sdValue = (val - X) / sd;
renderSDData.push(Math.max(-4, Math.min(4, sdValue)));
});
return { renderSDData, originValue };
};
const series = [];
// 存储所有系列的原始值(供tooltip使用)
const originValueMap = {};
zklist.value.forEach((item, index) => {
const { zkph, avg0, sd0 } = item;
const rawData = mockRawDataMap[zkph] || []; // 获取当前质控品的原始数据
const { renderSDData, originValue } = handleDataToSD(rawData, avg0, sd0);
// 存储原始值
originValueMap[zkph] = originValue;
// 系列配置
series.push({
name: `质控品${zkph}`,
type: 'line',
data: renderSDData,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineWidth: 1.5,
connectNulls: true, // 开启 null 点连接
itemStyle: {
// 按数据点数值动态设置转折点颜色
color: (params) => getSymbolColor(params.value, colorMap[index]),
shadowColor: colorMap[index],
},
lineStyle: { color: colorMap[index] || '#666' },
z: 3,
// 仅第一个系列显示SD参考线(避免重复)
markLine: zkph === zklist.value[0].zkph ? {
silent: true,
symbol: ['none', 'none'],
z: 2,
lineStyle: { type: 'solid', width: 1.2 },
data: [
{ yAxis: 3, lineStyle: { color: '#f53f3f' }, label: { show: false } },
{ yAxis: 2, lineStyle: { color: '#00b42a' }, label: { show: false } },
{ yAxis: 1, lineStyle: { color: '#1890ff' }, label: { show: false } },
{ yAxis: 0, lineStyle: { color: '#333' }, label: { show: false } },
{ yAxis: -1, lineStyle: { color: '#1890ff' }, label: { show: false } },
{ yAxis: -2, lineStyle: { color: '#00b42a' }, label: { show: false } },
{ yAxis: -3, lineStyle: { color: '#f53f3f' }, label: { show: false } }
]
} : undefined
});
});
// X轴数据
const xAxisData = Array.from(new Set(qcData.value.map(item => item.zkrqMonth))).splice(0, 31)
// 核心配置
const option = {
tooltip: {
trigger: 'axis',
formatter: (params) => {
// 过滤掉 null 值的系列
const validParams = params.filter(p => p.data !== null);
if (validParams.length === 0) return ''; // 无有效数据时不展示 tooltip
let res = `<div style="font-size:12px; font-weight:500;">${validParams[0].axisValue}</div>`;
validParams.forEach((item, index) => {
const zkph = item.seriesName.replace('质控品', '');
const originVal = originValueMap[zkph][item.dataIndex];
if (originVal === null) return; // 跳过 null 值
const color = colorMap[index] || '#666';
const currentItem = zklist.value.find(item => item.zkph === zkph);
const originalSD = currentItem ? (originVal - currentItem.avg0) / currentItem.sd0 : 0;
res += `<div style="margin-top:4px; font-size:12px;">
<span style="display: inline-block; width: 10px; height: 10px; background: ${color};"></span>
${item.seriesName}:<b>${originVal.toFixed(2)}</b>
</div>`;
});
return res;
}
},
grid: { left: '2%', right: '2%', bottom: '10%', top: '15%', containLabel: true },
dataZoom: [
{
type: "slider",
show: true,
realtime: true,
bottom: 20,
height: 10,
textStyle: { color: "transparent" },
fillerColor: "#7ECFFF", // 浅蓝填充
handleSize: "120%",
handleStyle: {
color: "#2DE2C4", // 滑块颜色与曲线主色一致
borderWidth: 0,
},
backgroundColor: "#E5F3FF", // 滑条底色浅蓝
borderColor: "transparent",
},
],
xAxis: {
type: 'category',
boundaryGap: false,
data: xAxisData,
axisLabel: {
fontSize: 12,
// interval: 0, // 强制显示所有标签
// width: 80, // 每个标签固定宽度80px(可根据需求调整)
// align: 'center', // 标签居中
// // rotate: 30, // 旋转避免文字重叠(可选)
// overflow: 'truncate' // 文字过长时截断
},
axisLine: { lineStyle: { color: '#000' } },
},
yAxis: {
type: 'value',
min: -4,
max: 4,
axisLabel: {
fontSize: 12,
formatter: (v) => v === 0 ? '𝑋̄' : `${v}SD`
},
interval: 1,
axisLine: { show: true, lineStyle: { color: '#000' } },
splitLine: { lineStyle: { type: 'dashed', color: '#000' } }
},
series: series // 使用动态生成的系列
};
myChart.setOption(option);
};
// 封装转折点颜色判断函数
const getSymbolColor = (value, defaultColor) => {
if (Math.abs(value) > 3) {
return '#ff0000'; // 红色
} else if (Math.abs(value) > 2) {
return '#ffc0cb'; // 粉色
}
return defaultColor;
};
const getList = () => {
qcgraphList(queryParams.value).then((res) => {
mockRawDataMap = {};
if (res.data) {
zklist.value = res.data.QcSamplelist;
qcData.value = res.data.QcValList.map((item) => ({
...item,
zkrqMonth: dayjs(item.zkrq).format('YY/M/D')
}));
const xAxisData = Array.from(new Set(qcData.value.map(item => item.zkrqMonth))).splice(0, 31);
const zkphs = Array.from(new Set(zklist.value.map(item => item.zkph)));
const dateZkphValueMap = new Map();
qcData.value.forEach((data) => {
const key = `${data.zkrqMonth}_${data.zkph}`;
dateZkphValueMap.set(key, data.zkjg ?? null); // 无值则存 null
});
zkphs.forEach((zkph) => {
mockRawDataMap[zkph] = xAxisData.map((date) => {
const key = `${date}_${zkph}`;
return dateZkphValueMap.get(key) ?? null; // 无数据 = null
});
});
zkKeys.value = Object.keys(mockRawDataMap);
resultData.value = xAxisData.map((item, i) => {
const obj = {};
obj['zkrqMonth'] = item;
zkKeys.value.map(v => {
obj['zkjg' + v] = mockRawDataMap[v][i] ?? '';
});
return obj;
}).splice(0, 31);
resultData.value.map(item => {
qcData.value.map(v => {
if (item.zkrqMonth == v.zkrqMonth) {
item.yhdh = v.yhdh;
// 判断是否为失控
item.flag = Math.abs((v.zkbz - v.zkjg) / v.sd) > 3 ? v.zkph : '';
}
});
});
// console.log('resultData.value==>', resultData.value);
nextTick(() => {
initChart();
});
} else {
qcData.value = [];
zklist.value = [];
zkKeys.value = [];
resultData.value = [];
initChart()
}
});
};
const timeDate = () => {
const today = dayjs();
const startOfWeek = today.subtract(6, 'day');
// queryParams.zkrq1 = startOfWeek.format('YYYY-MM-DD');
// queryParams.zkrq2 = today.format('YYYY-MM-DD');
queryParams.value.zkrq1 = '2025-02-01';
queryParams.value.zkrq2 = today.format('YYYY-MM-DD');
}
onMounted(() => {
getXmList()
timeDate()
loadinstrGroupOptions()
// getList()
})
</script>
<style scoped lang="scss">
.el-form-item {
margin-bottom: 5px;
}
.table-row {
height: 100%;
.table-col {
height: 100%;
.table-main {
height: calc(100% - 165px);
}
}
}
.table-col2 {
height: calc(100% - 165px);
overflow-y: auto;
}
.tips {
font-family: SourceHanSansCN, SourceHanSansCN;
font-size: 14px;
color: #409eff;
font-weight: 600;
margin-bottom: 5px;
}
.radio {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.result-box {
display: flex;
flex-wrap: wrap;
.item {
&>div {
width: 100px;
height: 24px;
line-height: 24px;
text-align: center;
border: 1px solid #ccc;
}
.time {
color: #409eff;
}
.red_title {
color: #f00;
}
}
}
</style>