完善历史预测展示

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 axios from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { QuestionFilled, Search, View, Delete, ArrowUp, ArrowDown, Minus, Download } from '@element-plus/icons-vue'; import { QuestionFilled, Search, View, Delete, ArrowUp, ArrowDown, Minus, Download } from '@element-plus/icons-vue';
import * as echarts from 'echarts/core'; import Chart from 'chart.js/auto'; // << Chart.js
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 { computed, onUnmounted } from 'vue'; 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 loading = ref(false);
const history = ref([]); const history = ref([]);
const products = ref([]); const products = ref([]);
@ -292,8 +260,8 @@ const currentPrediction = ref(null);
const rawResponseData = ref(null); const rawResponseData = ref(null);
const showRawDataFlag = ref(false); const showRawDataFlag = ref(false);
const fullscreenPredictionChart = ref(null); let predictionChart = null; // << 使chart
const fullscreenHistoryChart = ref(null); let historyChart = null;
const filters = reactive({ const filters = reactive({
product_id: '', product_id: '',
@ -982,104 +950,133 @@ const getFactorsArray = computed(() => {
watch(detailsVisible, (newVal) => { watch(detailsVisible, (newVal) => {
if (newVal && currentPrediction.value) { if (newVal && currentPrediction.value) {
nextTick(() => { nextTick(() => {
// Init Prediction Chart renderChart();
if (fullscreenPredictionChart.value) fullscreenPredictionChart.value.dispose(); //
const predChartDom = document.getElementById('fullscreen-prediction-chart-history'); // renderHistoryAnalysisChart();
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);
}
}
}); });
} }
}); });
const updatePredictionChart = (chartData, chart, isFullscreen = false) => { // << ProductPredictionView.vuerenderChart
if (!chart || !chartData) return; const renderChart = () => {
chart.showLoading(); const chartCanvas = document.getElementById('fullscreen-prediction-chart-history');
const dates = chartData.dates || []; if (!chartCanvas || !currentPrediction.value || !currentPrediction.value.data) return;
const sales = chartData.sales || [];
const types = chartData.types || [];
const combinedData = []; if (predictionChart) {
for (let i = 0; i < dates.length; i++) { predictionChart.destroy();
combinedData.push({ date: dates[i], sales: sales[i], type: types[i] });
} }
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 = { const formatDate = (date) => new Date(date).toISOString().split('T')[0];
title: { text: '销量预测趋势图', left: 'center', textStyle: { fontSize: isFullscreen ? 18 : 16, fontWeight: 'bold', color: '#e0e6ff' } },
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, const historyData = (currentPrediction.value.data.history_data || []).map(p => ({ ...p, date: formatDate(p.date) }));
formatter: function(params) { const predictionData = (currentPrediction.value.data.prediction_data || []).map(p => ({ ...p, date: formatDate(p.date) }));
if (!params || params.length === 0) return '';
const date = params[0].axisValue; if (historyData.length === 0 && predictionData.length === 0) {
let html = `<div style="font-weight:bold">${date}</div>`; ElMessage.warning('没有可用于图表的数据。');
params.forEach(item => { return;
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> const allLabels = [...new Set([...historyData.map(p => p.date), ...predictionData.map(p => p.date)])].sort();
<span>${item.seriesName}:</span> const simplifiedLabels = allLabels.map(date => date.split('-')[2]);
<span style="font-weight:bold;margin-left:5px;">${item.value.toFixed(2)}</span>
</div>`; const historyMap = new Map(historyData.map(p => [p.date, p.sales]));
} // 使 'sales'
}); const predictionMap = new Map(predictionData.map(p => [p.date, p.sales]));
return html;
} 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' } }, options: {
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, responsive: true,
toolbox: { feature: { saveAsImage: { title: '保存图片' } }, iconStyle: { borderColor: '#e0e6ff' } }, maintainAspectRatio: false,
xAxis: { type: 'category', boundaryGap: false, data: allDates, axisLabel: { color: '#e0e6ff' }, axisLine: { lineStyle: { color: 'rgba(224, 230, 255, 0.5)' } } }, plugins: {
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)' } } }, title: {
series: [ display: true,
{ 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' } }, text: `${currentPrediction.value.data.product_name} - 销量预测趋势图`,
{ name: '预测销量', type: 'line', smooth: true, connectNulls: true, data: allDates.map(date => predictionDates.includes(date) ? predictionSales[predictionDates.indexOf(date)] : null), lineStyle: { color: '#F56C6C' } } color: '#ffffff',
] font: {
}; size: 20,
chart.hideLoading(); weight: 'bold',
chart.setOption(option, true); }
}; },
subtitle: {
const updateHistoryChart = (analysisData, chart, isFullscreen = false) => { display: true,
if (!chart || !analysisData || !analysisData.history_chart_data) return; text: subtitleText,
chart.showLoading(); color: '#6c757d',
const { dates, changes } = analysisData.history_chart_data; font: {
size: 14,
const option = { },
title: { text: '销量日环比变化', left: 'center', textStyle: { fontSize: isFullscreen ? 18 : 16, fontWeight: 'bold', color: '#e0e6ff' } }, padding: {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: p => `${p[0].axisValue}<br/>环比: ${p[0].value.toFixed(2)}%` }, bottom: 20
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)' } } }, scales: {
series: [{ x: {
name: '日环比变化', type: 'bar', title: {
data: changes.map(val => ({ value: val, itemStyle: { color: val >= 0 ? '#67C23A' : '#F56C6C' } })) display: true,
}] text: '日期 (日)'
}; },
chart.hideLoading(); grid: {
chart.setOption(option, true); display: false
}
},
y: {
title: {
display: true,
text: '销量'
},
grid: {
color: '#e9e9e9',
drawBorder: false,
},
beginAtZero: true
}
}
}
});
}; };
const exportHistoryData = () => { const exportHistoryData = () => {

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -1456,6 +1456,27 @@ def predict():
print(f"prediction_data 长度: {len(response_data['prediction_data'])}") print(f"prediction_data 长度: {len(response_data['prediction_data'])}")
print("================================") 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) return jsonify(response_data)
except Exception as e: except Exception as e:
print(f"预测失败: {str(e)}") print(f"预测失败: {str(e)}")
@ -1772,16 +1793,31 @@ def get_prediction_history():
# 转换结果为字典列表 # 转换结果为字典列表
history_records = [] history_records = []
for record in 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({ history_records.append({
'id': record[0], 'id': record['id'],
'product_id': record[1], 'prediction_id': record['prediction_id'],
'product_name': record[2], 'product_id': record['product_id'],
'model_type': record[3], 'product_name': record['product_name'],
'model_id': record[4], 'model_type': record['model_type'],
'start_date': record[5], 'model_id': record['model_id'],
'future_days': record[6], 'start_date': start_date_str if start_date_str else "N/A",
'created_at': record[7], 'future_days': record['future_days'],
'file_path': record[8] 'created_at': formatted_created_at,
'file_path': record['file_path']
}) })
conn.close() conn.close()
@ -1801,109 +1837,88 @@ def get_prediction_history():
@app.route('/api/prediction/history/<prediction_id>', methods=['GET']) @app.route('/api/prediction/history/<prediction_id>', methods=['GET'])
def get_prediction_details(prediction_id): def get_prediction_details(prediction_id):
"""获取特定预测记录的详情""" """获取特定预测记录的详情 (v7 - 统一前端逻辑后的最终版)"""
try: try:
print(f"正在获取预测记录详情ID: {prediction_id}") logger.info(f"正在获取预测记录详情ID: {prediction_id}")
# 连接数据库
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
# 查询预测记录元数据 cursor.execute("SELECT * FROM prediction_history WHERE id = ?", (prediction_id,))
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,))
record = cursor.fetchone() 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() conn.close()
print(f"正在读取预测结果文件: {file_path}") if not record:
logger.warning(f"数据库中未找到预测记录: ID={prediction_id}")
if not os.path.exists(file_path): return jsonify({"status": "error", "message": "预测记录不存在"}), 404
print(f"预测结果文件不存在: {file_path}")
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 return jsonify({"status": "error", "message": "预测结果文件不存在"}), 404
# 读取保存的JSON文件内容
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
prediction_data = json.load(f) saved_data = json.load(f)
# 构建与预测分析接口一致的响应格式 core_data = saved_data
response_data = { if 'data' in saved_data and isinstance(saved_data.get('data'), dict):
"status": "success", nested_data = saved_data['data']
"meta": { if 'history_data' in nested_data or 'prediction_data' in nested_data:
"product_id": product_id, core_data = nested_data
"product_name": product_name,
"model_type": model_type, # 1. 数据清洗和字段名统一
"model_id": model_id, history_data = core_data.get('history_data', [])
"start_date": start_date, prediction_data = core_data.get('prediction_data', [])
"future_days": future_days,
"created_at": created_at cleaned_history = []
}, for item in (history_data or []):
"data": { if not isinstance(item, dict): continue
"prediction_data": [], sales_val = item.get('sales')
"history_data": [], cleaned_history.append({
"data": [] 'date': item.get('date'),
}, 'sales': float(sales_val) if sales_val is not None and not np.isnan(sales_val) else None
"analysis": prediction_data.get('analysis', {}), })
"chart_data": prediction_data.get('chart_data', {})
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', {}),
} }
# 处理预测数据 # 3. 最终封装
if 'prediction_data' in prediction_data and isinstance(prediction_data['prediction_data'], list): response_data = {
response_data['data']['prediction_data'] = prediction_data['prediction_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) return jsonify(response_data)
except json.JSONDecodeError as e: 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 return jsonify({"status": "error", "message": f"预测结果文件格式错误: {str(e)}"}), 500
except Exception as e: except Exception as e:
print(f"获取预测详情失败: {str(e)}") logger.error(f"获取预测详情失败: {str(e)}")
traceback.print_exc() traceback.print_exc()
return jsonify({"status": "error", "message": str(e)}), 500 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 from utils.multi_store_data_utils import aggregate_multi_store_data
if training_mode == 'store' and store_id: 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_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': elif training_mode == 'global':
product_df = aggregate_multi_store_data(aggregation_method='sum', file_path=DEFAULT_DATA_PATH) product_df = aggregate_multi_store_data(aggregation_method='sum', file_path=DEFAULT_DATA_PATH)
product_name = "全局销售数据" product_name = "全局销售数据"