diff --git a/UI/src/views/HistoryView.vue b/UI/src/views/HistoryView.vue index e06def4..22e5a57 100644 --- a/UI/src/views/HistoryView.vue +++ b/UI/src/views/HistoryView.vue @@ -14,7 +14,7 @@ - + @@ -82,18 +82,18 @@ - +
- {{ currentPrediction.meta?.product_name || currentPrediction.product_name || getProductName(currentPrediction.meta?.product_id) || '未知产品' }} + {{ currentPrediction.product_name || '未知产品' }} - {{ currentPrediction.meta?.model_type || currentPrediction.model_type || '未知模型' }} + {{ currentPrediction.model_type || '未知模型' }} - {{ currentPrediction.meta?.start_date || currentPrediction.start_date || (currentPrediction.data?.prediction_data?.[0]?.date) || 'N/A' }} - {{ formatDateTime(currentPrediction.meta?.created_at || currentPrediction.created_at) }} + {{ currentPrediction.start_date || 'N/A' }} + {{ formatDateTime(currentPrediction.created_at) }}
@@ -107,7 +107,7 @@

预测趋势图

-
+
@@ -123,29 +123,29 @@
-
{{ currentPrediction?.data?.prediction_data ? calculateAverage(currentPrediction.data.prediction_data).toFixed(2) : '暂无数据' }}
+
{{ currentPrediction?.prediction_data ? calculateAverage(currentPrediction.prediction_data).toFixed(2) : '暂无数据' }}
-
{{ currentPrediction?.data?.prediction_data ? calculateMax(currentPrediction.data.prediction_data).toFixed(2) : '暂无数据' }}
+
{{ currentPrediction?.prediction_data ? calculateMax(currentPrediction.prediction_data).toFixed(2) : '暂无数据' }}
-
{{ currentPrediction?.data?.prediction_data ? calculateMin(currentPrediction.data.prediction_data).toFixed(2) : '暂无数据' }}
+
{{ currentPrediction?.prediction_data ? calculateMin(currentPrediction.prediction_data).toFixed(2) : '暂无数据' }}
- + @@ -153,7 +153,7 @@ - + @@ -161,7 +161,7 @@ - + @@ -212,7 +212,7 @@ -
+
@@ -254,15 +254,17 @@ import { computed, onUnmounted } from 'vue'; const loading = ref(false); const history = ref([]); -const products = ref([]); +const productFilterOptions = ref([]); const modelTypes = ref([]); const detailsVisible = ref(false); const currentPrediction = ref(null); const rawResponseData = ref(null); const showRawDataFlag = ref(false); +const predictionChartCanvas = ref(null); +const analysisChartCanvas = ref(null); -let predictionChart = null; // << 关键改动:使用单个chart实例 -let historyChart = null; +let predictionChartInstance = null; +let analysisChartInstance = null; const filters = reactive({ product_id: '', @@ -287,14 +289,14 @@ const fetchModelTypes = async () => { } }; -const fetchProducts = async () => { +const fetchFilterOptions = async () => { try { - const response = await axios.get('/api/products'); + const response = await axios.get('/api/history/filter-options'); if (response.data.status === 'success') { - products.value = response.data.data; + productFilterOptions.value = response.data.data; } } catch (error) { - ElMessage.error('获取产品列表失败'); + ElMessage.error('获取筛选选项列表失败'); console.error(error); } }; @@ -311,7 +313,6 @@ const fetchHistory = async () => { if (response.data.status === 'success') { history.value = response.data.data; pagination.total = response.data.total; - } } catch (error) { ElMessage.error('获取历史记录失败'); @@ -333,8 +334,8 @@ const handleCurrentChange = (page) => { // 添加getProductName函数 const getProductName = (productId) => { if (!productId) return '未知产品'; - const product = products.value.find(p => p.product_id === productId); - return product ? product.product_name : '未知产品'; + const option = productFilterOptions.value.find(p => p.value === productId); + return option ? option.label : '未知产品'; }; const viewDetails = async (id) => { @@ -345,10 +346,18 @@ const viewDetails = async (id) => { if (responseData && responseData.status === 'success' && responseData.data) { // 简化逻辑:直接信任后端返回的、结构正确的 responseData.data + // 关键修复:将后端返回的扁平化数据结构正确赋值 + // 后端返回的 data 字段直接包含了所有需要的信息 currentPrediction.value = responseData.data; detailsVisible.value = true; console.log('接收到并准备渲染的预测详情数据:', currentPrediction.value); + + // 最终修复:在数据加载和弹窗可见后,通过nextTick确保DOM渲染完毕再初始化图表 + nextTick(() => { + renderPredictionChart(currentPrediction.value); + renderHistoryAnalysisChart(currentPrediction.value); + }); } else { ElMessage.error('获取详情失败: ' + (responseData?.message || responseData?.error || '数据格式错误')); @@ -903,155 +912,116 @@ const getFactorsArray = computed(() => { return []; }); -watch(detailsVisible, (newVal) => { - if (newVal && currentPrediction.value) { - nextTick(() => { - renderChart(); - // 可以在这里添加渲染第二个图表的逻辑 - // renderHistoryAnalysisChart(); - }); - } -}); +const renderPredictionChart = (predictionData) => { + const canvasEl = predictionChartCanvas.value; + if (!canvasEl || !predictionData) return; -// << 关键改动:从ProductPredictionView.vue复制并适应的renderChart函数 -const renderChart = () => { - const chartCanvas = document.getElementById('fullscreen-prediction-chart-history'); - if (!chartCanvas || !currentPrediction.value || !currentPrediction.value.data) return; - - if (predictionChart) { - predictionChart.destroy(); + if (predictionChartInstance) { + predictionChartInstance.destroy(); } const formatDate = (date) => new Date(date).toISOString().split('T')[0]; + const historyData = (predictionData.history_data || []).map(p => ({ ...p, date: formatDate(p.date) })); + const predData = (predictionData.prediction_data || []).map(p => ({ ...p, date: formatDate(p.date) })); - 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) { - const ctx = chartCanvas.getContext('2d'); - ctx.clearRect(0, 0, chartCanvas.width, chartCanvas.height); - ctx.textAlign = 'center'; - ctx.fillStyle = '#909399'; - ctx.font = '20px Arial'; - ctx.fillText('暂无详细图表数据', chartCanvas.width / 2, chartCanvas.height / 2); + if (historyData.length === 0 && predData.length === 0) { + // Handle no data case if needed 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 allLabels = [...new Set([...historyData.map(p => p.date), ...predData.map(p => p.date)])].sort(); + const simplifiedLabels = allLabels.map(date => date.split('-').slice(1).join('/')); const historyMap = new Map(historyData.map(p => [p.date, p.sales])); - // 修正:预测数据使用 'predicted_sales' 字段 - const predictionMap = new Map(predictionData.map(p => [p.date, p.predicted_sales])); - + const predictionMap = new Map(predData.map(p => [p.date, p.predicted_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) { + if (historyData.length > 0 && predData.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; + const lastHistoryIndex = allLabels.indexOf(lastHistoryDate); + if (!predictionMap.has(lastHistoryDate) && lastHistoryIndex !== -1) { + alignedPredictionSales[lastHistoryIndex] = 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}`; + if (historyData.length > 0) subtitleText += `历史: ${historyData[0].date} ~ ${historyData[historyData.length - 1].date}`; + if (predData.length > 0) { + if (subtitleText) subtitleText += ' | '; + subtitleText += `预测: ${predData[0].date} ~ ${predData[predData.length - 1].date}`; } - predictionChart = new Chart(chartCanvas, { + predictionChartInstance = new Chart(canvasEl, { 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], - } + { 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] } ] }, 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 - } - } + title: { display: true, text: `${predictionData.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 - } + x: { title: { display: true, text: '日期' }, grid: { display: false } }, + y: { title: { display: true, text: '销量' }, grid: { color: '#e9e9e9', drawBorder: false }, beginAtZero: true } } } }); }; +const renderHistoryAnalysisChart = (predictionData) => { + const canvasEl = analysisChartCanvas.value; + if (!canvasEl || !predictionData.analysis || !predictionData.analysis.history_chart_data) return; + + if (analysisChartInstance) { + analysisChartInstance.destroy(); + } + + const chartData = predictionData.analysis.history_chart_data; + analysisChartInstance = new Chart(canvasEl, { + type: 'bar', + data: { + labels: chartData.dates.map(d => d.split('-').slice(1).join('/')), + datasets: [{ + label: '日环比变化 (%)', + data: chartData.changes, + backgroundColor: chartData.changes.map(c => c >= 0 ? 'rgba(103, 194, 58, 0.6)' : 'rgba(245, 108, 108, 0.6)'), + borderColor: chartData.changes.map(c => c >= 0 ? '#67C23A' : '#F56C6C'), + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { y: { title: { display: true, text: '变化率 (%)' } } } + } + }); +}; + const exportHistoryData = () => { if (!currentPrediction.value) return; - const data = currentPrediction.value.data.data; + const allData = [ + ...(currentPrediction.value.history_data || []).map(d => ({...d, data_type: '历史销量'})), + ...(currentPrediction.value.prediction_data || []).map(d => ({...d, sales: d.predicted_sales, data_type: '预测销量'})) + ]; let csvContent = "日期,销量,数据类型\n"; - data.forEach(row => { - csvContent += `${new Date(row.date).toLocaleDateString()},${row.sales.toFixed(2)},${row.data_type}\n`; + allData.forEach(row => { + const salesValue = row.sales || row.predicted_sales || 0; + csvContent += `${new Date(row.date).toLocaleDateString()},${salesValue.toFixed(2)},${row.data_type}\n`; }); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - const { product_name, model_type, start_date } = currentPrediction.value.meta; + const { product_name, model_type, start_date } = currentPrediction.value; link.setAttribute('download', `历史预测_${product_name}_${model_type}_${start_date}.csv`); document.body.appendChild(link); link.click(); @@ -1060,28 +1030,34 @@ const exportHistoryData = () => { }; const resizeCharts = () => { - if (predictionChart) { - predictionChart.resize(); + if (predictionChartInstance) { + predictionChartInstance.resize(); } - if (historyChart) { - historyChart.resize(); + if (analysisChartInstance) { + analysisChartInstance.resize(); } }; window.addEventListener('resize', resizeCharts); +const destroyCharts = () => { + if (predictionChartInstance) { + predictionChartInstance.destroy(); + predictionChartInstance = null; + } + if (analysisChartInstance) { + analysisChartInstance.destroy(); + analysisChartInstance = null; + } +}; + onUnmounted(() => { window.removeEventListener('resize', resizeCharts); - if (predictionChart) { - predictionChart.destroy(); - } - if (historyChart) { - historyChart.destroy(); - } + destroyCharts(); // 在组件卸载时也调用 }); onMounted(() => { - fetchProducts(); + fetchFilterOptions(); fetchModelTypes(); fetchHistory(); }); diff --git a/prediction_history.db b/prediction_history.db index 0f731fe..87de459 100644 Binary files a/prediction_history.db and b/prediction_history.db differ diff --git a/server/api.py b/server/api.py index cd0a028..e134278 100644 --- a/server/api.py +++ b/server/api.py @@ -1398,6 +1398,24 @@ def predict(): if prediction_result is None: return jsonify({"status": "error", "message": "预测失败,核心预测器返回None"}), 500 + # 调试步骤:在分析函数调用后,打印其输入和输出 + history_data_for_analysis = prediction_result.get('history_data', []) + prediction_data_for_analysis = prediction_result.get('prediction_data', []) + logger.info(f"DEBUG: 分析函数的输入参数检查 - " + f"history_data 类型: {type(history_data_for_analysis)}, 长度: {len(history_data_for_analysis)}. " + f"prediction_data 类型: {type(prediction_data_for_analysis)}, 长度: {len(prediction_data_for_analysis)}.") + logger.info(f"DEBUG: 核心预测器返回的 'analysis' 字段内容: {prediction_result.get('analysis')}") + + # 根本修复:确保 'analysis' 字段始终存在且结构正确 + if not prediction_result.get('analysis'): + logger.warning(f"模型 {model_uid} 的预测结果中缺少分析数据,将使用默认空对象。") + prediction_result['analysis'] = { + 'description': '未能生成有效的趋势分析。', + 'metrics': {}, + 'peaks': [], + 'history_chart_data': {'dates': [], 'changes': []} + } + # 遵循用户规范,使用相对路径生成文件名 model_display_name = model_record.get('display_name', 'unknown_model') safe_model_name = secure_filename(model_display_name).replace(' ', '_').replace('-_', '_') @@ -1733,12 +1751,25 @@ def get_prediction_history(): query_params = [] if product_id: - query_conditions.append("json_extract(prediction_scope, '$.product_id') = ?") - query_params.append(product_id) + # v12 修复:支持混合类型的筛选 + # 判断传入的ID类型:产品ID(数字), 店铺ID(S开头), 或特殊名称(其他字符串) + if product_id.isdigit(): + # 按产品ID筛选 + query_conditions.append("prediction_scope LIKE ?") + query_params.append(f'%"product_id": "{product_id}"%') + elif product_id.startswith('S') and product_id[1:].isdigit(): + # 按店铺ID筛选 + query_conditions.append("prediction_scope LIKE ?") + query_params.append(f'%"store_id": "{product_id}"%') + else: + # 按特殊名称筛选 (如 全局预测) + query_conditions.append("product_name = ?") + query_params.append(product_id) if model_type: - query_conditions.append("model_type = ?") - query_params.append(model_type) + # v10 修复: 使用LIKE以兼容(best)版本 + query_conditions.append("model_type LIKE ?") + query_params.append(f"{model_type}%") # 构建完整的查询语句 query = "SELECT * FROM prediction_history" @@ -1846,7 +1877,6 @@ def get_prediction_details(prediction_id): if not full_path or not os.path.exists(full_path): logger.error(f"预测结果文件不存在或路径无效。相对路径: '{relative_file_path}', 检查的绝对路径: '{full_path}'") - print(f"DEBUG: JSON文件路径检查失败。相对路径: '{relative_file_path}', 检查的绝对路径: '{full_path}'") # 添加调试打印 # 即使文件不存在,也尝试返回基本信息,避免前端崩溃 final_payload = { 'product_name': record['product_name'] if 'product_name' in record_keys else 'N/A', @@ -1860,7 +1890,6 @@ def get_prediction_details(prediction_id): return jsonify({"status": "success", "data": final_payload}) # 读取和解析JSON文件 - print(f"DEBUG: 正在尝试读取JSON文件,绝对路径: '{full_path}'") # 添加调试打印 with open(full_path, 'r', encoding='utf-8') as f: saved_data = json.load(f) @@ -1871,8 +1900,6 @@ def get_prediction_details(prediction_id): history_data = core_data.get('history_data', []) prediction_data = core_data.get('prediction_data', []) - # 立即打印提取到的数据长度 - print(f"DEBUG: Extracted data lengths - History: {len(history_data)}, Prediction: {len(prediction_data)}") # 构建最终的、完整的响应数据 final_payload = { @@ -1884,14 +1911,27 @@ def get_prediction_details(prediction_id): 'prediction_data': prediction_data, 'analysis': core_data.get('analysis', {}), } + + # 动态修复:如果从文件中加载的分析数据为空,则实时生成 + if not final_payload.get('analysis'): + logger.info(f"记录 {prediction_id} 的分析数据为空,正在尝试动态生成...") + # 构建调用 analyze_prediction 所需的输入 + dynamic_analysis_input = { + "history_data": final_payload.get('history_data', []), + "prediction_data": final_payload.get('prediction_data', []) + } + dynamic_analysis_result = analyze_prediction(dynamic_analysis_input) + if dynamic_analysis_result: + final_payload['analysis'] = dynamic_analysis_result + logger.info(f"成功为记录 {prediction_id} 动态生成分析数据。") + else: + logger.warning(f"为记录 {prediction_id} 动态生成分析数据失败。") response_data = { "status": "success", "data": final_payload } - # 在返回前,完整打印最终的响应数据 - print(f"DEBUG: Final response payload being sent to frontend: {json.dumps(response_data, cls=CustomJSONEncoder, ensure_ascii=False)}") logger.info(f"成功构建并返回历史预测详情 (v8): ID={prediction_id}") return jsonify(response_data) @@ -1909,14 +1949,15 @@ def delete_prediction(prediction_id): cursor = conn.cursor() # 查询预测记录 - cursor.execute("SELECT file_path FROM prediction_history WHERE id = ?", (prediction_id,)) + # v15 修复: 修正列名,并使用绝对路径删除文件 + cursor.execute("SELECT result_file_path FROM prediction_history WHERE id = ?", (prediction_id,)) record = cursor.fetchone() if not record: conn.close() return jsonify({"status": "error", "message": "预测记录不存在"}), 404 - file_path = record[0] + relative_file_path = record[0] # 删除数据库记录 cursor.execute("DELETE FROM prediction_history WHERE id = ?", (prediction_id,)) @@ -1924,8 +1965,19 @@ def delete_prediction(prediction_id): conn.close() # 删除预测结果文件 - if os.path.exists(file_path): - os.remove(file_path) + if relative_file_path: + # 构建绝对路径以确保文件能被正确找到和删除 + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + absolute_file_path = os.path.join(project_root, relative_file_path) + + if os.path.exists(absolute_file_path): + try: + os.remove(absolute_file_path) + logger.info(f"成功删除文件: {absolute_file_path}") + except OSError as e: + logger.error(f"删除文件失败: {absolute_file_path}, 错误: {e}") + else: + logger.warning(f"尝试删除但文件未找到: {absolute_file_path}") return jsonify({ "status": "success", @@ -1937,6 +1989,38 @@ def delete_prediction(prediction_id): traceback.print_exc() return jsonify({"status": "error", "message": str(e)}), 500 +@app.route('/api/history/filter-options', methods=['GET']) +def get_history_filter_options(): + """获取历史记录页面用于筛选的选项列表""" + try: + # 1. 获取所有标准产品 + products = get_available_products() + + # 2. 获取所有店铺 + stores = get_available_stores() + + # 3. 从历史记录中获取特殊的预测名称(如全局预测) + options_map = {} + + # 添加产品 + for p in products: + options_map[p['product_id']] = {'value': p['product_id'], 'label': p['product_name'], 'type': 'product'} + + # 添加店铺 + for s in stores: + options_map[s['store_id']] = {'value': s['store_id'], 'label': s['store_name'], 'type': 'store'} + + # v14 修复: 硬编码添加“全局预测”选项 + global_prediction_key = "全局预测" + if global_prediction_key not in options_map: + options_map[global_prediction_key] = {'value': global_prediction_key, 'label': global_prediction_key, 'type': 'special'} + + return jsonify({"status": "success", "data": list(options_map.values())}) + + except Exception as e: + logger.error(f"获取历史筛选选项失败: {e}\n{traceback.format_exc()}") + return jsonify({"status": "error", "message": str(e)}), 500 + # 4. 模型管理API @app.route('/api/models', methods=['GET']) @swag_from({