diff --git a/UI/src/views/HistoryView.vue b/UI/src/views/HistoryView.vue index 80d5f5d..ec19ed3 100644 --- a/UI/src/views/HistoryView.vue +++ b/UI/src/views/HistoryView.vue @@ -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.vue复制并适应的renderChart函数 +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 = `
${date}
`; - params.forEach(item => { - if (item.value !== '-') { - html += `
- - ${item.seriesName}: - ${item.value.toFixed(2)} -
`; - } - }); - 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}
环比: ${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 = () => { diff --git a/UI/src/views/prediction/GlobalPredictionView.vue b/UI/src/views/prediction/GlobalPredictionView.vue index 10fab64..98e5b08 100644 --- a/UI/src/views/prediction/GlobalPredictionView.vue +++ b/UI/src/views/prediction/GlobalPredictionView.vue @@ -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: { diff --git a/UI/src/views/prediction/ProductPredictionView.vue b/UI/src/views/prediction/ProductPredictionView.vue index 7e43af4..279d38f 100644 --- a/UI/src/views/prediction/ProductPredictionView.vue +++ b/UI/src/views/prediction/ProductPredictionView.vue @@ -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: { diff --git a/UI/src/views/prediction/StorePredictionView.vue b/UI/src/views/prediction/StorePredictionView.vue index 828aba2..1eb0c1d 100644 --- a/UI/src/views/prediction/StorePredictionView.vue +++ b/UI/src/views/prediction/StorePredictionView.vue @@ -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: { diff --git a/prediction_history.db b/prediction_history.db index 31e42ae..4b9b54e 100644 Binary files a/prediction_history.db and b/prediction_history.db differ diff --git a/server/api.py b/server/api.py index fb27a43..aba5aee 100644 --- a/server/api.py +++ b/server/api.py @@ -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/', 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 diff --git a/server/predictors/model_predictor.py b/server/predictors/model_predictor.py index e5766b3..1f57caf 100644 --- a/server/predictors/model_predictor.py +++ b/server/predictors/model_predictor.py @@ -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 = "全局销售数据"