完善历史预测查看详情,删除功能
This commit is contained in:
parent
290e402181
commit
88f245b957
@ -14,7 +14,7 @@
|
|||||||
<el-form :model="filters" inline class="filter-form">
|
<el-form :model="filters" inline class="filter-form">
|
||||||
<el-form-item label="产品">
|
<el-form-item label="产品">
|
||||||
<el-select v-model="filters.product_id" placeholder="筛选产品" filterable clearable @change="fetchHistory">
|
<el-select v-model="filters.product_id" placeholder="筛选产品" filterable clearable @change="fetchHistory">
|
||||||
<el-option v-for="item in products" :key="item.product_id" :label="item.product_name" :value="item.product_id" />
|
<el-option v-for="item in productFilterOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模型类型">
|
<el-form-item label="模型类型">
|
||||||
@ -82,18 +82,18 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
<!-- 详情弹窗 -->
|
||||||
<el-dialog v-model="detailsVisible" title="预测详情" fullscreen destroy-on-close>
|
<el-dialog v-model="detailsVisible" title="预测详情" fullscreen destroy-on-close @close="destroyCharts">
|
||||||
<div v-if="currentPrediction" class="prediction-dialog-content">
|
<div v-if="currentPrediction" class="prediction-dialog-content">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<div class="prediction-summary">
|
<div class="prediction-summary">
|
||||||
<el-descriptions :column="4" border>
|
<el-descriptions :column="4" border>
|
||||||
<el-descriptions-item label="产品名称">{{ currentPrediction.meta?.product_name || currentPrediction.product_name || getProductName(currentPrediction.meta?.product_id) || '未知产品' }}</el-descriptions-item>
|
<el-descriptions-item label="产品名称">{{ currentPrediction.product_name || '未知产品' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="模型类型">
|
<el-descriptions-item label="模型类型">
|
||||||
<el-tag :type="getModelTagType(currentPrediction.meta?.model_type || currentPrediction.model_type)">{{ currentPrediction.meta?.model_type || currentPrediction.model_type || '未知模型' }}</el-tag>
|
<el-tag :type="getModelTagType(currentPrediction.model_type)">{{ currentPrediction.model_type || '未知模型' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="预测起始日">{{ currentPrediction.meta?.start_date || currentPrediction.start_date || (currentPrediction.data?.prediction_data?.[0]?.date) || 'N/A' }}</el-descriptions-item>
|
<el-descriptions-item label="预测起始日">{{ currentPrediction.start_date || 'N/A' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="预测时间">{{ formatDateTime(currentPrediction.meta?.created_at || currentPrediction.created_at) }}</el-descriptions-item>
|
<el-descriptions-item label="预测时间">{{ formatDateTime(currentPrediction.created_at) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -107,7 +107,7 @@
|
|||||||
<div class="prediction-chart-container">
|
<div class="prediction-chart-container">
|
||||||
<h3>预测趋势图</h3>
|
<h3>预测趋势图</h3>
|
||||||
<el-card shadow="hover" body-style="padding: 0;">
|
<el-card shadow="hover" body-style="padding: 0;">
|
||||||
<div id="fullscreen-prediction-chart-history" style="width: 100%; height: 500px;"></div>
|
<canvas ref="predictionChartCanvas" style="width: 100%; height: 500px;"></canvas>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -123,29 +123,29 @@
|
|||||||
<div class="stat-cards">
|
<div class="stat-cards">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<template #header><div class="stat-header"><span>平均预测销量</span></div></template>
|
<template #header><div class="stat-header"><span>平均预测销量</span></div></template>
|
||||||
<div class="stat-value">{{ currentPrediction?.data?.prediction_data ? calculateAverage(currentPrediction.data.prediction_data).toFixed(2) : '暂无数据' }}</div>
|
<div class="stat-value">{{ currentPrediction?.prediction_data ? calculateAverage(currentPrediction.prediction_data).toFixed(2) : '暂无数据' }}</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<template #header><div class="stat-header"><span>最高预测销量</span></div></template>
|
<template #header><div class="stat-header"><span>最高预测销量</span></div></template>
|
||||||
<div class="stat-value">{{ currentPrediction?.data?.prediction_data ? calculateMax(currentPrediction.data.prediction_data).toFixed(2) : '暂无数据' }}</div>
|
<div class="stat-value">{{ currentPrediction?.prediction_data ? calculateMax(currentPrediction.prediction_data).toFixed(2) : '暂无数据' }}</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<template #header><div class="stat-header"><span>最低预测销量</span></div></template>
|
<template #header><div class="stat-header"><span>最低预测销量</span></div></template>
|
||||||
<div class="stat-value">{{ currentPrediction?.data?.prediction_data ? calculateMin(currentPrediction.data.prediction_data).toFixed(2) : '暂无数据' }}</div>
|
<div class="stat-value">{{ currentPrediction?.prediction_data ? calculateMin(currentPrediction.prediction_data).toFixed(2) : '暂无数据' }}</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="currentPrediction?.data?.prediction_data || []" stripe height="330" border>
|
<el-table :data="currentPrediction?.prediction_data || []" stripe height="330" border>
|
||||||
<el-table-column prop="date" label="日期" width="150" :formatter="row => formatDate(row.date)" />
|
<el-table-column prop="date" label="日期" width="150" :formatter="row => formatDate(row.date)" />
|
||||||
<el-table-column prop="predicted_sales" label="预测销量" sortable>
|
<el-table-column prop="predicted_sales" label="预测销量" sortable>
|
||||||
<template #default="{ row }">{{ row.predicted_sales ? row.predicted_sales.toFixed(2) : '0.00' }}</template>
|
<template #default="{ row }">{{ row.predicted_sales ? row.predicted_sales.toFixed(2) : '0.00' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="趋势">
|
<el-table-column label="趋势">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<div v-if="$index > 0 && currentPrediction?.data?.prediction_data">
|
<div v-if="$index > 0 && currentPrediction?.prediction_data">
|
||||||
<el-icon v-if="row.predicted_sales > currentPrediction.data.prediction_data[$index-1].predicted_sales" color="#67C23A"><ArrowUp /></el-icon>
|
<el-icon v-if="row.predicted_sales > currentPrediction.prediction_data[$index-1].predicted_sales" color="#67C23A"><ArrowUp /></el-icon>
|
||||||
<el-icon v-else-if="row.predicted_sales < currentPrediction.data.prediction_data[$index-1].predicted_sales" color="#F56C6C"><ArrowDown /></el-icon>
|
<el-icon v-else-if="row.predicted_sales < currentPrediction.prediction_data[$index-1].predicted_sales" color="#F56C6C"><ArrowDown /></el-icon>
|
||||||
<el-icon v-else color="#909399"><Minus /></el-icon>
|
<el-icon v-else color="#909399"><Minus /></el-icon>
|
||||||
{{ Math.abs(row.predicted_sales - currentPrediction.data.prediction_data[$index-1].predicted_sales).toFixed(2) }}
|
{{ Math.abs(row.predicted_sales - currentPrediction.prediction_data[$index-1].predicted_sales).toFixed(2) }}
|
||||||
</div>
|
</div>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
@ -153,7 +153,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="历史数据">
|
<el-tab-pane label="历史数据">
|
||||||
<el-table :data="currentPrediction?.data?.history_data || []" stripe height="400" border>
|
<el-table :data="currentPrediction?.history_data || []" stripe height="400" border>
|
||||||
<el-table-column prop="date" label="日期" width="150" :formatter="row => formatDate(row.date)" />
|
<el-table-column prop="date" label="日期" width="150" :formatter="row => formatDate(row.date)" />
|
||||||
<el-table-column prop="sales" label="历史销量" sortable>
|
<el-table-column prop="sales" label="历史销量" sortable>
|
||||||
<template #default="{ row }">{{ row.sales ? row.sales.toFixed(2) : '0.00' }}</template>
|
<template #default="{ row }">{{ row.sales ? row.sales.toFixed(2) : '0.00' }}</template>
|
||||||
@ -161,7 +161,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="全部数据">
|
<el-tab-pane label="全部数据">
|
||||||
<el-table :data="currentPrediction?.data?.data || []" stripe height="400" border>
|
<el-table :data="[...(currentPrediction?.history_data || []).map(d => ({...d, data_type: '历史销量'})), ...(currentPrediction?.prediction_data || []).map(d => ({...d, sales: d.predicted_sales, data_type: '预测销量'}))]" stripe height="400" border>
|
||||||
<el-table-column prop="date" label="日期" width="150" :formatter="row => formatDate(row.date)" />
|
<el-table-column prop="date" label="日期" width="150" :formatter="row => formatDate(row.date)" />
|
||||||
<el-table-column prop="sales" label="销量" sortable>
|
<el-table-column prop="sales" label="销量" sortable>
|
||||||
<template #default="{ row }">{{ row.sales ? row.sales.toFixed(2) : '0.00' }}</template>
|
<template #default="{ row }">{{ row.sales ? row.sales.toFixed(2) : '0.00' }}</template>
|
||||||
@ -212,7 +212,7 @@
|
|||||||
<el-col :span="24">
|
<el-col :span="24">
|
||||||
<el-card shadow="hover" style="margin-bottom: 20px;">
|
<el-card shadow="hover" style="margin-bottom: 20px;">
|
||||||
<template #header><div class="analysis-header"><span>日环比变化</span></div></template>
|
<template #header><div class="analysis-header"><span>日环比变化</span></div></template>
|
||||||
<div id="fullscreen-history-chart-history" style="width: 100%; height: 350px;"></div>
|
<canvas ref="analysisChartCanvas" style="width: 100%; height: 350px;"></canvas>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@ -254,15 +254,17 @@ import { computed, onUnmounted } from 'vue';
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const history = ref([]);
|
const history = ref([]);
|
||||||
const products = ref([]);
|
const productFilterOptions = ref([]);
|
||||||
const modelTypes = ref([]);
|
const modelTypes = ref([]);
|
||||||
const detailsVisible = ref(false);
|
const detailsVisible = ref(false);
|
||||||
const currentPrediction = ref(null);
|
const currentPrediction = ref(null);
|
||||||
const rawResponseData = ref(null);
|
const rawResponseData = ref(null);
|
||||||
const showRawDataFlag = ref(false);
|
const showRawDataFlag = ref(false);
|
||||||
|
const predictionChartCanvas = ref(null);
|
||||||
|
const analysisChartCanvas = ref(null);
|
||||||
|
|
||||||
let predictionChart = null; // << 关键改动:使用单个chart实例
|
let predictionChartInstance = null;
|
||||||
let historyChart = null;
|
let analysisChartInstance = null;
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
product_id: '',
|
product_id: '',
|
||||||
@ -287,14 +289,14 @@ const fetchModelTypes = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchFilterOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/products');
|
const response = await axios.get('/api/history/filter-options');
|
||||||
if (response.data.status === 'success') {
|
if (response.data.status === 'success') {
|
||||||
products.value = response.data.data;
|
productFilterOptions.value = response.data.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('获取产品列表失败');
|
ElMessage.error('获取筛选选项列表失败');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -311,7 +313,6 @@ const fetchHistory = async () => {
|
|||||||
if (response.data.status === 'success') {
|
if (response.data.status === 'success') {
|
||||||
history.value = response.data.data;
|
history.value = response.data.data;
|
||||||
pagination.total = response.data.total;
|
pagination.total = response.data.total;
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('获取历史记录失败');
|
ElMessage.error('获取历史记录失败');
|
||||||
@ -333,8 +334,8 @@ const handleCurrentChange = (page) => {
|
|||||||
// 添加getProductName函数
|
// 添加getProductName函数
|
||||||
const getProductName = (productId) => {
|
const getProductName = (productId) => {
|
||||||
if (!productId) return '未知产品';
|
if (!productId) return '未知产品';
|
||||||
const product = products.value.find(p => p.product_id === productId);
|
const option = productFilterOptions.value.find(p => p.value === productId);
|
||||||
return product ? product.product_name : '未知产品';
|
return option ? option.label : '未知产品';
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewDetails = async (id) => {
|
const viewDetails = async (id) => {
|
||||||
@ -345,10 +346,18 @@ const viewDetails = async (id) => {
|
|||||||
|
|
||||||
if (responseData && responseData.status === 'success' && responseData.data) {
|
if (responseData && responseData.status === 'success' && responseData.data) {
|
||||||
// 简化逻辑:直接信任后端返回的、结构正确的 responseData.data
|
// 简化逻辑:直接信任后端返回的、结构正确的 responseData.data
|
||||||
|
// 关键修复:将后端返回的扁平化数据结构正确赋值
|
||||||
|
// 后端返回的 data 字段直接包含了所有需要的信息
|
||||||
currentPrediction.value = responseData.data;
|
currentPrediction.value = responseData.data;
|
||||||
detailsVisible.value = true;
|
detailsVisible.value = true;
|
||||||
|
|
||||||
console.log('接收到并准备渲染的预测详情数据:', currentPrediction.value);
|
console.log('接收到并准备渲染的预测详情数据:', currentPrediction.value);
|
||||||
|
|
||||||
|
// 最终修复:在数据加载和弹窗可见后,通过nextTick确保DOM渲染完毕再初始化图表
|
||||||
|
nextTick(() => {
|
||||||
|
renderPredictionChart(currentPrediction.value);
|
||||||
|
renderHistoryAnalysisChart(currentPrediction.value);
|
||||||
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('获取详情失败: ' + (responseData?.message || responseData?.error || '数据格式错误'));
|
ElMessage.error('获取详情失败: ' + (responseData?.message || responseData?.error || '数据格式错误'));
|
||||||
@ -903,155 +912,116 @@ const getFactorsArray = computed(() => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(detailsVisible, (newVal) => {
|
const renderPredictionChart = (predictionData) => {
|
||||||
if (newVal && currentPrediction.value) {
|
const canvasEl = predictionChartCanvas.value;
|
||||||
nextTick(() => {
|
if (!canvasEl || !predictionData) return;
|
||||||
renderChart();
|
|
||||||
// 可以在这里添加渲染第二个图表的逻辑
|
|
||||||
// renderHistoryAnalysisChart();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// << 关键改动:从ProductPredictionView.vue复制并适应的renderChart函数
|
if (predictionChartInstance) {
|
||||||
const renderChart = () => {
|
predictionChartInstance.destroy();
|
||||||
const chartCanvas = document.getElementById('fullscreen-prediction-chart-history');
|
|
||||||
if (!chartCanvas || !currentPrediction.value || !currentPrediction.value.data) return;
|
|
||||||
|
|
||||||
if (predictionChart) {
|
|
||||||
predictionChart.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => new Date(date).toISOString().split('T')[0];
|
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) }));
|
if (historyData.length === 0 && predData.length === 0) {
|
||||||
const predictionData = (currentPrediction.value.data.prediction_data || []).map(p => ({ ...p, date: formatDate(p.date) }));
|
// Handle no data case if needed
|
||||||
|
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allLabels = [...new Set([...historyData.map(p => p.date), ...predictionData.map(p => p.date)])].sort();
|
const allLabels = [...new Set([...historyData.map(p => p.date), ...predData.map(p => p.date)])].sort();
|
||||||
const simplifiedLabels = allLabels.map(date => date.split('-')[2]);
|
const simplifiedLabels = allLabels.map(date => date.split('-').slice(1).join('/'));
|
||||||
|
|
||||||
const historyMap = new Map(historyData.map(p => [p.date, p.sales]));
|
const historyMap = new Map(historyData.map(p => [p.date, p.sales]));
|
||||||
// 修正:预测数据使用 'predicted_sales' 字段
|
const predictionMap = new Map(predData.map(p => [p.date, p.predicted_sales]));
|
||||||
const predictionMap = new Map(predictionData.map(p => [p.date, p.predicted_sales]));
|
|
||||||
|
|
||||||
const alignedHistorySales = allLabels.map(label => historyMap.get(label) ?? null);
|
const alignedHistorySales = allLabels.map(label => historyMap.get(label) ?? null);
|
||||||
const alignedPredictionSales = allLabels.map(label => predictionMap.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 lastHistoryDate = historyData[historyData.length - 1].date;
|
||||||
const lastHistoryValue = historyData[historyData.length - 1].sales;
|
const lastHistoryValue = historyData[historyData.length - 1].sales;
|
||||||
if (!predictionMap.has(lastHistoryDate)) {
|
const lastHistoryIndex = allLabels.indexOf(lastHistoryDate);
|
||||||
alignedPredictionSales[allLabels.indexOf(lastHistoryDate)] = lastHistoryValue;
|
if (!predictionMap.has(lastHistoryDate) && lastHistoryIndex !== -1) {
|
||||||
|
alignedPredictionSales[lastHistoryIndex] = lastHistoryValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let subtitleText = '';
|
let subtitleText = '';
|
||||||
if (historyData.length > 0) {
|
if (historyData.length > 0) subtitleText += `历史: ${historyData[0].date} ~ ${historyData[historyData.length - 1].date}`;
|
||||||
subtitleText += `历史数据: ${historyData[0].date} ~ ${historyData[historyData.length - 1].date}`;
|
if (predData.length > 0) {
|
||||||
}
|
if (subtitleText) subtitleText += ' | ';
|
||||||
if (predictionData.length > 0) {
|
subtitleText += `预测: ${predData[0].date} ~ ${predData[predData.length - 1].date}`;
|
||||||
if (subtitleText) subtitleText += ' | ';
|
|
||||||
subtitleText += `预测数据: ${predictionData[0].date} ~ ${predictionData[predictionData.length - 1].date}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
predictionChart = new Chart(chartCanvas, {
|
predictionChartInstance = new Chart(canvasEl, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: simplifiedLabels,
|
labels: simplifiedLabels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{ label: '历史销量', data: alignedHistorySales, borderColor: '#67C23A', backgroundColor: 'rgba(103, 194, 58, 0.2)', tension: 0.4, fill: true, spanGaps: false },
|
||||||
label: '历史销量',
|
{ label: '预测销量', data: alignedPredictionSales, borderColor: '#409EFF', backgroundColor: 'rgba(64, 158, 255, 0.2)', tension: 0.4, fill: true, borderDash: [5, 5] }
|
||||||
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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: { display: true, text: `${predictionData.product_name} - 销量预测趋势图`, color: '#ffffff', font: { size: 20, weight: 'bold' } },
|
||||||
display: true,
|
subtitle: { display: true, text: subtitleText, color: '#6c757d', font: { size: 14 }, padding: { bottom: 20 } }
|
||||||
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: {
|
scales: {
|
||||||
x: {
|
x: { title: { display: true, text: '日期' }, grid: { display: false } },
|
||||||
title: {
|
y: { title: { display: true, text: '销量' }, grid: { color: '#e9e9e9', drawBorder: false }, beginAtZero: true }
|
||||||
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 = () => {
|
const exportHistoryData = () => {
|
||||||
if (!currentPrediction.value) return;
|
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";
|
let csvContent = "日期,销量,数据类型\n";
|
||||||
data.forEach(row => {
|
allData.forEach(row => {
|
||||||
csvContent += `${new Date(row.date).toLocaleDateString()},${row.sales.toFixed(2)},${row.data_type}\n`;
|
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 blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
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`);
|
link.setAttribute('download', `历史预测_${product_name}_${model_type}_${start_date}.csv`);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@ -1060,28 +1030,34 @@ const exportHistoryData = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resizeCharts = () => {
|
const resizeCharts = () => {
|
||||||
if (predictionChart) {
|
if (predictionChartInstance) {
|
||||||
predictionChart.resize();
|
predictionChartInstance.resize();
|
||||||
}
|
}
|
||||||
if (historyChart) {
|
if (analysisChartInstance) {
|
||||||
historyChart.resize();
|
analysisChartInstance.resize();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', resizeCharts);
|
window.addEventListener('resize', resizeCharts);
|
||||||
|
|
||||||
|
const destroyCharts = () => {
|
||||||
|
if (predictionChartInstance) {
|
||||||
|
predictionChartInstance.destroy();
|
||||||
|
predictionChartInstance = null;
|
||||||
|
}
|
||||||
|
if (analysisChartInstance) {
|
||||||
|
analysisChartInstance.destroy();
|
||||||
|
analysisChartInstance = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', resizeCharts);
|
window.removeEventListener('resize', resizeCharts);
|
||||||
if (predictionChart) {
|
destroyCharts(); // 在组件卸载时也调用
|
||||||
predictionChart.destroy();
|
|
||||||
}
|
|
||||||
if (historyChart) {
|
|
||||||
historyChart.destroy();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProducts();
|
fetchFilterOptions();
|
||||||
fetchModelTypes();
|
fetchModelTypes();
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
});
|
});
|
||||||
|
Binary file not shown.
112
server/api.py
112
server/api.py
@ -1398,6 +1398,24 @@ def predict():
|
|||||||
if prediction_result is None:
|
if prediction_result is None:
|
||||||
return jsonify({"status": "error", "message": "预测失败,核心预测器返回None"}), 500
|
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')
|
model_display_name = model_record.get('display_name', 'unknown_model')
|
||||||
safe_model_name = secure_filename(model_display_name).replace(' ', '_').replace('-_', '_')
|
safe_model_name = secure_filename(model_display_name).replace(' ', '_').replace('-_', '_')
|
||||||
@ -1733,12 +1751,25 @@ def get_prediction_history():
|
|||||||
query_params = []
|
query_params = []
|
||||||
|
|
||||||
if product_id:
|
if product_id:
|
||||||
query_conditions.append("json_extract(prediction_scope, '$.product_id') = ?")
|
# v12 修复:支持混合类型的筛选
|
||||||
query_params.append(product_id)
|
# 判断传入的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:
|
if model_type:
|
||||||
query_conditions.append("model_type = ?")
|
# v10 修复: 使用LIKE以兼容(best)版本
|
||||||
query_params.append(model_type)
|
query_conditions.append("model_type LIKE ?")
|
||||||
|
query_params.append(f"{model_type}%")
|
||||||
|
|
||||||
# 构建完整的查询语句
|
# 构建完整的查询语句
|
||||||
query = "SELECT * FROM prediction_history"
|
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):
|
if not full_path or not os.path.exists(full_path):
|
||||||
logger.error(f"预测结果文件不存在或路径无效。相对路径: '{relative_file_path}', 检查的绝对路径: '{full_path}'")
|
logger.error(f"预测结果文件不存在或路径无效。相对路径: '{relative_file_path}', 检查的绝对路径: '{full_path}'")
|
||||||
print(f"DEBUG: JSON文件路径检查失败。相对路径: '{relative_file_path}', 检查的绝对路径: '{full_path}'") # 添加调试打印
|
|
||||||
# 即使文件不存在,也尝试返回基本信息,避免前端崩溃
|
# 即使文件不存在,也尝试返回基本信息,避免前端崩溃
|
||||||
final_payload = {
|
final_payload = {
|
||||||
'product_name': record['product_name'] if 'product_name' in record_keys else 'N/A',
|
'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})
|
return jsonify({"status": "success", "data": final_payload})
|
||||||
|
|
||||||
# 读取和解析JSON文件
|
# 读取和解析JSON文件
|
||||||
print(f"DEBUG: 正在尝试读取JSON文件,绝对路径: '{full_path}'") # 添加调试打印
|
|
||||||
with open(full_path, 'r', encoding='utf-8') as f:
|
with open(full_path, 'r', encoding='utf-8') as f:
|
||||||
saved_data = json.load(f)
|
saved_data = json.load(f)
|
||||||
|
|
||||||
@ -1871,8 +1900,6 @@ def get_prediction_details(prediction_id):
|
|||||||
history_data = core_data.get('history_data', [])
|
history_data = core_data.get('history_data', [])
|
||||||
prediction_data = core_data.get('prediction_data', [])
|
prediction_data = core_data.get('prediction_data', [])
|
||||||
|
|
||||||
# 立即打印提取到的数据长度
|
|
||||||
print(f"DEBUG: Extracted data lengths - History: {len(history_data)}, Prediction: {len(prediction_data)}")
|
|
||||||
|
|
||||||
# 构建最终的、完整的响应数据
|
# 构建最终的、完整的响应数据
|
||||||
final_payload = {
|
final_payload = {
|
||||||
@ -1884,14 +1911,27 @@ def get_prediction_details(prediction_id):
|
|||||||
'prediction_data': prediction_data,
|
'prediction_data': prediction_data,
|
||||||
'analysis': core_data.get('analysis', {}),
|
'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 = {
|
response_data = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": final_payload
|
"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}")
|
logger.info(f"成功构建并返回历史预测详情 (v8): ID={prediction_id}")
|
||||||
return jsonify(response_data)
|
return jsonify(response_data)
|
||||||
|
|
||||||
@ -1909,14 +1949,15 @@ def delete_prediction(prediction_id):
|
|||||||
cursor = conn.cursor()
|
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()
|
record = cursor.fetchone()
|
||||||
|
|
||||||
if not record:
|
if not record:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"status": "error", "message": "预测记录不存在"}), 404
|
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,))
|
cursor.execute("DELETE FROM prediction_history WHERE id = ?", (prediction_id,))
|
||||||
@ -1924,8 +1965,19 @@ def delete_prediction(prediction_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# 删除预测结果文件
|
# 删除预测结果文件
|
||||||
if os.path.exists(file_path):
|
if relative_file_path:
|
||||||
os.remove(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({
|
return jsonify({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@ -1937,6 +1989,38 @@ def delete_prediction(prediction_id):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({"status": "error", "message": str(e)}), 500
|
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
|
# 4. 模型管理API
|
||||||
@app.route('/api/models', methods=['GET'])
|
@app.route('/api/models', methods=['GET'])
|
||||||
@swag_from({
|
@swag_from({
|
||||||
|
Loading…
x
Reference in New Issue
Block a user