huminmin
2026-06-01 a563ea879ef5fb6897e76d2df661e465dce2ab9b
src/views/qualityManagement/visualization/qualityDashboard.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,307 @@
<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>