195 lines
7.9 KiB
Vue
195 lines
7.9 KiB
Vue
<template>
|
||
<div style="padding:16px">
|
||
<div style="margin-bottom:16px;display:flex;align-items:center;gap:16px">
|
||
<span style="font-size:18px;font-weight:bold">室内质控管理</span>
|
||
<el-tag v-if="stats.total" type="info">共 {{ stats.total }} 条</el-tag>
|
||
<el-tag v-if="stats.passed" type="success">合格 {{ stats.passed }}</el-tag>
|
||
<el-tag v-if="stats.failed" type="danger">失控 {{ stats.failed }}</el-tag>
|
||
</div>
|
||
|
||
<el-row :gutter="16">
|
||
<el-col :span="8">
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<span>录入质控数据</span>
|
||
</template>
|
||
<el-form :model="form" label-width="80px" size="default">
|
||
<el-form-item label="质控项目">
|
||
<el-input v-model="form.qcItem" placeholder="如:ALT, GLU" />
|
||
</el-form-item>
|
||
<el-form-item label="仪器">
|
||
<el-input v-model="form.instrumentName" placeholder="仪器名称" />
|
||
</el-form-item>
|
||
<el-form-item label="靶值">
|
||
<el-input-number v-model="form.targetValue" :precision="4" :step="0.1" style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item label="实测值">
|
||
<el-input-number v-model="form.actualValue" :precision="4" :step="0.1" style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item label="检测日期">
|
||
<el-date-picker v-model="form.qcDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item label="操作人">
|
||
<el-input v-model="form.operatorName" />
|
||
</el-form-item>
|
||
<el-form-item label="备注">
|
||
<el-input v-model="form.remarks" type="textarea" :rows="2" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="doRunWestgard" :loading="loading">执行Westgard判断</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="16">
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span>质控图</span>
|
||
<div style="display:flex;gap:8px">
|
||
<el-input v-model="query.qcItem" placeholder="质控项目" clearable style="width:140px" />
|
||
<el-select v-model="query.isPass" placeholder="结果" clearable style="width:100px">
|
||
<el-option label="通过" :value="true" />
|
||
<el-option label="失控" :value="false" />
|
||
</el-select>
|
||
<el-button type="primary" @click="loadResults">查询</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div ref="chartRef" style="width:100%;height:300px" />
|
||
</el-card>
|
||
<el-card shadow="never" style="margin-top:12px">
|
||
<template #header>
|
||
<span>质控结果列表</span>
|
||
</template>
|
||
<el-table :data="tableData" border stripe size="small">
|
||
<el-table-column prop="qcItem" label="质控项目" width="120" />
|
||
<el-table-column prop="instrumentName" label="仪器" width="120" />
|
||
<el-table-column prop="targetValue" label="靶值" width="90" />
|
||
<el-table-column prop="actualValue" label="实测值" width="90" />
|
||
<el-table-column prop="sdValue" label="SD" width="80" />
|
||
<el-table-column prop="cvRate" label="CV%" width="80" />
|
||
<el-table-column prop="westgardRule" label="Westgard判定" width="180">
|
||
<template #default="{row}">
|
||
<el-tag :type="row.isPass ? 'success' : 'danger'" size="small">{{ row.westgardRule }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="qcDate" label="日期" width="110" />
|
||
<el-table-column prop="operatorName" label="操作人" width="80" />
|
||
</el-table>
|
||
<el-pagination
|
||
v-model:current-page="query.pageNo"
|
||
v-model:page-size="query.pageSize"
|
||
style="margin-top:12px;justify-content:flex-end"
|
||
:total="total"
|
||
layout="total,prev,pager,next"
|
||
@current-change="loadResults"
|
||
/>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, nextTick, onUnmounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import * as echarts from 'echarts'
|
||
import { runWestgard as runWestgardApi, getQcResults, getQcStats } from '@/api/lab/labQc'
|
||
|
||
const chartRef = ref(null)
|
||
let chartInstance = null
|
||
|
||
const loading = ref(false)
|
||
const tableData = ref([])
|
||
const total = ref(0)
|
||
const stats = ref({ total: 0, passed: 0, failed: 0 })
|
||
|
||
const query = ref({ qcItem: '', isPass: null, pageNo: 1, pageSize: 20 })
|
||
|
||
const defaultForm = () => ({
|
||
qcItem: '', instrumentName: '', targetValue: null, actualValue: null,
|
||
qcDate: '', operatorName: '', departmentName: '', departmentId: null, remarks: ''
|
||
})
|
||
const form = ref(defaultForm())
|
||
|
||
const loadResults = async () => {
|
||
const r = await getQcResults(query.value)
|
||
tableData.value = r.data?.records || []
|
||
total.value = r.data?.total || 0
|
||
renderChart()
|
||
}
|
||
|
||
const loadStats = async () => {
|
||
const r = await getQcStats()
|
||
stats.value = r.data || stats.value
|
||
}
|
||
|
||
const doRunWestgard = async () => {
|
||
if (!form.value.qcItem || !form.value.actualValue) {
|
||
ElMessage.warning('请填写质控项目和实测值')
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res = await runWestgardApi(form.value)
|
||
if (res.data?.isPass) {
|
||
ElMessage.success('Westgard判定: ' + res.data.westgardRule)
|
||
} else {
|
||
ElMessage.error('Westgard判定: ' + res.data.westgardRule)
|
||
}
|
||
form.value = defaultForm()
|
||
await loadResults()
|
||
await loadStats()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const renderChart = () => {
|
||
if (!chartRef.value || tableData.value.length === 0) return
|
||
if (!chartInstance) {
|
||
chartInstance = echarts.init(chartRef.value)
|
||
}
|
||
const items = [...tableData.value].reverse()
|
||
const dates = items.map(i => i.qcDate)
|
||
const values = items.map(i => i.actualValue)
|
||
const targetValues = items.map(i => i.targetValue)
|
||
|
||
const mean = targetValues.reduce((a, b) => a + (b || 0), 0) / targetValues.length || 0
|
||
const sdArr = items.map(i => i.sdValue || 0)
|
||
const sd = sdArr.reduce((a, b) => a + b, 0) / sdArr.length || 1
|
||
|
||
chartInstance.setOption({
|
||
tooltip: { trigger: 'axis' },
|
||
legend: { data: ['实测值', '靶值', '+2SD', '-2SD', '+3SD', '-3SD'] },
|
||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||
xAxis: { type: 'category', data: dates },
|
||
yAxis: { type: 'value', name: '检测值' },
|
||
series: [
|
||
{ name: '实测值', type: 'line', data: values, itemStyle: { color: '#409EFF' }, lineStyle: { width: 2 } },
|
||
{ name: '靶值', type: 'line', data: targetValues, lineStyle: { type: 'dashed', color: '#67C23A' }, itemStyle: { color: '#67C23A' } },
|
||
{ name: '+2SD', type: 'line', data: dates.map(() => mean + 2 * sd), lineStyle: { type: 'dotted', color: '#E6A23C' }, itemStyle: { color: '#E6A23C' }, symbol: 'none' },
|
||
{ name: '-2SD', type: 'line', data: dates.map(() => mean - 2 * sd), lineStyle: { type: 'dotted', color: '#E6A23C' }, itemStyle: { color: '#E6A23C' }, symbol: 'none' },
|
||
{ name: '+3SD', type: 'line', data: dates.map(() => mean + 3 * sd), lineStyle: { type: 'dotted', color: '#F56C6C' }, itemStyle: { color: '#F56C6C' }, symbol: 'none' },
|
||
{ name: '-3SD', type: 'line', data: dates.map(() => mean - 3 * sd), lineStyle: { type: 'dotted', color: '#F56C6C' }, itemStyle: { color: '#F56C6C' }, symbol: 'none' },
|
||
]
|
||
})
|
||
}
|
||
|
||
const handleResize = () => chartInstance?.resize()
|
||
|
||
onMounted(async () => {
|
||
await loadResults()
|
||
await loadStats()
|
||
await nextTick()
|
||
renderChart()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
chartInstance?.dispose()
|
||
})
|
||
</script>
|