feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h3>预测准确率报表</h3>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="entityId" size="small" style="width:200px;" @change="loadAccuracy">
|
||||
<el-option label="陕西酣客(白酒经销)" :value="1" />
|
||||
<el-option label="陕西博海科技(IT服务)" :value="2" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="loadAccuracy" :loading="loading" type="primary">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="summary-card">
|
||||
<div class="s-label">平均绝对误差 (MAE)</div>
|
||||
<div class="s-value" :class="summary.avg_mae < 5 ? 'green' : summary.avg_mae < 10 ? 'orange' : 'red'">
|
||||
{{ summary.avg_mae?.toFixed(2) || '--' }}
|
||||
<span style="font-size:12px;color:#909399;">万元</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="summary-card">
|
||||
<div class="s-label">平均百分比误差 (MAPE)</div>
|
||||
<div class="s-value" :class="summary.avg_mape < 15 ? 'green' : summary.avg_mape < 30 ? 'orange' : 'red'">
|
||||
{{ summary.avg_mape?.toFixed(2) || '--' }}
|
||||
<span style="font-size:12px;color:#909399;">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="summary-card">
|
||||
<div class="s-label">统计周期数</div>
|
||||
<div class="s-value" style="color:#409eff;">
|
||||
{{ summary.total_periods || 0 }}
|
||||
<span style="font-size:12px;color:#909399;">个月</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 准确率趋势图 -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<span>📈 预测准确率趋势(MAE/MAPE)</span>
|
||||
</template>
|
||||
<div ref="accuracyChartRef" style="height:360px;width:100%;"></div>
|
||||
</el-card>
|
||||
|
||||
<!-- 每月明细 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>📋 每月预测 vs 实际对比</span>
|
||||
<span style="margin-left:12px;font-size:12px;color:#999;" v-if="accuracyData.length">共 {{ accuracyData.length }} 期</span>
|
||||
</template>
|
||||
<el-table :data="accuracyData" v-loading="loading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="period" label="期间" width="100" />
|
||||
<el-table-column prop="forecast_value" label="预测值(万)" width="130" align="right">
|
||||
<template #default="{ row }">{{ row.forecast_value?.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值(万)" width="130" align="right">
|
||||
<template #default="{ row }">{{ row.actual_value?.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异(万)" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: Math.abs(row.forecast_value - row.actual_value) < 5 ? '#67c23a' : '#f56c6c', fontWeight:600 }">
|
||||
{{ (row.forecast_value - row.actual_value) > 0 ? '+' : '' }}{{ (row.forecast_value - row.actual_value)?.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mae" label="MAE(万)" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.mae < 5 ? '#67c23a' : row.mae < 10 ? '#e6a23c' : '#f56c6c' }">{{ row.mae?.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mape" label="MAPE(%)" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.mape < 15 ? '#67c23a' : row.mape < 30 ? '#e6a23c' : '#f56c6c' }">{{ row.mape?.toFixed(2) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="评级" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.mape < 15 ? 'success' : row.mape < 30 ? 'warning' : 'danger'" size="small">
|
||||
{{ row.mape < 15 ? '准确' : row.mape < 30 ? '一般' : '偏差大' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { predictApi } from '../api/index'
|
||||
|
||||
const loading = ref(false)
|
||||
const entityId = ref(1)
|
||||
const accuracyData = ref<any[]>([])
|
||||
const summary = ref<any>({})
|
||||
const accuracyChartRef = ref<HTMLElement | null>(null)
|
||||
let accuracyChart: any = null
|
||||
|
||||
async function loadAccuracy() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await predictApi.forecastAccuracy({ entity_id: entityId.value })
|
||||
accuracyData.value = r.data || []
|
||||
summary.value = r.summary || {}
|
||||
nextTick(() => renderAccuracyChart())
|
||||
} catch (e) {
|
||||
ElMessage.error('加载预测准确率失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function renderAccuracyChart() {
|
||||
if (!accuracyChartRef.value || !accuracyData.value.length) return
|
||||
import('echarts').then(echarts => {
|
||||
if (accuracyChart) accuracyChart.dispose()
|
||||
accuracyChart = echarts.init(accuracyChartRef.value!)
|
||||
|
||||
const periods = accuracyData.value.map((d: any) => d.period)
|
||||
const maeData = accuracyData.value.map((d: any) => d.mae)
|
||||
const mapeData = accuracyData.value.map((d: any) => d.mape)
|
||||
const forecastVals = accuracyData.value.map((d: any) => d.forecast_value)
|
||||
const actualVals = accuracyData.value.map((d: any) => d.actual_value)
|
||||
|
||||
accuracyChart.setOption({
|
||||
grid: [{ left: 60, right: 30, top: 20, bottom: 50 }, { left: 60, right: 30, top: 320, bottom: 50 }],
|
||||
xAxis: [
|
||||
{ type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 }, gridIndex: 0 },
|
||||
{ type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 }, gridIndex: 1 },
|
||||
],
|
||||
yAxis: [
|
||||
{ type: 'value', name: '误差', gridIndex: 0 },
|
||||
{ type: 'value', name: '现金(万)', gridIndex: 1 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'MAE', type: 'bar', data: maeData, xAxisIndex: 0, yAxisIndex: 0,
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
barWidth: '30%',
|
||||
},
|
||||
{
|
||||
name: 'MAPE(%)', type: 'line', data: mapeData, xAxisIndex: 0, yAxisIndex: 0,
|
||||
lineStyle: { color: '#e6a23c', width: 2 },
|
||||
symbol: 'circle', symbolSize: 6,
|
||||
itemStyle: { color: '#e6a23c' },
|
||||
},
|
||||
{
|
||||
name: '预测值', type: 'line', data: forecastVals, xAxisIndex: 1, yAxisIndex: 1,
|
||||
lineStyle: { color: '#409eff', width: 2 },
|
||||
symbol: 'diamond', symbolSize: 8,
|
||||
itemStyle: { color: '#409eff' },
|
||||
},
|
||||
{
|
||||
name: '实际值', type: 'line', data: actualVals, xAxisIndex: 1, yAxisIndex: 1,
|
||||
lineStyle: { color: '#67c23a', width: 2 },
|
||||
symbol: 'circle', symbolSize: 6,
|
||||
itemStyle: { color: '#67c23a' },
|
||||
},
|
||||
],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAccuracy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
|
||||
.page-header h3 { margin:0; }
|
||||
.header-actions { display:flex; gap:8px; }
|
||||
.summary-card { text-align:center; padding:8px 0; }
|
||||
.s-label { font-size:13px; color:#909399; margin-bottom:8px; }
|
||||
.s-value { font-size:28px; font-weight:bold; }
|
||||
.s-value.green { color:#67c23a; }
|
||||
.s-value.orange { color:#e6a23c; }
|
||||
.s-value.red { color:#f56c6c; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user