修复图表显示和数据处理问题

1. 修复前端图表日期排序问题:
   - 改进 PredictionView.vue 和 HistoryView.vue 中的图表渲染逻辑
   - 确保历史数据和预测数据按照正确的日期顺序显示

2. 修复后端API处理:
   - 解决 optimized_kan 模型类型的路径映射问题
   - 添加 JSON 序列化器处理 Pandas Timestamp 对象
   - 改进预测数据与历史数据的衔接处理

3. 优化图表样式和用户体验
This commit is contained in:
gdtiti 2025-06-15 00:00:50 +08:00
parent 7a52c67703
commit 5d505b37af
3 changed files with 3174 additions and 280 deletions

View 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

1333
api.py

File diff suppressed because it is too large Load Diff