第一阶段:初步重构与标准化

起点: 最初的系统功能基本,但代码存在重复,缺乏统一标准。我们从修复一个 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调用,使筛选功能更加智能和高效。
This commit is contained in:
xz2000 2025-07-24 18:37:58 +08:00
parent a02bc11921
commit adb5e0f2b4
7 changed files with 89 additions and 80 deletions

View File

@ -18,7 +18,7 @@
</el-select>
</el-form-item>
<el-form-item label="模型类型">
<el-select v-model="filters.model_type" placeholder="筛选模型" clearable @change="fetchHistory">
<el-select v-model="filters.model_type" placeholder="筛选模型" clearable @change="fetchHistory" style="width: 200px;">
<el-option
v-for="item in modelTypes"
:key="item.id"
@ -286,16 +286,7 @@ const fetchModelTypes = async () => {
}
};
const fetchProducts = async () => {
try {
const response = await axios.get('/api/products');
if (response.data.status === 'success') {
products.value = response.data.data;
}
} catch (error) {
ElMessage.error('获取产品列表失败');
}
};
// fetchProducts
const fetchHistory = async () => {
loading.value = true;
@ -309,6 +300,17 @@ const fetchHistory = async () => {
if (response.data.status === 'success') {
history.value = response.data.data;
pagination.total = response.data.total;
//
if (response.data.data && response.data.data.length > 0) {
const uniqueProducts = new Map();
response.data.data.forEach(record => {
if (record.product_id && record.product_name) {
uniqueProducts.set(record.product_id, record.product_name);
}
});
products.value = Array.from(uniqueProducts, ([product_id, product_name]) => ({ product_id, product_name }));
}
}
} catch (error) {
ElMessage.error('获取历史记录失败');
@ -1120,7 +1122,7 @@ onUnmounted(() => {
});
onMounted(() => {
fetchProducts();
// fetchProducts(); //
fetchModelTypes();
fetchHistory();
});

View File

@ -54,7 +54,7 @@
type="primary"
size="small"
@click="startPrediction(row)"
:loading="predicting[row.model_id]"
:loading="predicting[row.model_uid]"
>
<el-icon><TrendCharts /></el-icon>
开始预测
@ -164,33 +164,31 @@ const fetchModels = async () => {
}
const startPrediction = async (model) => {
predicting[model.model_id] = true
predicting[model.model_uid] = true; // 使 model_uid
try {
const payload = {
training_mode: 'global',
model_type: model.model_type,
version: model.version,
model_uid: model.model_uid, // 使 model_uid
future_days: form.future_days,
history_lookback_days: form.history_lookback_days,
start_date: form.start_date,
analyze_result: form.analyze_result,
}
const response = await axios.post('/api/prediction', payload)
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()
predictionResult.value = response.data.data;
ElMessage.success('预测完成!');
dialogVisible.value = true;
await nextTick();
renderChart();
} else {
ElMessage.error(response.data.error || '预测失败')
ElMessage.error(response.data.error || '预测失败');
}
} catch (error) {
ElMessage.error(error.response?.data?.error || '预测请求失败')
ElMessage.error(error.response?.data?.error || '预测请求失败');
} finally {
predicting[model.model_id] = false
predicting[model.model_uid] = false; //
}
}
};
const renderChart = () => {
if (!chartCanvas.value || !predictionResult.value) return

View File

@ -63,7 +63,7 @@
type="primary"
size="small"
@click="startPrediction(row)"
:loading="predicting[row.model_id]"
:loading="predicting[row.model_uid]"
>
<el-icon><TrendCharts /></el-icon>
开始预测
@ -174,16 +174,14 @@ const fetchModels = async () => {
}
const startPrediction = async (model) => {
predicting[model.model_id] = true
predicting[model.model_uid] = true // 使 model_uid
try {
const payload = {
product_id: model.product_id,
model_type: model.model_type,
version: model.version,
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, //
include_visualization: true,
}
const response = await axios.post('/api/prediction', payload)
if (response.data.status === 'success') {
@ -198,7 +196,7 @@ const startPrediction = async (model) => {
} catch (error) {
ElMessage.error(error.response?.data?.error || '预测请求失败')
} finally {
predicting[model.model_id] = false
predicting[model.model_uid] = false
}
}

View File

@ -63,7 +63,7 @@
type="primary"
size="small"
@click="startPrediction(row)"
:loading="predicting[row.model_id]"
:loading="predicting[row.model_uid]"
>
<el-icon><TrendCharts /></el-icon>
开始预测
@ -187,34 +187,31 @@ const fetchStores = async () => {
}
const startPrediction = async (model) => {
predicting[model.model_id] = true
predicting[model.model_uid] = true; // 使 model_uid
try {
const payload = {
training_mode: 'store',
store_id: model.store_id,
model_type: model.model_type,
version: model.version,
model_uid: model.model_uid, // 使 model_uid
future_days: form.future_days,
history_lookback_days: form.history_lookback_days,
start_date: form.start_date,
analyze_result: form.analyze_result,
}
const response = await axios.post('/api/prediction', payload)
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()
predictionResult.value = response.data.data;
ElMessage.success('预测完成!');
dialogVisible.value = true;
await nextTick();
renderChart();
} else {
ElMessage.error(response.data.error || '预测失败')
ElMessage.error(response.data.error || '预测失败');
}
} catch (error) {
ElMessage.error(error.response?.data?.error || '预测请求失败')
ElMessage.error(error.response?.data?.error || '预测请求失败');
} finally {
predicting[model.model_id] = false
predicting[model.model_uid] = false; //
}
}
};
const renderChart = () => {
if (!chartCanvas.value || !predictionResult.value) return

Binary file not shown.

View File

@ -1383,7 +1383,7 @@ def predict():
"prediction_uid": prediction_uid,
"model_id": model_uid,
"model_type": model_type,
"product_name": model_record.get('display_name'),
"product_name": prediction_result.get('product_name') or model_record.get('display_name'),
"prediction_scope": {"product_id": product_id, "store_id": store_id},
"prediction_params": {"future_days": future_days, "start_date": start_date},
"metrics": prediction_result.get('analysis', {}).get('metrics', {}),
@ -1672,13 +1672,18 @@ def get_prediction_history():
# 连接数据库
conn = get_db_connection()
cursor = conn.cursor()
# 获取产品ID到名称的映射用于修正历史数据中的产品名称
from utils.multi_store_data_utils import get_available_products
all_products = get_available_products()
product_name_map = {p['product_id']: p['product_name'] for p in all_products}
# 构建查询条件
query_conditions = []
query_params = []
if product_id:
query_conditions.append("product_id = ?")
query_conditions.append("json_extract(prediction_scope, '$.product_id') = ?")
query_params.append(product_id)
if model_type:
@ -1709,32 +1714,40 @@ 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}")
# 解析JSON字段
prediction_scope = json.loads(record['prediction_scope']) if record['prediction_scope'] else {}
prediction_params = json.loads(record['prediction_params']) if record['prediction_params'] else {}
history_records.append({
'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']
})
# 安全地获取嵌套值
product_id = prediction_scope.get('product_id', 'N/A')
start_date_str = prediction_params.get('start_date', 'N/A')
future_days = prediction_params.get('future_days', 'N/A')
created_at_str = record['created_at']
formatted_created_at = created_at_str
try:
dt_obj = datetime.fromisoformat(created_at_str)
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['id'],
'prediction_uid': record['prediction_uid'],
'product_id': product_id,
'product_name': product_name_map.get(product_id, record['product_name']),
'model_type': record['model_type'],
'model_id': record['model_id'],
'start_date': start_date_str,
'future_days': future_days,
'created_at': formatted_created_at,
'file_path': record['result_file_path']
})
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"处理历史记录失败 (ID: {record['id']}): {e}")
continue
conn.close()

View File

@ -11,6 +11,7 @@
- **结构清晰**: 物理文件(模型、日志、预测结果)与数据库记录分离,数据库只存元数据和路径,保持自身轻量。
---
## 预测结果JSON保存到saved_predictions
## 表结构定义