起点: 最初的系统功能基本,但代码存在重复,缺乏统一标准。我们从修复一个 xgboost_trainer.py 中的 NameError 开始。 核心工作: 统一工件保存: 将所有模型训练脚本 (xgboost, kan, tcn 等) 中保存模型和图表的逻辑进行了重构,提取到 server/utils/visualization.py 和 server/utils/model_manager.py 中,确保了所有训练器都遵循统一、健壮的模式。 修复绘图Bug: 修正了 visualization.py 中损失曲线图文件名生成不正确的bug,并更新了所有训练脚本以适应新的、更通用的 plot_loss_curve 函数。 第二阶段:数据库与API的现代化改造 问题: 原有的数据库设计过于简单,依赖文件名或复合键来识别模型,非常脆弱且难以扩展。 核心工作: 数据库重新设计: 在 server/api.py 中,我对数据库进行了彻底的重新设计。 废弃了旧的 model_versions 表,引入了新的 models 表,为每个模型实例分配一个唯一的 model_uid。此表现在存储了模型的全方位元数据,如类型、训练范围、参数、性能指标和工件路径。 重构了 prediction_history 表,使其通过 model_uid 与 models 表关联,并使用灵活的 JSON 字段(如 prediction_scope)来存储预测范围,以优雅地支持“按产品”、“按店铺”和“全局”等不同模式。 API大规模重构: 数据库的变更引发了对后端API的全面重构。 /api/models: 完全重写,以从新的 models 表中查询数据。 /api/prediction: 接口被简化,现在只接受一个核心参数 model_uid,而不是之前的一系列零散参数。 /api/prediction/history: 同样被重写以适应新的表结构。 第三阶段:前后端联调与Bug修复周期 问题: 后端的重大重构导致了前端功能失效,暴露出前后端接口定义不匹配的问题。 核心工作: 修复模型列表显示: 解决了因后端 /api/models 返回的JSON键与前端期望(product_name, store_name)不符,而导致的“药品名称”和“店铺名称”列为空的问题。 修复预测功能: 解决了因前端向 /api/prediction 发送旧格式的请求体而导致的“预测失败”问题。我修改了所有三个预测视图(ProductPredictionView.vue, StorePredictionView.vue, GlobalPredictionView.vue),使其发送后端现在需要的 model_uid。 修复UI视觉问题: 修复了预测按钮上的加载动画(spinner)失效的问题。原因是前端脚本逻辑中的键已更新为 model_uid,但模板中的 :loading 绑定仍错误地指向了旧的 model_id。 第四阶段:历史记录页面的最终完善 问题: 历史记录页面存在多个深层bug,包括列表为空、数据显示不正确以及筛选功能失效。 核心工作: 修复空列表: 解决了因后端 get_prediction_history 函数查询了不存在的数据库列而导致历史记录为空的问题。修复方案是改用 json_extract 函数来正确查询嵌套在JSON字段中的 product_id。 修正产品名称显示: 解决了列表中产品名称显示为内部标识符的bug。根据您的要求,我在不修改数据库记录的前提下,通过在后端读取数据时动态查询产品名并修正返回给前端的数据,解决了这个问题。 实现动态筛选下拉框: 解决了产品筛选下拉框为空的问题。根据您的最终指示,我修改了 HistoryView.vue,使其从返回的历史记录数据中动态提取唯一的产品列表来填充下拉框,移除了原有的独立API调用,使筛选功能更加智能和高效。
361 lines
11 KiB
Vue
361 lines
11 KiB
Vue
<template>
|
||
<div class="prediction-view">
|
||
<el-card>
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>按店铺预测</span>
|
||
<el-tooltip content="对系统中的所有店铺模型进行批量或单个预测">
|
||
<el-icon><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="controls-section">
|
||
<el-form :model="filters" label-width="80px" inline>
|
||
<el-form-item label="目标店铺">
|
||
<StoreSelector
|
||
v-model="filters.store_id"
|
||
:show-all-option="true"
|
||
all-option-label="所有店铺"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="算法类型">
|
||
<el-select v-model="filters.model_type" placeholder="所有类型" clearable value-key="id" style="width: 200px;">
|
||
<el-option
|
||
v-for="item in modelTypes"
|
||
:key="item.id"
|
||
:label="item.name"
|
||
:value="item"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="预测天数">
|
||
<el-input-number v-model="form.future_days" :min="1" :max="365" />
|
||
</el-form-item>
|
||
<el-form-item label="历史天数">
|
||
<el-input-number v-model="form.history_lookback_days" :min="7" :max="365" />
|
||
</el-form-item>
|
||
<el-form-item label="起始日期">
|
||
<el-date-picker
|
||
v-model="form.start_date"
|
||
type="date"
|
||
placeholder="选择日期"
|
||
format="YYYY-MM-DD"
|
||
value-format="YYYY-MM-DD"
|
||
:clearable="false"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
|
||
<!-- 模型列表 -->
|
||
<div class="model-list-section">
|
||
<h4>📦 可用店铺模型列表</h4>
|
||
<el-table :data="paginatedModelList" style="width: 100%" v-loading="modelsLoading">
|
||
<el-table-column prop="store_name" label="店铺名称" sortable />
|
||
<el-table-column prop="model_type" label="模型类型" sortable />
|
||
<el-table-column prop="version" label="版本" />
|
||
<el-table-column prop="created_at" label="创建时间" />
|
||
<el-table-column label="操作">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
@click="startPrediction(row)"
|
||
:loading="predicting[row.model_uid]"
|
||
>
|
||
<el-icon><TrendCharts /></el-icon>
|
||
开始预测
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-pagination
|
||
background
|
||
layout="prev, pager, next"
|
||
:total="filteredModelList.length"
|
||
:page-size="pagination.pageSize"
|
||
@current-change="handlePageChange"
|
||
style="margin-top: 20px; justify-content: center;"
|
||
/>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 预测结果弹窗 -->
|
||
<el-dialog v-model="dialogVisible" title="📈 预测结果" width="70%">
|
||
<div class="prediction-chart">
|
||
<canvas ref="chartCanvas" width="800" height="400"></canvas>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
||
import axios from 'axios'
|
||
import { ElMessage, ElDialog, ElTable, ElTableColumn, ElButton, ElIcon, ElCard, ElTooltip, ElForm, ElFormItem, ElInputNumber, ElDatePicker, ElSelect, ElOption, ElPagination } from 'element-plus'
|
||
import { QuestionFilled, TrendCharts } from '@element-plus/icons-vue'
|
||
import Chart from 'chart.js/auto'
|
||
import StoreSelector from '../../components/StoreSelector.vue'
|
||
|
||
const modelList = ref([])
|
||
const modelTypes = ref([])
|
||
const modelsLoading = ref(false)
|
||
const predicting = reactive({})
|
||
const stores = ref([])
|
||
const dialogVisible = ref(false)
|
||
const predictionResult = ref(null)
|
||
const chartCanvas = ref(null)
|
||
let chart = null
|
||
|
||
const form = reactive({
|
||
future_days: 7,
|
||
history_lookback_days: 30,
|
||
start_date: '',
|
||
analyze_result: true // 保持分析功能开启,但UI上移除开关
|
||
})
|
||
|
||
const filters = reactive({
|
||
store_id: '',
|
||
model_type: null
|
||
})
|
||
|
||
const pagination = reactive({
|
||
currentPage: 1,
|
||
pageSize: 8
|
||
})
|
||
|
||
const filteredModelList = computed(() => {
|
||
return modelList.value.filter(model => {
|
||
// 简化逻辑:直接使用后端返回的数据进行筛选
|
||
const storeMatch = !filters.store_id || (model.training_scope?.store?.id === filters.store_id);
|
||
const modelTypeMatch = !filters.model_type || model.model_type === filters.model_type.id;
|
||
return storeMatch && modelTypeMatch;
|
||
});
|
||
});
|
||
|
||
const paginatedModelList = computed(() => {
|
||
const start = (pagination.currentPage - 1) * pagination.pageSize;
|
||
const end = start + pagination.pageSize;
|
||
return filteredModelList.value.slice(start, end);
|
||
});
|
||
|
||
const handlePageChange = (page) => {
|
||
pagination.currentPage = page
|
||
}
|
||
|
||
const fetchModelTypes = async () => {
|
||
try {
|
||
const response = await axios.get('/api/model_types')
|
||
if (response.data.status === 'success') {
|
||
modelTypes.value = response.data.data
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('获取模型类型失败')
|
||
}
|
||
}
|
||
|
||
const fetchModels = async () => {
|
||
modelsLoading.value = true
|
||
try {
|
||
const response = await axios.get('/api/models', { params: { training_mode: 'store' } })
|
||
if (response.data.status === 'success') {
|
||
modelList.value = response.data.data
|
||
} else {
|
||
ElMessage.error('获取模型列表失败')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('获取模型列表失败')
|
||
} finally {
|
||
modelsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const fetchStores = async () => {
|
||
try {
|
||
const response = await axios.get('/api/stores')
|
||
if (response.data.status === 'success') {
|
||
stores.value = response.data.data
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('获取店铺列表失败')
|
||
}
|
||
}
|
||
|
||
const startPrediction = async (model) => {
|
||
predicting[model.model_uid] = true; // 使用 model_uid 作为唯一的键
|
||
try {
|
||
const payload = {
|
||
model_uid: model.model_uid, // 关键修复:使用 model_uid
|
||
future_days: form.future_days,
|
||
history_lookback_days: form.history_lookback_days,
|
||
start_date: form.start_date,
|
||
include_visualization: true,
|
||
};
|
||
const response = await axios.post('/api/prediction', payload);
|
||
if (response.data.status === 'success') {
|
||
predictionResult.value = response.data.data;
|
||
ElMessage.success('预测完成!');
|
||
dialogVisible.value = true;
|
||
await nextTick();
|
||
renderChart();
|
||
} else {
|
||
ElMessage.error(response.data.error || '预测失败');
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error.response?.data?.error || '预测请求失败');
|
||
} finally {
|
||
predicting[model.model_uid] = false; // 保持键的一致性
|
||
}
|
||
};
|
||
|
||
const renderChart = () => {
|
||
if (!chartCanvas.value || !predictionResult.value) return
|
||
if (chart) {
|
||
chart.destroy()
|
||
}
|
||
|
||
const formatDate = (date) => new Date(date).toISOString().split('T')[0];
|
||
|
||
const historyData = (predictionResult.value.history_data || []).map(p => ({ ...p, date: formatDate(p.date) }));
|
||
const predictionData = (predictionResult.value.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('-').slice(1).join('/'));
|
||
|
||
const historyMap = new Map(historyData.map(p => [p.date, p.sales]))
|
||
const predictionMap = new Map(predictionData.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) {
|
||
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}`;
|
||
}
|
||
|
||
chart = new Chart(chartCanvas.value, {
|
||
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],
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: `${predictionResult.value.store_name} - 销量预测趋势图`,
|
||
color: '#303133',
|
||
font: {
|
||
size: 20,
|
||
weight: 'bold',
|
||
}
|
||
},
|
||
subtitle: {
|
||
display: true,
|
||
text: subtitleText,
|
||
color: '#606266',
|
||
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
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchStores()
|
||
fetchModels()
|
||
fetchModelTypes()
|
||
const today = new Date()
|
||
form.start_date = today.toISOString().split('T')[0]
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.prediction-view {
|
||
padding: 20px;
|
||
}
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.controls-section, .model-list-section {
|
||
margin-top: 20px;
|
||
}
|
||
.model-list-section h4 {
|
||
margin-bottom: 16px;
|
||
}
|
||
.prediction-chart {
|
||
margin-top: 20px;
|
||
}
|
||
</style> |