ShopTRAINING/UI/src/views/prediction/ProductPredictionView.vue

349 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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="目标药品">
<ProductSelector
v-model="filters.product_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="product_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_id]"
>
<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, ElRow, ElCol, ElPagination } from 'element-plus'
import { QuestionFilled, TrendCharts } from '@element-plus/icons-vue'
import Chart from 'chart.js/auto'
import ProductSelector from '../../components/ProductSelector.vue'
const modelList = ref([])
const modelTypes = ref([])
const modelsLoading = ref(false)
const predicting = reactive({})
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({
product_id: '',
model_type: null
})
const pagination = reactive({
currentPage: 1,
pageSize: 12
})
const filteredModelList = computed(() => {
return modelList.value.filter(model => {
const productMatch = !filters.product_id || model.product_id === filters.product_id
const modelTypeMatch = !filters.model_type || model.model_type === filters.model_type.id
return productMatch && 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: 'product' } })
if (response.data.status === 'success') {
modelList.value = response.data.data
} else {
ElMessage.error('获取模型列表失败')
}
} catch (error) {
ElMessage.error('获取模型列表失败')
} finally {
modelsLoading.value = false
}
}
const startPrediction = async (model) => {
predicting[model.model_id] = true
try {
const payload = {
product_id: model.product_id,
model_type: model.model_type,
version: model.version,
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_id] = 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.product_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(() => {
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;
}
.filters-section, .global-settings-section, .model-list-section {
margin-top: 20px;
}
.filters-section h4, .global-settings-section h4, .model-list-section h4 {
margin-bottom: 20px;
}
.prediction-chart {
margin-top: 20px;
}
</style>