From 244393670deec2856657d1ec969a72ad6217391f Mon Sep 17 00:00:00 2001 From: LYFxiaoan Date: Mon, 21 Jul 2025 18:44:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8E=86=E5=8F=B2=E9=A2=84?= =?UTF-8?q?=E6=B5=8B=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UI/src/views/HistoryView.vue | 247 +++++++++--------- .../views/prediction/GlobalPredictionView.vue | 13 +- .../prediction/ProductPredictionView.vue | 15 +- .../views/prediction/StorePredictionView.vue | 15 +- prediction_history.db | Bin 36864 -> 94208 bytes server/api.py | 199 +++++++------- server/predictors/model_predictor.py | 7 +- 7 files changed, 267 insertions(+), 229 deletions(-) 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 31e42aea35377038bbee0cf4109345d3ad015491..4b9b54ee610730970236bac50f12c92b89d60830 100644 GIT binary patch literal 94208 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 AYXATM 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 = "全局销售数据"