Compare commits

...

2 Commits

Author SHA1 Message Date
af7638aeca 备份,模型管理待完善 2025-07-25 17:08:09 +08:00
88f245b957 完善历史预测查看详情,删除功能 2025-07-25 16:07:45 +08:00
7 changed files with 478 additions and 320 deletions

View File

@ -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);
// nextTickDOM
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.vuerenderChart 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();
}); });

View File

@ -17,7 +17,22 @@
<el-form :inline="true" @submit.prevent="fetchModels"> <el-form :inline="true" @submit.prevent="fetchModels">
<el-form-item label="产品ID" style="width:300px"> <el-form-item label="产品ID" style="width:300px">
<el-input v-model="filters.product_id" placeholder="按产品ID筛选" clearable></el-input> <el-select
v-model="filters.product_id"
placeholder="按产品/店铺/全局筛选"
clearable
filterable
remote
:remote-method="searchOptions"
:loading="searchLoading"
>
<el-option
v-for="item in searchResults"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="模型类型" style="width:300px"> <el-form-item label="模型类型" style="width:300px">
<el-select v-model="filters.model_type" placeholder="按模型类型筛选" clearable> <el-select v-model="filters.model_type" placeholder="按模型类型筛选" clearable>
@ -127,7 +142,7 @@
{{ selectedModelDetails.model_info.model_type }} {{ selectedModelDetails.model_info.model_type }}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="产品">{{ selectedModelDetails.model_info.product_name }}</el-descriptions-item> <el-descriptions-item :label="selectedModelDetails.model_info.scopeLabel">{{ selectedModelDetails.model_info.scopeName }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(selectedModelDetails.model_info.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatDateTime(selectedModelDetails.model_info.created_at) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
@ -207,6 +222,27 @@ const models = ref([])
const modelTypes = ref([]) const modelTypes = ref([])
const loading = ref(true) const loading = ref(true)
const filters = reactive({ product_id: '', model_type: '' }) const filters = reactive({ product_id: '', model_type: '' })
const searchLoading = ref(false)
const searchResults = ref([])
const searchOptions = async (query) => {
if (query) {
searchLoading.value = true
try {
const response = await axios.get('/api/management/filter-options', { params: { query } })
if (response.data.status === 'success') {
searchResults.value = response.data.data
}
} catch (error) {
console.error('获取筛选选项失败:', error)
searchResults.value = []
} finally {
searchLoading.value = false
}
} else {
searchResults.value = []
}
}
// //
const pagination = reactive({ const pagination = reactive({
@ -290,29 +326,43 @@ const handlePageSizeChange = (pageSize) => {
const viewDetails = async (model) => { const viewDetails = async (model) => {
detailsDialogVisible.value = true; detailsDialogVisible.value = true;
selectedModelDetails.value = null; // selectedModelDetails.value = null;
try { try {
// : 使API const response = await axios.get(`/api/models/${model.model_uid}`);
const details = {
model_info: {
model_id: model.model_uid,
model_type: model.model_type,
product_name: model.display_name,
created_at: model.created_at,
},
training_metrics: model.performance_metrics,
chart_data: {
loss_chart: model.artifacts?.loss_curve_data || { epochs: [], train_loss: [], test_loss: [] }
}
};
selectedModelDetails.value = details;
nextTick(() => {
initLossChart();
});
if (response.data.status === 'success') { if (response.data.status === 'success') {
const details = response.data.data; const details = response.data.data;
details.training_metrics = normalizeMetricsKeys(details.training_metrics);
selectedModelDetails.value = details; //
//
const model_info = {
model_id: details.model_uid,
model_type: details.model_type,
created_at: details.created_at,
scopeLabel: '范围', //
scopeName: details.display_name //
};
if (details.training_mode === 'product') {
model_info.scopeLabel = '产品';
model_info.scopeName = details.product_name || details.display_name;
} else if (details.training_mode === 'store') {
model_info.scopeLabel = '店铺';
model_info.scopeName = details.store_name || details.display_name;
} else if (details.training_mode === 'global') {
model_info.scopeLabel = '全局模型';
model_info.scopeName = details.display_name;
}
const formattedDetails = {
model_info,
training_metrics: normalizeMetricsKeys(details.performance_metrics),
chart_data: {
loss_chart: details.artifacts?.loss_curve_data || { epochs: [], train_loss: [], test_loss: [] }
}
};
selectedModelDetails.value = formattedDetails;
nextTick(() => { nextTick(() => {
initLossChart(); initLossChart();
}); });

View File

@ -0,0 +1,82 @@
| 分类 | 字段名 | 数据类型 | 描述 | 来源 |
| --- | --- | --- | --- | --- |
| **标识符** | `subbh` | String | 店铺唯一标识 | 骨架 |
| | `hh` | String | 商品唯一标识 | 骨架 |
| | `kdrq` | Date | 开单日期 (主键之一) | 骨架 |
| **核心指标** | `sales_quantity` | Float | 当日销售量 (无销售则为0) | 当日销售 |
| | `return_quantity` | Float | 当日退货量 (无销售则为0) | 当日销售 |
| | `net_sales_quantity` | Float | **当日净销售量 (目标变量)** | 当日销售 |
| | `gross_profit_total` | Float | 当日毛利 (无销售则为0) | 当日销售 |
| | `transaction_count` | Integer | 当日交易次数 (无销售则为0) | 当日销售 |
| **日期特征** | `date` | Date | 日期 (冗余字段) | 时序计算 |
| | `is_weekend` | Boolean | 是否为周末 (True/False) | 时序计算 |
| | `day_of_week` | Integer | 一周中的第几天 (0=周一, 6=周日) | 时序计算 |
| | `day_of_month` | Integer | 一月中的第几天 (1-31) | 时序计算 |
| | `day_of_year` | Integer | 一年中的第几天 (1-366) | 时序计算 |
| | `week_of_month` | Integer | 当月第几周 (1-5) | 时序计算 |
| | `month` | Integer | 月份 (1-12) | 时序计算 |
| | `quarter` | Integer | 季度 (1-4) | 时序计算 |
| | `is_holiday` | Boolean | 是否为节假日 (True/False) | 时序计算 |
| **生命周期特征** | `first_sale_date` | Date | SKU在店首次销售日期 | 生命周期 |
| | `last_sale_date` | Date | SKU在店末次销售日期 | 生命周期 |
| | `lifecycle_days` | Integer | SKU在店生命周期总天数 | 生命周期 |
| | `sample_category` | String | 生命周期分类 (new/medium/old) | 生命周期 |
| | `rolling_7d_valid` | Boolean | 7日滚动窗口是否有效 (距离首次销售>=7天) | 生命周期 |
| | `rolling_15d_valid` | Boolean | 15日滚动窗口是否有效 | 生命周期 |
| | `rolling_30d_valid` | Boolean | 30日滚动窗口是否有效 | 生命周期 |
| | `rolling_90d_valid` | Boolean | 90日滚动窗口是否有效 | 生命周期 |
| **滚动特征 (7天)** | `sales_quantity_rolling_mean_7d` | Float | 过去7日平均销售量 | 历史滚动 |
| | `return_quantity_rolling_mean_7d` | Float | 过去7日平均退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_mean_7d`| Float | 过去7日平均净销量 | 历史滚动 |
| | `sales_quantity_rolling_sum_7d` | Float | 过去7日总销售量 | 历史滚动 |
| | `return_quantity_rolling_sum_7d` | Float | 过去7日总退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_sum_7d` | Float | 过去7日总净销量 | 历史滚动 |
| **滚动特征 (15天)** | `sales_quantity_rolling_mean_15d` | Float | 过去15日平均销售量 | 历史滚动 |
| | `return_quantity_rolling_mean_15d` | Float | 过去15日平均退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_mean_15d`| Float | 过去15日平均净销量 | 历史滚动 |
| | `sales_quantity_rolling_sum_15d` | Float | 过去15日总销售量 | 历史滚动 |
| | `return_quantity_rolling_sum_15d` | Float | 过去15日总退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_sum_15d` | Float | 过去15日总净销量 | 历史滚动 |
| **滚动特征 (30天)** | `sales_quantity_rolling_mean_30d` | Float | 过去30日平均销售量 | 历史滚动 |
| | `return_quantity_rolling_mean_30d` | Float | 过去30日平均退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_mean_30d`| Float | 过去30日平均净销量 | 历史滚动 |
| | `sales_quantity_rolling_sum_30d` | Float | 过去30日总销售量 | 历史滚动 |
| | `return_quantity_rolling_sum_30d` | Float | 过去30日总退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_sum_30d` | Float | 过去30日总净销量 | 历史滚动 |
| **滚动特征 (90天)** | `sales_quantity_rolling_mean_90d` | Float | 过去90日平均销售量 | 历史滚动 |
| | `return_quantity_rolling_mean_90d` | Float | 过去90日平均退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_mean_90d`| Float | 过去90日平均净销量 | 历史滚动 |
| | `sales_quantity_rolling_sum_90d` | Float | 过去90日总销售量 | 历史滚动 |
| | `return_quantity_rolling_sum_90d` | Float | 过去90日总退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_sum_90d` | Float | 过去90日总净销量 | 历史滚动 |
| **滚动特征 (180天)** | `sales_quantity_rolling_mean_180d` | Float | 过去180日平均销售量 | 历史滚动 |
| | `return_quantity_rolling_mean_180d` | Float | 过去180日平均退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_mean_180d`| Float | 过去180日平均净销量 | 历史滚动 |
| | `sales_quantity_rolling_sum_180d` | Float | 过去180日总销售量 | 历史滚动 |
| | `return_quantity_rolling_sum_180d` | Float | 过去180日总退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_sum_180d` | Float | 过去180日总净销量 | 历史滚动 |
| **滚动特征 (365天)** | `sales_quantity_rolling_mean_365d` | Float | 过去365日平均销售量 | 历史滚动 |
| | `return_quantity_rolling_mean_365d` | Float | 过去365日平均退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_mean_365d`| Float | 过去365日平均净销量 | 历史滚动 |
| | `sales_quantity_rolling_sum_365d` | Float | 过去365日总销售量 | 历史滚动 |
| | `return_quantity_rolling_sum_365d` | Float | 过去365日总退货量 | 历史滚动 |
| | `net_sales_quantity_rolling_sum_365d` | Float | 过去365日总净销量 | 历史滚动 |
| **店铺特征** | `province` | String | 店铺所在省份 | 店铺特征 |
| | `city` | String | 店铺所在城市 | 店铺特征 |
| | `district` | String | 店铺所在行政区 | 店铺特征 |
| | `poi_residential_count` | Integer | 周边住宅区POI数量 | 店铺特征 |
| | `poi_school_count` | Integer | 周边学校POI数量 | 店铺特征 |
| | `poi_mall_count` | Integer | 周边购物中心POI数量 | 店铺特征 |
| | `temperature_2m_max` | Float | 当日最高气温 | 店铺特征 |
| | `temperature_2m_min` | Float | 当日最低气温 | 店铺特征 |
| | `temperature_2m_mean`| Float | 当日平均气温 | 店铺特征 |
| **商品特征** | `零售大类代码_encoded` | Integer | 零售大类代码的数字编码 | 商品特征 |
| | `零售中类代码_encoded` | Integer | 零售中类代码的数字编码 | 商品特征 |
| | `零售小类代码_encoded` | Integer | 零售小类代码的数字编码 | 商品特征 |
| | `商品ABC分类_encoded` | Integer | 商品ABC分类的数字编码 | 商品特征 |
| | `商品手册代码_encoded` | Integer | 商品手册代码的数字编码 | 商品特征 |
| | `产地_encoded` | Integer | 产地的数字编码 | 商品特征 |
| | `brand_encoded` | Integer | 品牌的数字编码 | 商品特征 |
| | `packaging_quantity` | Float | 包装数量 (从规格中提取) | 商品特征 |
| | `approval_type_encoded` | Integer | 批准文号类型的数字编码 | 商品特征 |

Binary file not shown.

Binary file not shown.

View File

@ -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,58 @@ 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:
conn = get_db_connection()
cursor = conn.cursor()
# 从 prediction_history 表中查询所有唯一的 product_name
cursor.execute("SELECT DISTINCT product_name FROM prediction_history WHERE product_name IS NOT NULL")
records = cursor.fetchall()
conn.close()
# 构建选项列表
options = [{'value': row['product_name'], 'label': row['product_name']} for row in records]
return jsonify({"status": "success", "data": options})
except Exception as e:
logger.error(f"获取历史筛选选项失败: {e}\n{traceback.format_exc()}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/management/filter-options', methods=['GET'])
def get_management_filter_options():
"""获取模型管理页面筛选框的动态选项"""
try:
query = request.args.get('query', '').lower()
options = []
# 添加全局预测选项
global_option = {'value': 'global', 'label': '全局预测'}
if not query or '全局' in query or 'global' in query:
options.append(global_option)
# 获取并筛选产品
products = get_available_products()
for p in products:
label = f"{p.get('product_name', '未知产品')} ({p['product_id']})"
if not query or query in label.lower():
options.append({'value': p['product_id'], 'label': label})
# 获取并筛选店铺
stores = get_available_stores()
for s in stores:
label = f"{s.get('store_name', '未知店铺')} ({s['store_id']})"
if not query or query in label.lower():
options.append({'value': s['store_id'], 'label': label})
return jsonify({"status": "success", "data": options})
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({
@ -2143,100 +2247,60 @@ def list_models():
}) })
def get_model_details(model_id): def get_model_details(model_id):
""" """
获取单个模型的详细信息 获取单个模型的详细信息 (v3 - 统一数据结构)
---
tags:
- 模型管理
parameters:
- name: model_id
in: path
type: string
required: true
description: "模型的唯一标识符 (格式: model_type_product_id)"
responses:
200:
description: 模型的详细信息
404:
description: 未找到模型
""" """
try: try:
model_type, product_id = model_id.split('_', 1) # 智能处理带 '_best' 后缀的UID
db_query_uid = model_id[:-len('_best')] if model_id.endswith('_best') else model_id
# 处理优化版KAN模型的文件名 model_record = find_model_by_uid(db_query_uid)
file_model_type = model_type
if model_type == 'optimized_kan':
file_model_type = 'kan_optimized'
# 首先尝试从app配置中获取模型目录 if not model_record:
models_dir = app.config.get('MODEL_DIR', DEFAULT_MODEL_DIR) return jsonify({"status": "error", "message": "模型未找到"}), 404
# 检查models_dir是否存在如果不存在使用DEFAULT_MODEL_DIR作为后备 # 将 sqlite3.Row 转换为可修改的字典
if not os.path.exists(models_dir) and os.path.exists(DEFAULT_MODEL_DIR): model_data = dict(model_record)
print(f"警告: 配置的模型目录 '{models_dir}' 不存在,使用默认目录 '{DEFAULT_MODEL_DIR}'")
models_dir = DEFAULT_MODEL_DIR
# 尝试多种可能的文件名格式
possible_patterns = [
f'{file_model_type}_product_{product_id}_v1.pth', # 新格式
f'{file_model_type}_model_product_{product_id}.pth', # 旧格式
f'{file_model_type}_{product_id}_v1.pth', # 备用格式
]
model_path = None # 解析JSON字段
for pattern in possible_patterns: model_data['training_scope'] = json.loads(model_data.get('training_scope', '{}'))
test_path = os.path.join(models_dir, pattern) model_data['performance_metrics'] = json.loads(model_data.get('performance_metrics', '{}'))
if os.path.exists(test_path): model_data['artifacts'] = json.loads(model_data.get('artifacts', '{}'))
model_path = test_path
print(f"找到模型文件: {pattern}")
break
if not model_path: # 统一化修复:完全复制 list_models 的名称处理逻辑,确保数据结构一致
print(f"未找到模型文件,尝试的路径:") scope = model_data.get('training_scope', {})
for pattern in possible_patterns: mode = model_data.get('training_mode')
test_path = os.path.join(models_dir, pattern)
print(f" - {test_path}") # 初始化所有可能的名称字段
return jsonify({"status": "error", "error": "模型未找到"}), 404 model_data['product_name'] = None
model_data['store_name'] = None
# 优先使用数据库中的 display_name
display_name = model_data.get('display_name')
# 加载模型文件 if isinstance(scope, dict):
try: product_info = scope.get('product')
# 添加weights_only=False参数解决PyTorch 2.6序列化问题 store_info = scope.get('store')
checkpoint = torch.load(model_path, map_location='cpu', weights_only=False)
# 提取模型信息 if mode == 'product' and isinstance(product_info, dict):
model_info = { model_data['product_name'] = product_info.get('name')
"model_id": model_id, if not display_name: display_name = model_data['product_name']
"product_id": product_id,
"model_type": model_type,
"created_at": datetime.fromtimestamp(os.path.getctime(model_path)).isoformat(),
"file_path": model_path,
"file_size": f"{os.path.getsize(model_path) / (1024 * 1024):.2f} MB"
}
# 如果checkpoint是字典提取其中的信息 elif mode == 'store' and isinstance(store_info, dict):
if isinstance(checkpoint, dict): model_data['store_name'] = store_info.get('name')
# 提取配置信息 if not display_name: display_name = model_data['store_name']
if 'config' in checkpoint:
config = checkpoint['config']
for key, value in config.items():
model_info[key] = value
# 提取评估指标
if 'metrics' in checkpoint:
model_info['metrics'] = checkpoint['metrics']
# 获取产品名称 elif mode == 'global':
product_name = get_product_name(product_id) if not display_name: display_name = "全局模型"
if product_name:
model_info['product_name'] = product_name # 提供最终的后备方案
if not display_name:
display_name = "信息不完整"
return jsonify({"status": "success", "data": model_info}) model_data['display_name'] = display_name
except Exception as e:
print(f"加载模型文件失败: {str(e)}") return jsonify({"status": "success", "data": model_data})
return jsonify({"status": "error", "error": f"加载模型文件失败: {str(e)}"}), 500
except ValueError:
return jsonify({"status": "error", "error": "无效的model_id格式"}), 400
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": f"获取模型详情失败: {e}"}), 500 logger.error(f"获取模型详情失败: {e}\n{traceback.format_exc()}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/models/<model_id>', methods=['DELETE']) @app.route('/api/models/<model_id>', methods=['DELETE'])
@swag_from({ @swag_from({
@ -2273,68 +2337,46 @@ def get_model_details(model_id):
}) })
def delete_model(model_id): def delete_model(model_id):
""" """
删除一个模型及其关联文件 删除一个模型及其关联文件 (v2 - 基于数据库)
---
tags:
- 模型管理
parameters:
- name: model_id
in: path
type: string
required: true
description: "要删除的模型的ID (格式: model_type_product_id)"
responses:
200:
description: 模型删除成功
404:
description: 模型未找到
""" """
try: try:
model_type, product_id = model_id.split('_', 1) # 智能处理带 '_best' 后缀的UID
db_query_uid = model_id[:-len('_best')] if model_id.endswith('_best') else model_id
# 处理优化版KAN模型的文件名 conn = get_db_connection()
file_model_type = model_type cursor = conn.cursor()
if model_type == 'optimized_kan':
file_model_type = 'kan_optimized'
# 首先尝试从app配置中获取模型目录 # 查找模型记录
models_dir = app.config.get('MODEL_DIR', DEFAULT_MODEL_DIR) cursor.execute("SELECT artifacts FROM models WHERE model_uid = ?", (db_query_uid,))
record = cursor.fetchone()
# 检查models_dir是否存在如果不存在使用DEFAULT_MODEL_DIR作为后备 if not record:
if not os.path.exists(models_dir) and os.path.exists(DEFAULT_MODEL_DIR): conn.close()
print(f"警告: 配置的模型目录 '{models_dir}' 不存在,使用默认目录 '{DEFAULT_MODEL_DIR}'") return jsonify({"status": "error", "message": "模型未找到"}), 404
models_dir = DEFAULT_MODEL_DIR
# 删除数据库记录
cursor.execute("DELETE FROM models WHERE model_uid = ?", (db_query_uid,))
conn.commit()
# 删除关联的模型文件
try:
artifacts = json.loads(record['artifacts'])
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 尝试多种可能的文件名格式 for key, path in artifacts.items():
possible_patterns = [ if path and isinstance(path, str):
f'{file_model_type}_product_{product_id}_v1.pth', # 新格式 full_path = os.path.join(project_root, path)
f'{file_model_type}_model_product_{product_id}.pth', # 旧格式 if os.path.exists(full_path):
f'{file_model_type}_{product_id}_v1.pth', # 备用格式 os.remove(full_path)
] logger.info(f"已删除文件: {full_path}")
except (json.JSONDecodeError, TypeError, OSError) as e:
model_path = None logger.error(f"删除模型文件失败: {e}")
for pattern in possible_patterns:
test_path = os.path.join(models_dir, pattern)
if os.path.exists(test_path):
model_path = test_path
print(f"找到模型文件: {pattern}")
break
if not model_path:
print(f"未找到模型文件,尝试的路径:")
for pattern in possible_patterns:
test_path = os.path.join(models_dir, pattern)
print(f" - {test_path}")
return jsonify({"status": "error", "error": "模型未找到"}), 404
# 删除模型文件 conn.close()
os.remove(model_path)
return jsonify({"status": "success", "message": f"模型 {model_id} 已删除"}) return jsonify({"status": "success", "message": f"模型 {model_id} 已删除"})
except ValueError:
return jsonify({"status": "error", "error": "无效的model_id格式"}), 400
except Exception as e: except Exception as e:
return jsonify({"status": "error", "error": f"删除模型失败: {e}"}), 500 logger.error(f"删除模型失败: {e}\n{traceback.format_exc()}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/models/<model_id>/export', methods=['GET']) @app.route('/api/models/<model_id>/export', methods=['GET'])
@swag_from({ @swag_from({

View File

@ -65,14 +65,22 @@ def query_models_from_db(filters: dict, page: int = 1, page_size: int = 10):
conditions = [] conditions = []
params = [] params = []
if filters.get('product_id'): product_id_filter = filters.get('product_id')
conditions.append("json_extract(training_scope, '$.product.id') = ?") if product_id_filter:
params.append(filters['product_id']) if product_id_filter.lower() == 'global':
conditions.append("training_mode = ?")
params.append('global')
elif product_id_filter.startswith('S'):
conditions.append("json_extract(training_scope, '$.store.id') = ?")
params.append(product_id_filter)
else:
conditions.append("(json_extract(training_scope, '$.product.id') = ? OR display_name LIKE ?)")
params.extend([product_id_filter, f"%{product_id_filter}%"])
if filters.get('model_type'): if filters.get('model_type'):
conditions.append("model_type = ?") conditions.append("model_type LIKE ?")
params.append(filters['model_type']) params.append(f"{filters['model_type']}%")
if filters.get('store_id'): if filters.get('store_id'):
conditions.append("json_extract(training_scope, '$.store.id') = ?") conditions.append("json_extract(training_scope, '$.store.id') = ?")
params.append(filters['store_id']) params.append(filters['store_id'])