<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>
|