修复图表显示和数据处理问题
1. 修复前端图表日期排序问题: - 改进 PredictionView.vue 和 HistoryView.vue 中的图表渲染逻辑 - 确保历史数据和预测数据按照正确的日期顺序显示 2. 修复后端API处理: - 解决 optimized_kan 模型类型的路径映射问题 - 添加 JSON 序列化器处理 Pandas Timestamp 对象 - 改进预测数据与历史数据的衔接处理 3. 优化图表样式和用户体验
This commit is contained in:
parent
7a52c67703
commit
5d505b37af
425
UI/src/views/HistoryView.vue
Normal file
425
UI/src/views/HistoryView.vue
Normal file
@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div class="history-view">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>历史预测记录</span>
|
||||
<el-tooltip content="在这里可以查看、管理和分析所有历史预测的结果">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<el-form :model="filters" inline class="filter-form">
|
||||
<el-form-item label="产品">
|
||||
<el-select v-model="filters.product_id" placeholder="筛选产品" filterable clearable @change="fetchHistory">
|
||||
<el-option v-for="item in products" :key="item.product_id" :label="item.product_name" :value="item.product_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型类型">
|
||||
<el-select v-model="filters.model_type" placeholder="筛选模型" clearable @change="fetchHistory">
|
||||
<el-option label="mLSTM" value="mlstm" />
|
||||
<el-option label="Transformer" value="transformer" />
|
||||
<el-option label="KAN" value="kan" />
|
||||
<el-option label="优化版KAN" value="optimized_kan" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetchHistory">
|
||||
<el-icon><Search /></el-icon> 查询
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 历史记录表格 -->
|
||||
<div v-loading="loading">
|
||||
<el-empty v-if="history.length === 0 && !loading" description="暂无历史预测记录"></el-empty>
|
||||
<el-table v-else :data="history" border stripe style="width: 100%">
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150" />
|
||||
<el-table-column prop="model_type" label="模型类型" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getModelTagType(row.model_type)" size="small">{{ row.model_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="start_date" label="预测起始日" min-width="120" />
|
||||
<el-table-column prop="future_days" label="预测天数" min-width="100" />
|
||||
<el-table-column prop="created_at" label="预测时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<el-button size="small" type="primary" @click="viewDetails(row.id)">
|
||||
<el-icon><View /></el-icon> 查看详情
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteHistory(row.id)">
|
||||
<el-icon><Delete /></el-icon> 删除记录
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailsVisible" title="预测详情" fullscreen :destroy-on-close="true">
|
||||
<div v-if="currentPrediction" class="prediction-details-content">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="产品名称">{{ currentPrediction.meta.product_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模型类型">
|
||||
<el-tag :type="getModelTagType(currentPrediction.meta.model_type)">{{ currentPrediction.meta.model_type }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预测时间">{{ formatDateTime(currentPrediction.meta.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预测起始日期">{{ currentPrediction.meta.start_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预测天数">{{ currentPrediction.meta.future_days }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模型ID">{{ currentPrediction.meta.model_id }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div ref="detailsChartRef" style="width: 100%; height: 500px;"></div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-table :data="currentPrediction.data.prediction_data" stripe border height="400">
|
||||
<el-table-column prop="date" label="日期" width="150" />
|
||||
<el-table-column prop="sales" label="预测销量" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ row.sales.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="detailsVisible = false">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, watch, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { QuestionFilled, Search, View, Delete } from '@element-plus/icons-vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const loading = ref(false);
|
||||
const history = ref([]);
|
||||
const products = ref([]);
|
||||
const detailsVisible = ref(false);
|
||||
const currentPrediction = ref(null);
|
||||
const detailsChartRef = ref(null);
|
||||
let detailsChart = null;
|
||||
|
||||
const filters = reactive({
|
||||
product_id: '',
|
||||
model_type: ''
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/products');
|
||||
if (response.data.status === 'success') {
|
||||
products.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取产品列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
...filters,
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
};
|
||||
const response = await axios.get('/api/prediction/history', { params });
|
||||
if (response.data.status === 'success') {
|
||||
history.value = response.data.data;
|
||||
pagination.total = response.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取历史记录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.page_size = size;
|
||||
fetchHistory();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page;
|
||||
fetchHistory();
|
||||
};
|
||||
|
||||
const viewDetails = async (id) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/prediction/history/${id}`);
|
||||
if (response.data.status === 'success') {
|
||||
currentPrediction.value = response.data;
|
||||
detailsVisible.value = true;
|
||||
nextTick(() => {
|
||||
initDetailsChart();
|
||||
});
|
||||
} else {
|
||||
ElMessage.error('获取详情失败');
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHistory = (id) => {
|
||||
ElMessageBox.confirm('确定要删除这条预测记录吗?此操作不可逆。', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await axios.delete(`/api/prediction/history/${id}`);
|
||||
ElMessage.success('删除成功');
|
||||
fetchHistory(); // 重新加载数据
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}).catch(() => {
|
||||
//
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (isoString) => {
|
||||
if (!isoString) return 'N/A';
|
||||
return new Date(isoString).toLocaleString();
|
||||
};
|
||||
|
||||
const getModelTagType = (modelType) => {
|
||||
const types = {
|
||||
'mlstm': 'primary',
|
||||
'transformer': 'success',
|
||||
'kan': 'warning',
|
||||
'optimized_kan': 'info'
|
||||
};
|
||||
return types[modelType] || 'info';
|
||||
};
|
||||
|
||||
const initDetailsChart = () => {
|
||||
if (detailsChart) {
|
||||
detailsChart.dispose();
|
||||
}
|
||||
detailsChart = echarts.init(detailsChartRef.value);
|
||||
|
||||
const chartData = currentPrediction.value.data.chart_data;
|
||||
|
||||
// 分离历史数据和预测数据
|
||||
const historyDates = [];
|
||||
const historySales = [];
|
||||
const predictionDates = [];
|
||||
const predictionSales = [];
|
||||
|
||||
// 创建一个包含日期、销售额和类型的完整数据集
|
||||
const combinedData = [];
|
||||
for (let i = 0; i < chartData.dates.length; i++) {
|
||||
combinedData.push({
|
||||
date: chartData.dates[i],
|
||||
sales: chartData.sales[i],
|
||||
type: chartData.types[i]
|
||||
});
|
||||
}
|
||||
|
||||
// 按日期排序
|
||||
combinedData.sort((a, b) => {
|
||||
return new Date(a.date) - new Date(b.date);
|
||||
});
|
||||
|
||||
// 将排序后的数据分离为历史和预测
|
||||
const allDates = [];
|
||||
for (const item of combinedData) {
|
||||
allDates.push(item.date);
|
||||
if (item.type === '历史销量') {
|
||||
historyDates.push(item.date);
|
||||
historySales.push(item.sales);
|
||||
} else {
|
||||
predictionDates.push(item.date);
|
||||
predictionSales.push(item.sales);
|
||||
}
|
||||
}
|
||||
|
||||
const allSales = [...historySales, ...predictionSales].filter(val => !isNaN(val));
|
||||
const minSale = Math.max(0, Math.floor(Math.min(...allSales) * 0.9));
|
||||
const maxSale = Math.ceil(Math.max(...allSales) * 1.1);
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: '销量预测趋势图',
|
||||
left: 'center',
|
||||
textStyle: { color: '#e0e6ff' }
|
||||
},
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['历史销量', '预测销量'],
|
||||
top: 30,
|
||||
textStyle: { color: '#e0e6ff' }
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
toolbox: { feature: { saveAsImage: {} }, iconStyle: { borderColor: '#e0e6ff' } },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: allDates, // 使用排序后的完整日期列表
|
||||
axisLabel: { color: '#e0e6ff' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#e0e6ff' },
|
||||
splitLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.1)' } },
|
||||
min: minSale,
|
||||
max: maxSale
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '历史销量',
|
||||
type: 'line',
|
||||
// 使用坐标系计算
|
||||
data: allDates.map(date => {
|
||||
const index = historyDates.indexOf(date);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
value: historySales[index],
|
||||
itemStyle: { color: '#409EFF' }
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
value: '-', // 留空,表示该日期没有历史数据
|
||||
itemStyle: { opacity: 0 }
|
||||
};
|
||||
}
|
||||
}),
|
||||
connectNulls: true, // 连接空值点
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#409EFF'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '预测销量',
|
||||
type: 'line',
|
||||
// 使用坐标系计算
|
||||
data: allDates.map(date => {
|
||||
const index = predictionDates.indexOf(date);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
value: predictionSales[index],
|
||||
itemStyle: { color: '#F56C6C' }
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
value: '-', // 留空,表示该日期没有预测数据
|
||||
itemStyle: { opacity: 0 }
|
||||
};
|
||||
}
|
||||
}),
|
||||
connectNulls: true, // 连接空值点
|
||||
smooth: true,
|
||||
symbol: 'diamond',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: '#F56C6C'
|
||||
},
|
||||
markPoint: {
|
||||
data: [
|
||||
{ type: 'max', name: '最大值' },
|
||||
{ type: 'min', name: '最小值' }
|
||||
],
|
||||
label: {
|
||||
color: '#e0e6ff'
|
||||
}
|
||||
},
|
||||
markLine: {
|
||||
data: [
|
||||
{ type: 'average', name: '平均值' }
|
||||
],
|
||||
label: {
|
||||
color: '#e0e6ff'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
detailsChart.setOption(option);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProducts();
|
||||
fetchHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-view {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.prediction-details-content {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user