<template>
|
<div class="quality-dashboard">
|
<el-row :gutter="16">
|
<el-col :xs="24" :sm="12">
|
<el-card shadow="hover" class="panel">
|
<template #header>
|
<div class="panel-title">
|
检测样品动态状态
|
<div class="actions">
|
<el-switch v-model="voiceEnabled" active-text="语音预警" inactive-text="静音" />
|
</div>
|
</div>
|
</template>
|
<div class="status-list">
|
<div v-for="item in sampleStatus" :key="item.id" class="status-item" :class="item.status">
|
<div class="left">
|
<span class="dot" :class="item.status"></span>
|
<span class="name">{{ item.name }}</span>
|
</div>
|
<div class="right">
|
<el-tag :type="statusTagType(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
|
<span class="time">{{ item.time }}</span>
|
</div>
|
</div>
|
</div>
|
</el-card>
|
</el-col>
|
<el-col :xs="24" :sm="12">
|
<el-card shadow="hover" class="panel">
|
<template #header>
|
<div class="panel-title">任务排行(Top 10)</div>
|
</template>
|
<EChart :xAxis="tasksXAxis" :yAxis="[{ type: 'value' }]" :series="tasksSeries" :grid="{ left: 40, right: 20, top: 20, bottom: 40 }" :tooltip="{ trigger: 'axis' }" :barColors="['#3b82f6']" :chartStyle="{ height: '320px', width: '100%' }" />
|
</el-card>
|
</el-col>
|
</el-row>
|
|
<el-row :gutter="16" style="margin-top: 16px;">
|
<el-col :xs="24" :sm="14">
|
<el-card shadow="hover" class="panel">
|
<template #header>
|
<div class="panel-title">历史趋势</div>
|
</template>
|
<EChart :xAxis="[{ type: 'category', data: trendXAxis }]" :yAxis="[{ type: 'value', name: '数量' }]" :series="trendSeries" :tooltip="{ trigger: 'axis' }" :legend="{ top: 0 }" :lineColors="['#10b981', '#f59e0b']" :chartStyle="{ height: '340px', width: '100%' }" />
|
</el-card>
|
</el-col>
|
<el-col :xs="24" :sm="10">
|
<el-card shadow="hover" class="panel">
|
<template #header>
|
<div class="panel-title">合格率分析</div>
|
</template>
|
<EChart :series="passRateSeries" :legend="{ show: false }" :chartStyle="{ height: '340px', width: '100%' }" />
|
<div class="passrate-text">
|
当前合格率:<b>{{ (passRate * 100).toFixed(1) }}%</b>
|
</div>
|
</el-card>
|
</el-col>
|
</el-row>
|
|
<el-row :gutter="16" style="margin-top: 16px;">
|
<el-col :xs="24">
|
<el-card shadow="hover" class="panel">
|
<template #header>
|
<div class="panel-title">SPC 控制图(Xbar)</div>
|
</template>
|
<EChart :xAxis="[{ type: 'category', data: spcXAxis }]" :yAxis="[{ type: 'value', name: '测量值' }]" :series="spcSeries" :legend="{ top: 0 }" :tooltip="{ trigger: 'axis' }" :lineColors="['#2563eb', '#ef4444', '#f97316', '#22c55e']" :chartStyle="{ height: '380px', width: '100%' }" />
|
</el-card>
|
</el-col>
|
</el-row>
|
</div>
|
|
</template>
|
|
<script setup>
|
import { onMounted, onBeforeUnmount, reactive, ref } from 'vue'
|
import EChart from '@/components/Echarts/echarts.vue'
|
|
const voiceEnabled = ref(true)
|
let dataTimer = null
|
|
// 1) 样品动态状态(滚动更新)
|
const sampleStatus = ref([])
|
const statusPool = ['processing', 'warning', 'error', 'success']
|
function statusLabel(s) {
|
return s === 'processing' ? '检测中' : s === 'warning' ? '预警' : s === 'error' ? '不合格' : '合格'
|
}
|
function statusTagType(s) {
|
return s === 'processing' ? 'info' : s === 'warning' ? 'warning' : s === 'error' ? 'danger' : 'success'
|
}
|
function randomSample() {
|
const id = Math.random().toString(36).slice(2, 8)
|
const status = statusPool[Math.floor(Math.random() * statusPool.length)]
|
const name = `样品-${Math.floor(Math.random() * 900 + 100)}`
|
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
return { id, name, status, time }
|
}
|
|
// 2) 任务排行(柱状图)
|
const tasksXAxis = reactive([{ type: 'category', data: [] }])
|
const tasksSeries = ref([
|
{
|
type: 'bar',
|
data: [],
|
label: { show: true, position: 'inside', align: 'center', verticalAlign: 'middle', color: '#fff' },
|
encode: undefined,
|
},
|
])
|
|
// 3) 历史趋势(折线)
|
const trendXAxis = ref([])
|
const trendSeries = ref([
|
{ name: '来样数', type: 'line', smooth: true, data: [] },
|
{ name: '完成数', type: 'line', smooth: true, data: [] },
|
])
|
|
// 4) 合格率分析(仪表盘)
|
const passRate = ref(0.92)
|
const passRateSeries = ref([
|
{
|
type: 'gauge',
|
progress: { show: true, width: 12 },
|
axisLine: { lineStyle: { width: 12 } },
|
pointer: { show: true },
|
detail: { valueAnimation: true, formatter: (v) => `${(v * 100).toFixed(1)}%` },
|
data: [{ value: passRate.value }],
|
},
|
])
|
|
// 5) SPC 控制图
|
const spcXAxis = ref([])
|
const spcData = ref([]) // 实际测量值
|
const CL = ref(50)
|
const UCL = ref(55)
|
const LCL = ref(45)
|
const spcSeries = ref([
|
{
|
name: '测量均值',
|
type: 'line',
|
smooth: false,
|
symbol: 'circle',
|
data: [],
|
markLine: {
|
symbol: 'none',
|
lineStyle: { type: 'dashed', color: '#999' },
|
data: [
|
{ yAxis: () => UCL.value, name: 'UCL' },
|
{ yAxis: () => CL.value, name: 'CL' },
|
{ yAxis: () => LCL.value, name: 'LCL' },
|
],
|
label: { formatter: ({ name }) => name },
|
},
|
},
|
{ name: 'UCL', type: 'line', data: [], symbol: 'none', lineStyle: { type: 'dashed', color: '#ef4444' } },
|
{ name: 'CL', type: 'line', data: [], symbol: 'none', lineStyle: { type: 'dashed', color: '#f97316' } },
|
{ name: 'LCL', type: 'line', data: [], symbol: 'none', lineStyle: { type: 'dashed', color: '#22c55e' } },
|
])
|
|
// 语音播报
|
function speak(text) {
|
if (!voiceEnabled.value) return
|
if (!('speechSynthesis' in window)) return
|
const utter = new SpeechSynthesisUtterance(text)
|
utter.lang = 'zh-CN'
|
try {
|
window.speechSynthesis.cancel()
|
window.speechSynthesis.speak(utter)
|
} catch (e) {
|
// ignore
|
}
|
}
|
|
function refreshFakeData() {
|
// 样品状态滚动
|
const next = randomSample()
|
sampleStatus.value = [next, ...sampleStatus.value].slice(0, 8)
|
|
// 任务排行
|
const tasks = Array.from({ length: 10 }).map((_, i) => ({ name: `任务-${i + 1}`, count: Math.floor(Math.random() * 100 + 20) }))
|
tasks.sort((a, b) => a.count - b.count)
|
tasksXAxis.data = tasks.map(t => t.name)
|
tasksSeries.value[0].data = tasks.map(t => t.count)
|
|
// 历史趋势(追加点)
|
const nowLabel = new Date().toLocaleTimeString('zh-CN', { minute: '2-digit', second: '2-digit' })
|
if (trendXAxis.value.length > 15) {
|
trendXAxis.value.shift()
|
trendSeries.value[0].data.shift()
|
trendSeries.value[1].data.shift()
|
}
|
trendXAxis.value.push(nowLabel)
|
const incoming = Math.floor(Math.random() * 30 + 20)
|
const finished = Math.max(0, incoming - Math.floor(Math.random() * 10))
|
trendSeries.value[0].data.push(incoming)
|
trendSeries.value[1].data.push(finished)
|
|
// 合格率(轻微波动)
|
const delta = (Math.random() - 0.5) * 0.02
|
passRate.value = Math.min(0.99, Math.max(0.6, passRate.value + delta))
|
passRateSeries.value[0].data[0].value = passRate.value
|
|
// SPC 数据(窗口移动)
|
const nextVal = CL.value + (Math.random() - 0.5) * 8 // 波动
|
if (spcXAxis.value.length > 30) {
|
spcXAxis.value.shift()
|
spcData.value.shift()
|
}
|
spcXAxis.value.push(`${spcXAxis.value.length + 1}`)
|
spcData.value.push(parseFloat(nextVal.toFixed(2)))
|
spcSeries.value[0].data = [...spcData.value]
|
spcSeries.value[1].data = new Array(spcData.value.length).fill(UCL.value)
|
spcSeries.value[2].data = new Array(spcData.value.length).fill(CL.value)
|
spcSeries.value[3].data = new Array(spcData.value.length).fill(LCL.value)
|
|
// 触发播报:合格率过低或 SPC 超限
|
if (passRate.value < 0.8) {
|
speak(`预警,当前合格率为 ${(passRate.value * 100).toFixed(0)}%,低于 80% 阈值`)
|
}
|
const last = spcData.value[spcData.value.length - 1]
|
if (last > UCL.value) {
|
speak(`预警,最新测量值 ${last.toFixed(2)} 超过上限`)
|
}
|
if (last < LCL.value) {
|
speak(`预警,最新测量值 ${last.toFixed(2)} 低于下限`)
|
}
|
}
|
|
onMounted(() => {
|
// 初始化几条假数据
|
sampleStatus.value = Array.from({ length: 5 }).map(() => randomSample())
|
for (let i = 0; i < 10; i++) {
|
trendXAxis.value.push(`T-${i}`)
|
trendSeries.value[0].data.push(Math.floor(Math.random() * 30 + 20))
|
trendSeries.value[1].data.push(Math.floor(Math.random() * 25 + 15))
|
}
|
for (let i = 0; i < 20; i++) {
|
spcXAxis.value.push(`${i + 1}`)
|
const v = CL.value + (Math.random() - 0.5) * 6
|
spcData.value.push(parseFloat(v.toFixed(2)))
|
}
|
spcSeries.value[0].data = [...spcData.value]
|
spcSeries.value[1].data = new Array(spcData.value.length).fill(UCL.value)
|
spcSeries.value[2].data = new Array(spcData.value.length).fill(CL.value)
|
spcSeries.value[3].data = new Array(spcData.value.length).fill(LCL.value)
|
|
dataTimer = setInterval(refreshFakeData, 10000)
|
})
|
|
onBeforeUnmount(() => {
|
if (dataTimer) clearInterval(dataTimer)
|
try { window.speechSynthesis && window.speechSynthesis.cancel() } catch (e) {}
|
})
|
</script>
|
|
<style scoped>
|
.quality-dashboard {
|
padding: 8px;
|
}
|
.panel-title {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
font-weight: 600;
|
}
|
.status-list {
|
display: flex;
|
flex-direction: column;
|
gap: 8px;
|
max-height: 320px;
|
overflow: auto;
|
}
|
.status-item {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 8px 10px;
|
border-radius: 6px;
|
background: var(--el-fill-color-light);
|
}
|
.status-item .left {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
.status-item .right {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
}
|
.status-item .name { font-weight: 500; }
|
.status-item .time { color: var(--el-text-color-secondary); font-size: 12px; }
|
.dot {
|
width: 8px;
|
height: 8px;
|
border-radius: 50%;
|
display: inline-block;
|
}
|
.dot.processing { background: #60a5fa; }
|
.dot.warning { background: #f59e0b; }
|
.dot.error { background: #ef4444; }
|
.dot.success { background: #10b981; }
|
.passrate-text {
|
text-align: center;
|
margin-top: 8px;
|
}
|
</style>
|