gaoluyang
9 天以前 39e6447b576ee5504e157b8168d91774e6fdebe1
浪潮lims
1.添加数据分析与展示页面
已添加2个文件
843 ■■■■■ 文件已修改
src/api/lims/dataAnalysis.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/lims/dataAnalysis/index.vue 796 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/lims/dataAnalysis.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
// æ•°æ®åˆ†æžä¸Žå±•示接口
import request from '@/utils/request'
// èŽ·å–çœ‹æ¿å…¨éƒ¨æ•°æ®ï¼ˆæŽ¨èï¼‰
export function getDashboard(query) {
    return request({
        url: '/lims/dataAnalysis/dashboard',
        method: 'get',
        params: query
    })
}
// èŽ·å–æ¦‚è§ˆæŒ‡æ ‡
export function getOverview(query) {
    return request({
        url: '/lims/dataAnalysis/overview',
        method: 'get',
        params: query
    })
}
// èŽ·å–è¶‹åŠ¿æ•°æ®
export function getTrend(query) {
    return request({
        url: '/lims/dataAnalysis/trend',
        method: 'get',
        params: query
    })
}
// èŽ·å–æ¯”è¾ƒåˆ†æžæ•°æ®
export function getComparison(query) {
    return request({
        url: '/lims/dataAnalysis/comparison',
        method: 'get',
        params: query
    })
}
// èŽ·å–è´¨é‡åˆ†å¸ƒæ•°æ®
export function getQualityDistribution(query) {
    return request({
        url: '/lims/dataAnalysis/qualityDistribution',
        method: 'get',
        params: query
    })
}
src/views/lims/dataAnalysis/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,796 @@
<template>
  <div class="app-container data-analysis-container">
    <!-- ç­›é€‰æ¡ä»¶ -->
    <div class="search_form">
      <div class="search-left">
        <span class="search_title">时间范围:</span>
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          value-format="YYYY-MM-DD"
          @change="handleQuery"
          style="width: 260px"
        />
        <span class="search_title" style="margin-left: 20px">数据类型:</span>
        <el-select
          v-model="searchForm.dataType"
          placeholder="请选择"
          clearable
          @change="handleQuery"
          style="width: 140px"
        >
          <el-option label="温度" value="temperature" />
          <el-option label="湿度" value="humidity" />
          <el-option label="压力" value="pressure" />
          <el-option label="流量" value="flow" />
          <el-option label="浓度" value="concentration" />
        </el-select>
        <span class="search_title" style="margin-left: 20px">设备编号:</span>
        <el-input
          v-model="searchForm.deviceCode"
          placeholder="请输入设备编号"
          clearable
          @change="handleQuery"
          style="width: 160px"
        />
        <span class="search_title" style="margin-left: 20px">趋势粒度:</span>
        <el-radio-group v-model="searchForm.granularity" @change="handleQuery">
          <el-radio-button value="day">按天</el-radio-button>
          <el-radio-button value="hour">按小时</el-radio-button>
        </el-radio-group>
      </div>
      <div class="search-right">
        <el-button type="primary" @click="handleQuery">查询</el-button>
        <el-button @click="resetQuery">重置</el-button>
      </div>
    </div>
    <!-- æ¦‚览指标卡片 -->
    <div class="overview-cards">
      <div class="overview-card">
        <div class="card-icon total">
          <el-icon><DataLine /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">采集总条数</div>
          <div class="card-value">{{ dashboardData.overview?.totalCollections || 0 }}</div>
        </div>
      </div>
      <div class="overview-card">
        <div class="card-icon today">
          <el-icon><Calendar /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">今日采集</div>
          <div class="card-value">{{ dashboardData.overview?.todayCollections || 0 }}</div>
        </div>
      </div>
      <div class="overview-card">
        <div class="card-icon abnormal">
          <el-icon><Warning /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">异常数据</div>
          <div class="card-value">{{ dashboardData.overview?.abnormalCollections || 0 }}</div>
        </div>
      </div>
      <div class="overview-card">
        <div class="card-icon rate">
          <el-icon><CircleCheck /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">合格率</div>
          <div class="card-value">{{ dashboardData.overview?.qualifiedRate || 0 }}%</div>
        </div>
      </div>
      <div class="overview-card">
        <div class="card-icon experiment">
          <el-icon><Collection /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">进行中实验</div>
          <div class="card-value">{{ dashboardData.overview?.inProgressExperiments || 0 }}</div>
        </div>
      </div>
      <div class="overview-card">
        <div class="card-icon warning">
          <el-icon><Bell /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">预警监控</div>
          <div class="card-value">{{ dashboardData.overview?.warningMonitors || 0 }}</div>
        </div>
      </div>
      <div class="overview-card">
        <div class="card-icon sample">
          <el-icon><Box /></el-icon>
        </div>
        <div class="card-content">
          <div class="card-label">在库样品</div>
          <div class="card-value">{{ dashboardData.overview?.inStockSamples || 0 }}</div>
        </div>
      </div>
    </div>
    <!-- å›¾è¡¨åŒºåŸŸ -->
    <div class="charts-container">
      <!-- è¶‹åŠ¿åˆ†æž -->
      <div class="chart-panel trend-panel">
        <div class="panel-header">
          <div class="panel-title">
            <el-icon><TrendCharts /></el-icon>
            æ•°æ®è¶‹åŠ¿åˆ†æž
          </div>
          <div class="panel-extra">
            <span v-if="dashboardData.startTime && dashboardData.endTime">
              {{ formatDateTime(dashboardData.startTime) }} ~ {{ formatDateTime(dashboardData.endTime) }}
            </span>
          </div>
        </div>
        <div class="chart-content">
          <div ref="trendChartRef" style="width: 100%; height: 100%;"></div>
        </div>
      </div>
      <!-- æ¯”较分析 -->
      <div class="chart-panel comparison-panel">
        <div class="panel-header">
          <div class="panel-title">
            <el-icon><Histogram /></el-icon>
            æ•°æ®æ¯”较分析
          </div>
          <el-radio-group v-model="searchForm.dimension" size="small" @change="handleQuery">
            <el-radio-button value="dataType">按数据类型</el-radio-button>
            <el-radio-button value="deviceName">按设备名称</el-radio-button>
            <el-radio-button value="deviceCode">按设备编号</el-radio-button>
          </el-radio-group>
        </div>
        <div class="chart-content">
          <div ref="comparisonChartRef" style="width: 100%; height: 100%;"></div>
        </div>
      </div>
      <!-- è´¨é‡åˆ†å¸ƒ -->
      <div class="chart-panel quality-panel">
        <div class="panel-header">
          <div class="panel-title">
            <el-icon><PieChart /></el-icon>
            è´¨é‡åˆ†å¸ƒç»Ÿè®¡
          </div>
        </div>
        <div class="chart-content">
          <div ref="qualityChartRef" style="width: 100%; height: 100%;"></div>
        </div>
        <!-- è´¨é‡åˆ†å¸ƒå›¾ä¾‹ -->
        <div class="quality-legend" v-if="qualityChartData.length > 0">
          <div class="legend-item" v-for="item in qualityChartData" :key="item.category">
            <span class="legend-dot" :style="{ backgroundColor: item.color }"></span>
            <span class="legend-label">{{ item.name }}</span>
            <span class="legend-value">{{ item.value }}</span>
            <span class="legend-ratio">({{ item.ratio }}%)</span>
          </div>
        </div>
        <div v-else class="no-data-text">暂无数据</div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import {
  DataLine, Calendar, Warning, CircleCheck,
  Collection, Bell, Box, TrendCharts, Histogram, PieChart
} from '@element-plus/icons-vue'
import { getDashboard } from '@/api/lims/dataAnalysis'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
// æœç´¢è¡¨å•
const searchForm = reactive({
  dataType: '',
  deviceCode: '',
  granularity: 'day',
  dimension: 'dataType'
})
// æ—¥æœŸèŒƒå›´
const dateRange = ref([])
// çœ‹æ¿æ•°æ®
const dashboardData = ref({
  overview: {},
  trend: [],
  comparison: [],
  qualityDistribution: []
})
// å›¾è¡¨å®žä¾‹
const trendChartRef = ref(null)
const comparisonChartRef = ref(null)
const qualityChartRef = ref(null)
let trendChart = null
let comparisonChart = null
let qualityChart = null
// åŠ è½½çŠ¶æ€
const loading = ref(false)
// èŽ·å–é»˜è®¤æ—¶é—´èŒƒå›´ï¼ˆæœ€è¿‘7天)
const getDefaultDateRange = () => {
  const end = new Date()
  const start = new Date()
  start.setDate(start.getDate() - 7)
  return [
    start.toISOString().slice(0, 10),
    end.toISOString().slice(0, 10)
  ]
}
// æ ¼å¼åŒ–日期时间
const formatDateTime = (dateTime) => {
  if (!dateTime) return ''
  return dateTime.replace('T', ' ')
}
// åˆå§‹åŒ–趋势图表
const initTrendChart = () => {
  if (!trendChartRef.value) return
  trendChart = echarts.init(trendChartRef.value)
  updateTrendChart()
}
// æ›´æ–°è¶‹åŠ¿å›¾è¡¨
const updateTrendChart = () => {
  if (!trendChart) return
  const trend = dashboardData.value.trend || []
  const xAxis = trend.map(item => item.time)
  const pointCount = trend.map(item => item.pointCount)
  const avgValue = trend.map(item => item.avgValue)
  const maxValue = trend.map(item => item.maxValue)
  const minValue = trend.map(item => item.minValue)
  const option = {
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'cross'
      }
    },
    legend: {
      data: ['采集点数', '平均值', '最大值', '最小值'],
      bottom: 0
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '10%',
      top: '10%',
      containLabel: true
    },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: xAxis
    },
    yAxis: [
      {
        type: 'value',
        name: '采集点数',
        position: 'left'
      },
      {
        type: 'value',
        name: '数值',
        position: 'right'
      }
    ],
    series: [
      {
        name: '采集点数',
        type: 'line',
        data: pointCount,
        smooth: true,
        areaStyle: {
          opacity: 0.1
        }
      },
      {
        name: '平均值',
        type: 'line',
        yAxisIndex: 1,
        data: avgValue,
        smooth: true
      },
      {
        name: '最大值',
        type: 'line',
        yAxisIndex: 1,
        data: maxValue,
        smooth: true
      },
      {
        name: '最小值',
        type: 'line',
        yAxisIndex: 1,
        data: minValue,
        smooth: true
      }
    ]
  }
  trendChart.setOption(option, true)
}
// åˆå§‹åŒ–比较图表
const initComparisonChart = () => {
  if (!comparisonChartRef.value) return
  comparisonChart = echarts.init(comparisonChartRef.value)
  updateComparisonChart()
}
// æ›´æ–°æ¯”较图表
const updateComparisonChart = () => {
  if (!comparisonChart) return
  const comparison = dashboardData.value.comparison || []
  if (comparison.length === 0) {
    comparisonChart.clear()
    return
  }
  const xAxis = comparison.map(item => item.dimensionValue)
  const pointCount = comparison.map(item => item.pointCount)
  const avgValue = comparison.map(item => item.avgValue)
  const option = {
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'shadow'
      }
    },
    legend: {
      data: ['采集点数', '平均值'],
      bottom: 0
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '10%',
      top: '10%',
      containLabel: true
    },
    xAxis: {
      type: 'category',
      data: xAxis
    },
    yAxis: [
      {
        type: 'value',
        name: '采集点数'
      },
      {
        type: 'value',
        name: '平均值'
      }
    ],
    series: [
      {
        name: '采集点数',
        type: 'bar',
        data: pointCount,
        itemStyle: {
          borderRadius: [4, 4, 0, 0]
        }
      },
      {
        name: '平均值',
        type: 'bar',
        yAxisIndex: 1,
        data: avgValue,
        itemStyle: {
          borderRadius: [4, 4, 0, 0]
        }
      }
    ]
  }
  comparisonChart.setOption(option, true)
}
// è´¨é‡åˆ†å¸ƒé¢œè‰²æ˜ å°„
const qualityColorMap = {
  qualified: '#67C23A',
  abnormal: '#F56C6C',
  pending: '#E6A23C'
}
const qualityNameMap = {
  qualified: '合格',
  abnormal: '异常',
  pending: '待处理'
}
// è´¨é‡åˆ†å¸ƒå›¾è¡¨æ•°æ®
const qualityChartData = computed(() => {
  const distribution = dashboardData.value.qualityDistribution || []
  return distribution.map(item => ({
    ...item,
    name: qualityNameMap[item.category] || item.category,
    value: item.pointCount,
    color: qualityColorMap[item.category] || '#909399'
  }))
})
// åˆå§‹åŒ–质量分布图表
const initQualityChart = () => {
  if (!qualityChartRef.value) return
  qualityChart = echarts.init(qualityChartRef.value)
  updateQualityChart()
}
// æ›´æ–°è´¨é‡åˆ†å¸ƒå›¾è¡¨
const updateQualityChart = () => {
  if (!qualityChart) return
  const distribution = dashboardData.value.qualityDistribution || []
  if (distribution.length === 0) {
    qualityChart.clear()
    return
  }
  const data = distribution.map(item => ({
    name: qualityNameMap[item.category] || item.category,
    value: item.pointCount,
    itemStyle: {
      color: qualityColorMap[item.category] || '#909399'
    }
  }))
  const option = {
    tooltip: {
      trigger: 'item',
      formatter: '{b}: {c} ({d}%)'
    },
    series: [
      {
        type: 'pie',
        radius: ['40%', '70%'],
        avoidLabelOverlap: false,
        itemStyle: {
          borderRadius: 10,
          borderColor: '#fff',
          borderWidth: 2
        },
        label: {
          show: false,
          position: 'center'
        },
        emphasis: {
          label: {
            show: true,
            fontSize: 20,
            fontWeight: 'bold'
          }
        },
        labelLine: {
          show: false
        },
        data: data
      }
    ]
  }
  qualityChart.setOption(option, true)
}
// æŸ¥è¯¢æ•°æ®
const handleQuery = async () => {
  loading.value = true
  try {
    const params = {
      ...searchForm
    }
    // å¤„理时间范围 - å°†æ—¥æœŸè½¬æ¢ä¸ºæ—¥æœŸæ—¶é—´æ ¼å¼
    if (dateRange.value && dateRange.value.length === 2) {
      params.startTime = dateRange.value[0] + ' 00:00:00'
      params.endTime = dateRange.value[1] + ' 23:59:59'
    }
    const res = await getDashboard(params)
    if (res.code === 200) {
      dashboardData.value = res.data || {}
      // æ›´æ–°å›¾è¡¨
      nextTick(() => {
        updateTrendChart()
        updateComparisonChart()
        updateQualityChart()
      })
    } else {
      ElMessage.error(res.msg || '获取数据失败')
    }
  } catch (error) {
    console.error('获取看板数据失败:', error)
    ElMessage.error('获取数据失败')
  } finally {
    loading.value = false
  }
}
// é‡ç½®æŸ¥è¯¢
const resetQuery = () => {
  searchForm.dataType = ''
  searchForm.deviceCode = ''
  searchForm.granularity = 'day'
  searchForm.dimension = 'dataType'
  dateRange.value = getDefaultDateRange()
  handleQuery()
}
// çª—口大小改变时重新调整图表
const handleResize = () => {
  trendChart?.resize()
  comparisonChart?.resize()
  qualityChart?.resize()
}
// åˆå§‹åŒ–
onMounted(() => {
  dateRange.value = getDefaultDateRange()
  nextTick(() => {
    initTrendChart()
    initComparisonChart()
    initQualityChart()
    handleQuery()
  })
  window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
  trendChart?.dispose()
  comparisonChart?.dispose()
  qualityChart?.dispose()
})
</script>
<style lang="scss" scoped>
.data-analysis-container {
  .search_form {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
    margin-bottom: 20px;
    .search-left {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: 10px;
    }
    .search-right {
      display: flex;
      gap: 10px;
    }
  }
  // æ¦‚览卡片
  .overview-cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 16px;
    margin-bottom: 20px;
    .overview-card {
      background: #fff;
      border-radius: 8px;
      padding: 20px;
      display: flex;
      align-items: center;
      gap: 16px;
      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
      transition: transform 0.3s;
      &:hover {
        transform: translateY(-2px);
        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
      }
      .card-icon {
        width: 56px;
        height: 56px;
        border-radius: 12px;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 28px;
        &.total {
          background: linear-gradient(135deg, #409EFF 0%, #1677FF 100%);
          color: #fff;
        }
        &.today {
          background: linear-gradient(135deg, #67C23A 0%, #52C41A 100%);
          color: #fff;
        }
        &.abnormal {
          background: linear-gradient(135deg, #F56C6C 0%, #FF4D4F 100%);
          color: #fff;
        }
        &.rate {
          background: linear-gradient(135deg, #E6A23C 0%, #FAAD14 100%);
          color: #fff;
        }
        &.experiment {
          background: linear-gradient(135deg, #909399 0%, #666666 100%);
          color: #fff;
        }
        &.warning {
          background: linear-gradient(135deg, #FF6B6B 0%, #EE5A6F 100%);
          color: #fff;
        }
        &.sample {
          background: linear-gradient(135deg, #36CFC9 0%, #13C2C2 100%);
          color: #fff;
        }
      }
      .card-content {
        flex: 1;
        .card-label {
          font-size: 14px;
          color: #909399;
          margin-bottom: 8px;
        }
        .card-value {
          font-size: 28px;
          font-weight: 600;
          color: #303133;
          line-height: 1;
        }
      }
    }
  }
  // å›¾è¡¨å®¹å™¨
  .charts-container {
    display: grid;
    grid-template-columns: 2fr 1fr;
    grid-template-rows: 1fr 1fr;
    gap: 20px;
    .chart-panel {
      background: #fff;
      border-radius: 8px;
      padding: 20px;
      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
      .panel-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 20px;
        .panel-title {
          font-size: 16px;
          font-weight: 600;
          color: #303133;
          display: flex;
          align-items: center;
          gap: 8px;
          .el-icon {
            font-size: 20px;
            color: #409EFF;
          }
        }
        .panel-extra {
          font-size: 12px;
          color: #909399;
        }
      }
      .chart-content {
        height: 300px;
      }
    }
    .trend-panel {
      grid-row: span 2;
      .chart-content {
        height: 620px;
      }
    }
    .quality-panel {
      .chart-content {
        height: 220px;
      }
      .quality-legend {
        display: flex;
        flex-direction: column;
        gap: 12px;
        margin-top: 16px;
        padding-top: 16px;
        border-top: 1px solid #EBEEF5;
        .legend-item {
          display: flex;
          align-items: center;
          gap: 8px;
          .legend-dot {
            width: 12px;
            height: 12px;
            border-radius: 50%;
          }
          .legend-label {
            flex: 1;
            font-size: 14px;
            color: #606266;
          }
          .legend-value {
            font-size: 16px;
            font-weight: 600;
            color: #303133;
          }
          .legend-ratio {
            font-size: 12px;
            color: #909399;
          }
        }
      }
      .no-data-text {
        text-align: center;
        color: #909399;
        font-size: 14px;
        padding: 40px 0;
      }
    }
  }
}
@media (max-width: 1200px) {
  .data-analysis-container {
    .charts-container {
      grid-template-columns: 1fr;
      grid-template-rows: auto;
      .trend-panel {
        grid-row: span 1;
        .chart-content {
          height: 300px;
        }
      }
    }
  }
}
</style>