完善历史预测展示

This commit is contained in:
LYFxiaoan 2025-07-21 18:44:20 +08:00
parent e4d170d667
commit 244393670d
7 changed files with 267 additions and 229 deletions

View File

@ -248,41 +248,9 @@ import { ref, onMounted, reactive, watch, nextTick } from 'vue';
import axios from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus';
import { QuestionFilled, Search, View, Delete, ArrowUp, ArrowDown, Minus, Download } from '@element-plus/icons-vue';
import * as echarts from 'echarts/core';
import { LineChart, BarChart } from 'echarts/charts';
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent
} from 'echarts/components';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
import Chart from 'chart.js/auto'; // << Chart.js
import { computed, onUnmounted } from 'vue';
//
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent,
LineChart,
BarChart,
LabelLayout,
UniversalTransition,
CanvasRenderer
]);
const loading = ref(false);
const history = ref([]);
const products = ref([]);
@ -292,8 +260,8 @@ const currentPrediction = ref(null);
const rawResponseData = ref(null);
const showRawDataFlag = ref(false);
const fullscreenPredictionChart = ref(null);
const fullscreenHistoryChart = ref(null);
let predictionChart = null; // << 使chart
let historyChart = null;
const filters = reactive({
product_id: '',
@ -982,104 +950,133 @@ const getFactorsArray = computed(() => {
watch(detailsVisible, (newVal) => {
if (newVal && currentPrediction.value) {
nextTick(() => {
// Init Prediction Chart
if (fullscreenPredictionChart.value) fullscreenPredictionChart.value.dispose();
const predChartDom = document.getElementById('fullscreen-prediction-chart-history');
if (predChartDom) {
fullscreenPredictionChart.value = echarts.init(predChartDom);
if (currentPrediction.value.chart_data) {
updatePredictionChart(currentPrediction.value.chart_data, fullscreenPredictionChart.value, true);
}
}
// Init History Chart
if (currentPrediction.value.analysis) {
if (fullscreenHistoryChart.value) fullscreenHistoryChart.value.dispose();
const histChartDom = document.getElementById('fullscreen-history-chart-history');
if (histChartDom) {
fullscreenHistoryChart.value = echarts.init(histChartDom);
updateHistoryChart(currentPrediction.value.analysis, fullscreenHistoryChart.value, true);
}
}
renderChart();
//
// renderHistoryAnalysisChart();
});
}
});
const updatePredictionChart = (chartData, chart, isFullscreen = false) => {
if (!chart || !chartData) return;
chart.showLoading();
const dates = chartData.dates || [];
const sales = chartData.sales || [];
const types = chartData.types || [];
// << ProductPredictionView.vuerenderChart
const renderChart = () => {
const chartCanvas = document.getElementById('fullscreen-prediction-chart-history');
if (!chartCanvas || !currentPrediction.value || !currentPrediction.value.data) return;
const combinedData = [];
for (let i = 0; i < dates.length; i++) {
combinedData.push({ date: dates[i], sales: sales[i], type: types[i] });
if (predictionChart) {
predictionChart.destroy();
}
combinedData.sort((a, b) => new Date(a.date) - new Date(b.date));
const allDates = combinedData.map(item => item.date);
const historyDates = combinedData.filter(d => d.type === '历史销量').map(d => d.date);
const historySales = combinedData.filter(d => d.type === '历史销量').map(d => d.sales);
const predictionDates = combinedData.filter(d => d.type === '预测销量').map(d => d.date);
const predictionSales = combinedData.filter(d => d.type === '预测销量').map(d => d.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: { fontSize: isFullscreen ? 18 : 16, fontWeight: 'bold', color: '#e0e6ff' } },
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' },
formatter: function(params) {
if (!params || params.length === 0) return '';
const date = params[0].axisValue;
let html = `<div style="font-weight:bold">${date}</div>`;
params.forEach(item => {
if (item.value !== '-') {
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:5px 0;">
<span style="display:inline-block;margin-right:5px;width:10px;height:10px;border-radius:50%;background-color:${item.color};"></span>
<span>${item.seriesName}:</span>
<span style="font-weight:bold;margin-left:5px;">${item.value.toFixed(2)}</span>
</div>`;
}
});
return html;
}
const formatDate = (date) => new Date(date).toISOString().split('T')[0];
const historyData = (currentPrediction.value.data.history_data || []).map(p => ({ ...p, date: formatDate(p.date) }));
const predictionData = (currentPrediction.value.data.prediction_data || []).map(p => ({ ...p, date: formatDate(p.date) }));
if (historyData.length === 0 && predictionData.length === 0) {
ElMessage.warning('没有可用于图表的数据。');
return;
}
const allLabels = [...new Set([...historyData.map(p => p.date), ...predictionData.map(p => p.date)])].sort();
const simplifiedLabels = allLabels.map(date => date.split('-')[2]);
const historyMap = new Map(historyData.map(p => [p.date, p.sales]));
// 使 'sales'
const predictionMap = new Map(predictionData.map(p => [p.date, p.sales]));
const alignedHistorySales = allLabels.map(label => historyMap.get(label) ?? null);
const alignedPredictionSales = allLabels.map(label => predictionMap.get(label) ?? null);
if (historyData.length > 0 && predictionData.length > 0) {
const lastHistoryDate = historyData[historyData.length - 1].date;
const lastHistoryValue = historyData[historyData.length - 1].sales;
if (!predictionMap.has(lastHistoryDate)) {
alignedPredictionSales[allLabels.indexOf(lastHistoryDate)] = lastHistoryValue;
}
}
let subtitleText = '';
if (historyData.length > 0) {
subtitleText += `历史数据: ${historyData[0].date} ~ ${historyData[historyData.length - 1].date}`;
}
if (predictionData.length > 0) {
if (subtitleText) subtitleText += ' | ';
subtitleText += `预测数据: ${predictionData[0].date} ~ ${predictionData[predictionData.length - 1].date}`;
}
predictionChart = new Chart(chartCanvas, {
type: 'line',
data: {
labels: simplifiedLabels,
datasets: [
{
label: '历史销量',
data: alignedHistorySales,
borderColor: '#67C23A',
backgroundColor: 'rgba(103, 194, 58, 0.2)',
tension: 0.4,
fill: true,
spanGaps: false,
},
{
label: '预测销量',
data: alignedPredictionSales,
borderColor: '#409EFF',
backgroundColor: 'rgba(64, 158, 255, 0.2)',
tension: 0.4,
fill: true,
borderDash: [5, 5],
}
]
},
legend: { data: ['历史销量', '预测销量'], top: isFullscreen ? 40 : 30, textStyle: { color: '#e0e6ff' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
toolbox: { feature: { saveAsImage: { title: '保存图片' } }, iconStyle: { borderColor: '#e0e6ff' } },
xAxis: { type: 'category', boundaryGap: false, data: allDates, axisLabel: { color: '#e0e6ff' }, axisLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.5)' } } },
yAxis: { type: 'value', name: '销量', min: minSale, max: maxSale, axisLabel: { color: '#e0e6ff' }, nameTextStyle: { color: '#e0e6ff' }, axisLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.5)' } }, splitLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.1)' } } },
series: [
{ name: '历史销量', type: 'line', smooth: true, connectNulls: true, data: allDates.map(date => historyDates.includes(date) ? historySales[historyDates.indexOf(date)] : null), areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' }, { offset: 1, color: 'rgba(64, 158, 255, 0.1)' }]) }, lineStyle: { color: '#409EFF' } },
{ name: '预测销量', type: 'line', smooth: true, connectNulls: true, data: allDates.map(date => predictionDates.includes(date) ? predictionSales[predictionDates.indexOf(date)] : null), lineStyle: { color: '#F56C6C' } }
]
};
chart.hideLoading();
chart.setOption(option, true);
};
const updateHistoryChart = (analysisData, chart, isFullscreen = false) => {
if (!chart || !analysisData || !analysisData.history_chart_data) return;
chart.showLoading();
const { dates, changes } = analysisData.history_chart_data;
const option = {
title: { text: '销量日环比变化', left: 'center', textStyle: { fontSize: isFullscreen ? 18 : 16, fontWeight: 'bold', color: '#e0e6ff' } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: p => `${p[0].axisValue}<br/>环比: ${p[0].value.toFixed(2)}%` },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
toolbox: { feature: { saveAsImage: { title: '保存图片' } }, iconStyle: { borderColor: '#e0e6ff' } },
xAxis: { type: 'category', data: dates.map(d => formatDate(d)), axisLabel: { color: '#e0e6ff' }, axisLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.5)' } } },
yAxis: { type: 'value', name: '环比变化(%)', axisLabel: { formatter: '{value}%', color: '#e0e6ff' }, nameTextStyle: { color: '#e0e6ff' }, axisLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.5)' } }, splitLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.1)' } } },
series: [{
name: '日环比变化', type: 'bar',
data: changes.map(val => ({ value: val, itemStyle: { color: val >= 0 ? '#67C23A' : '#F56C6C' } }))
}]
};
chart.hideLoading();
chart.setOption(option, true);
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${currentPrediction.value.data.product_name} - 销量预测趋势图`,
color: '#ffffff',
font: {
size: 20,
weight: 'bold',
}
},
subtitle: {
display: true,
text: subtitleText,
color: '#6c757d',
font: {
size: 14,
},
padding: {
bottom: 20
}
}
},
scales: {
x: {
title: {
display: true,
text: '日期 (日)'
},
grid: {
display: false
}
},
y: {
title: {
display: true,
text: '销量'
},
grid: {
color: '#e9e9e9',
drawBorder: false,
},
beginAtZero: true
}
}
}
});
};
const exportHistoryData = () => {

View File

@ -294,15 +294,22 @@ const renderChart = () => {
title: {
display: true,
text: '全局销量预测趋势图',
font: { size: 18 }
color: '#ffffff',
font: {
size: 20,
weight: 'bold',
}
},
subtitle: {
display: true,
text: subtitleText,
color: '#6c757d',
font: {
size: 14,
},
padding: {
bottom: 20
},
font: { size: 14 }
}
}
},
scales: {

View File

@ -314,16 +314,23 @@ const renderChart = () => {
plugins: {
title: {
display: true,
text: `${form.product_id}” - 销量预测趋势图`,
font: { size: 18 }
text: `${predictionResult.value.product_name} - 销量预测趋势图`,
color: '#ffffff',
font: {
size: 20,
weight: 'bold',
}
},
subtitle: {
display: true,
text: subtitleText,
color: '#6c757d',
font: {
size: 14,
},
padding: {
bottom: 20
},
font: { size: 14 }
}
}
},
scales: {

View File

@ -312,16 +312,23 @@ const renderChart = () => {
plugins: {
title: {
display: true,
text: `“店铺${form.store_id}” - 销量预测趋势图`,
font: { size: 18 }
text: `${predictionResult.value.product_name} - 销量预测趋势图`,
color: '#ffffff',
font: {
size: 20,
weight: 'bold',
}
},
subtitle: {
display: true,
text: subtitleText,
color: '#6c757d',
font: {
size: 14,
},
padding: {
bottom: 20
},
font: { size: 14 }
}
}
},
scales: {

Binary file not shown.

View File

@ -1456,6 +1456,27 @@ def predict():
print(f"prediction_data 长度: {len(response_data['prediction_data'])}")
print("================================")
# 重新加入保存预测结果的逻辑
try:
model_id_to_save = f"{model_identifier}_{model_type}_{version}"
product_name_to_save = prediction_result.get('product_name', product_id or store_id or 'global')
# 调用辅助函数保存结果
save_prediction_result(
prediction_result=prediction_result,
product_id=product_id or store_id or 'global',
product_name=product_name_to_save,
model_type=model_type,
model_id=model_id_to_save,
start_date=start_date,
future_days=future_days
)
print(f"✅ 预测结果已成功保存到历史记录。")
except Exception as e:
print(f"⚠️ 警告: 保存预测结果到历史记录失败: {str(e)}")
traceback.print_exc()
# 不应阻止向用户返回结果,因此只打印警告
return jsonify(response_data)
except Exception as e:
print(f"预测失败: {str(e)}")
@ -1772,16 +1793,31 @@ def get_prediction_history():
# 转换结果为字典列表
history_records = []
for record in records:
# 使用列名访问,更安全可靠
created_at_str = record['created_at']
start_date_str = record['start_date']
formatted_created_at = created_at_str # 默认值
try:
# 解析ISO 8601格式的日期时间
dt_obj = datetime.fromisoformat(created_at_str)
# 格式化为前端期望的 'YYYY/MM/DD HH:MM:SS'
formatted_created_at = dt_obj.strftime('%Y/%m/%d %H:%M:%S')
except (ValueError, TypeError):
# 如果解析失败,记录日志并使用原始字符串
logger.warning(f"无法解析历史记录中的日期格式: {created_at_str}")
history_records.append({
'id': record[0],
'product_id': record[1],
'product_name': record[2],
'model_type': record[3],
'model_id': record[4],
'start_date': record[5],
'future_days': record[6],
'created_at': record[7],
'file_path': record[8]
'id': record['id'],
'prediction_id': record['prediction_id'],
'product_id': record['product_id'],
'product_name': record['product_name'],
'model_type': record['model_type'],
'model_id': record['model_id'],
'start_date': start_date_str if start_date_str else "N/A",
'future_days': record['future_days'],
'created_at': formatted_created_at,
'file_path': record['file_path']
})
conn.close()
@ -1801,109 +1837,88 @@ def get_prediction_history():
@app.route('/api/prediction/history/<prediction_id>', methods=['GET'])
def get_prediction_details(prediction_id):
"""获取特定预测记录的详情"""
"""获取特定预测记录的详情 (v7 - 统一前端逻辑后的最终版)"""
try:
print(f"正在获取预测记录详情ID: {prediction_id}")
logger.info(f"正在获取预测记录详情ID: {prediction_id}")
# 连接数据库
conn = get_db_connection()
cursor = conn.cursor()
# 查询预测记录元数据
cursor.execute("""
SELECT product_id, product_name, model_type, model_id,
start_date, future_days, created_at, file_path
FROM prediction_history WHERE id = ?
""", (prediction_id,))
cursor.execute("SELECT * FROM prediction_history WHERE id = ?", (prediction_id,))
record = cursor.fetchone()
if not record:
print(f"预测记录不存在: {prediction_id}")
conn.close()
return jsonify({"status": "error", "message": "预测记录不存在"}), 404
# 提取元数据
product_id = record['product_id']
product_name = record['product_name']
model_type = record['model_type']
model_id = record['model_id']
start_date = record['start_date']
future_days = record['future_days']
created_at = record['created_at']
file_path = record['file_path']
conn.close()
print(f"正在读取预测结果文件: {file_path}")
if not os.path.exists(file_path):
print(f"预测结果文件不存在: {file_path}")
if not record:
logger.warning(f"数据库中未找到预测记录: ID={prediction_id}")
return jsonify({"status": "error", "message": "预测记录不存在"}), 404
file_path = record['file_path']
if not file_path or not os.path.exists(file_path):
logger.error(f"预测结果文件不存在或路径为空: {file_path}")
return jsonify({"status": "error", "message": "预测结果文件不存在"}), 404
# 读取保存的JSON文件内容
with open(file_path, 'r', encoding='utf-8') as f:
prediction_data = json.load(f)
saved_data = json.load(f)
# 构建与预测分析接口一致的响应格式
response_data = {
"status": "success",
"meta": {
"product_id": product_id,
"product_name": product_name,
"model_type": model_type,
"model_id": model_id,
"start_date": start_date,
"future_days": future_days,
"created_at": created_at
},
"data": {
"prediction_data": [],
"history_data": [],
"data": []
},
"analysis": prediction_data.get('analysis', {}),
"chart_data": prediction_data.get('chart_data', {})
core_data = saved_data
if 'data' in saved_data and isinstance(saved_data.get('data'), dict):
nested_data = saved_data['data']
if 'history_data' in nested_data or 'prediction_data' in nested_data:
core_data = nested_data
# 1. 数据清洗和字段名统一
history_data = core_data.get('history_data', [])
prediction_data = core_data.get('prediction_data', [])
cleaned_history = []
for item in (history_data or []):
if not isinstance(item, dict): continue
sales_val = item.get('sales')
cleaned_history.append({
'date': item.get('date'),
'sales': float(sales_val) if sales_val is not None and not np.isnan(sales_val) else None
})
cleaned_prediction = []
for item in (prediction_data or []):
if not isinstance(item, dict): continue
# 关键修复:将 'predicted_sales' 统一为 'sales'
sales_val = item.get('predicted_sales', item.get('sales'))
cleaned_prediction.append({
'date': item.get('date'),
'sales': float(sales_val) if sales_val is not None and not np.isnan(sales_val) else None,
# 统一前端逻辑后不再需要predicted_sales但为兼容旧数据保留
'predicted_sales': float(sales_val) if sales_val is not None and not np.isnan(sales_val) else None
})
# 2. 构建与前端统一逻辑完全兼容的payload
final_payload = {
'product_name': record['product_name'],
'model_type': record['model_type'],
'start_date': record['start_date'],
'created_at': record['created_at'],
'history_data': cleaned_history,
'prediction_data': cleaned_prediction,
'analysis': core_data.get('analysis', {}),
}
# 处理预测数据
if 'prediction_data' in prediction_data and isinstance(prediction_data['prediction_data'], list):
response_data['data']['prediction_data'] = prediction_data['prediction_data']
# 3. 最终封装
response_data = {
"status": "success",
"data": final_payload
}
logger.info(f"成功构建并返回历史预测详情 (v7): ID={prediction_id}, "
f"历史数据点: {len(final_payload['history_data'])}, "
f"预测数据点: {len(final_payload['prediction_data'])}")
# 处理历史数据
if 'history_data' in prediction_data and isinstance(prediction_data['history_data'], list):
response_data['data']['history_data'] = prediction_data['history_data']
# 处理合并的数据
if 'data' in prediction_data and isinstance(prediction_data['data'], list):
response_data['data']['data'] = prediction_data['data']
else:
# 如果没有合并数据,从历史和预测数据中构建
history_data = response_data['data']['history_data']
pred_data = response_data['data']['prediction_data']
response_data['data']['data'] = history_data + pred_data
# 确保所有数据字段都存在且格式正确
for key in ['prediction_data', 'history_data', 'data']:
if not isinstance(response_data['data'][key], list):
response_data['data'][key] = []
# 添加兼容性字段(直接在根级别)
response_data.update({
'product_id': product_id,
'product_name': product_name,
'model_type': model_type,
'start_date': start_date,
'created_at': created_at
})
print(f"成功获取预测详情,产品: {product_name}, 模型: {model_type}")
return jsonify(response_data)
except json.JSONDecodeError as e:
print(f"预测结果文件JSON解析错误: {e}")
logger.error(f"预测结果文件JSON解析错误: {file_path}, 错误: {e}")
return jsonify({"status": "error", "message": f"预测结果文件格式错误: {str(e)}"}), 500
except Exception as e:
print(f"获取预测详情失败: {str(e)}")
logger.error(f"获取预测详情失败: {str(e)}")
traceback.print_exc()
return jsonify({"status": "error", "message": str(e)}), 500

View File

@ -45,8 +45,13 @@ def load_model_and_predict(model_path: str, product_id: str, model_type: str, st
# 加载销售数据
from utils.multi_store_data_utils import aggregate_multi_store_data
if training_mode == 'store' and store_id:
# 先从原始数据加载一次以获取店铺名称,聚合会丢失此信息
from utils.multi_store_data_utils import load_multi_store_data
store_df_for_name = load_multi_store_data(store_id=store_id)
product_name = store_df_for_name['store_name'].iloc[0] if not store_df_for_name.empty else f"店铺 {store_id}"
# 然后再进行聚合获取用于预测的数据
product_df = aggregate_multi_store_data(store_id=store_id, aggregation_method='sum', file_path=DEFAULT_DATA_PATH)
product_name = product_df['store_name'].iloc[0] if not product_df.empty else f"店铺{store_id}"
elif training_mode == 'global':
product_df = aggregate_multi_store_data(aggregation_method='sum', file_path=DEFAULT_DATA_PATH)
product_name = "全局销售数据"