697 lines
22 KiB
Vue
Raw Normal View History

2026-02-09 17:34:49 +08:00
/**
* @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">
2026-02-10 17:36:45 +08:00
<div :class="[item['zkphFlag' + v] ? 'red_title' : '', 'no-select']" @dblclick="dbSKHandle(v, item)">
{{ item['zkjg' + v] }}
</div>
2026-02-09 17:34:49 +08:00
</div>
<div>{{ item.yhdh }}</div>
</div>
</div>
</div>
</el-col>
</el-row>
<!-- 质控值、SD计算 -->
2026-02-10 17:36:45 +08:00
<el-dialog v-model="dialogVisible" title="质控靶值、SD计算" draggable :close-on-click-modal="false" width="600px">
2026-02-09 17:34:49 +08:00
<el-form :model="form" ref="queryRef" label-width="90px">
<el-form-item label="计算时间:" prop="xmdh">
2026-02-10 17:36:45 +08:00
<el-date-picker v-model="form.zkrq1" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
style="width: 40%;" @change="handletime" />&nbsp;-&nbsp;
<el-date-picker v-model="form.zkrq2" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
@change="handletime" style="width: 40%;" />
<el-button type="primary" class="ml5" @click="jsTime">计算</el-button>
2026-02-09 17:34:49 +08:00
</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";
2026-02-10 17:36:45 +08:00
import { qcgraphList, querySample, qcCalc, saveBtQcSample } from "@/api/liswork/qualityControl/index";
2026-02-09 17:34:49 +08:00
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: '',
2026-02-10 17:36:45 +08:00
zkrq1: '',
zkrq2: '',
2026-02-09 17:34:49 +08:00
zkph: '',
zkbz: '',
sd: '',
})
const queryParams = ref({
// instrGroup: mbStore.defaultConfig.lisgroupid || 'ALL',
instrGroup: 'ALL',
2026-02-10 17:36:45 +08:00
yq: mbStore.defaultConfig.defaultinstr || '1',
2026-02-09 17:34:49 +08:00
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' },
2026-02-10 17:36:45 +08:00
{ label: 'cv(%)', prop: 'cv', visible: true, align: 'center' },
{ label: 'sd', prop: 'sd', visible: true, align: 'center' },
2026-02-09 17:34:49 +08:00
{ 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
}))
}
})
}
2026-02-10 17:36:45 +08:00
// 获取上个月的第一天和最后一天
const getLastMonthRange = (format = 'YYYY-MM-DD') => {
const lastMonth = dayjs().subtract(1, 'month');
return {
firstDay: lastMonth.startOf('month').format(format),
lastDay: lastMonth.endOf('month').format(format)
};
};
const info = ref({})
2026-02-09 17:34:49 +08:00
const handleEdit = (row, event) => {
2026-02-10 17:36:45 +08:00
const { firstDay, lastDay } = getLastMonthRange();
info.value = row
2026-02-09 17:34:49 +08:00
event.stopPropagation();
2026-02-10 17:36:45 +08:00
form.value.zkrq1 = firstDay
form.value.zkrq2 = lastDay
2026-02-09 17:34:49 +08:00
form.value.xmdh = row.xmdh
form.value.zkph = row.zkph
2026-02-10 17:36:45 +08:00
// row.cv = form.value.sd * 100 / form.value.zkph
2026-02-09 17:34:49 +08:00
dialogVisible.value = true
}
2026-02-10 17:36:45 +08:00
const jsTime = () => {
qcCalc({ ...form.value, yq: queryParams.value.yq, cs: queryParams.value.cs }).then((res) => {
form.value.zkbz = res.data[0].avg1
form.value.sd = res.data[0].sd1
})
}
2026-02-09 17:34:49 +08:00
const handlePj = (row, event) => {
event.stopPropagation();
pjForm.value = {
zkph: row.zkph,
bz1: row.bz1,
}
2026-02-10 17:36:45 +08:00
info.value = row
2026-02-09 17:34:49 +08:00
pjShow.value = true
}
2026-02-10 17:36:45 +08:00
const dbSKHandle = (val, item) => {
console.log('val==>', val, item);
}
2026-02-09 17:34:49 +08:00
const pjHandle = () => {
2026-02-10 17:36:45 +08:00
info.value.bz1 = pjForm.value.bz1
saveBtQcSample([info.value]).then((res) => {
if (res.code == 0) {
pjShow.value = false
tableData.value = tableData.value.map((item, i) => item.zkph == info.value.zkph ? { ...info.value } : item);
nextTick(() => {
tableRef.value.toggleAllSelection()
})
}
})
2026-02-09 17:34:49 +08:00
}
const subHandle = () => {
2026-02-10 17:36:45 +08:00
if (!form.value.zkbz && !form.value.sd) return ElMessage.warning('请先计算!')
info.value.cv = (form.value.sd * 100 / form.value.zkbz).toFixed(3)
info.value.zkbz = form.value.zkbz
info.value.sd = form.value.sd
saveBtQcSample([info.value]).then((res) => {
if (res.code == 0) {
dialogVisible.value = false
tableData.value = tableData.value.map((item, i) => item.zkph == info.value.zkph ? { ...info.value } : item);
nextTick(() => {
tableRef.value.toggleAllSelection()
})
}
})
2026-02-09 17:34:49 +08:00
}
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;
}
},
2026-02-10 17:36:45 +08:00
grid: { left: '1%', right: '2%', bottom: '10%', top: '10%', containLabel: true },
2026-02-09 17:34:49 +08:00
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;
// 判断是否为失控
2026-02-10 17:36:45 +08:00
item['zkphFlag' + v.zkph] = Math.abs((v.zkbz - v.zkjg) / v.sd) > 3 ? true : false;
2026-02-09 17:34:49 +08:00
}
});
});
// 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>