- 内置3宏观因素: 原油价格/美元汇率/CPI通胀率 - 敏感性引擎: 按KPI类别推断弹性(成本类油价0.15/利润类0.12/营收类0.08), 方向+因素涨KPI涨 - 负值KPI(亏损)方向反转修复: 油价涨→净利更亏 - API: GET /predict/kpi-forecast/sensitivity?pct=10 → KPI×因素矩阵(±pct调整后预测) - 前端: 敏感性幅度选择(±5/10/20%) + 敏感性矩阵表(同向/反向+↑↓调整值) - 诚实标注: 模型弹性(规则推断,非历史回归), 后续可用宏观历史数据回归校准 - 验证: Chrome实测页面+API矩阵, pytest 46 passed
349 lines
12 KiB
Vue
349 lines
12 KiB
Vue
<template>
|
||
<div class="cost-intelligence">
|
||
<!-- 顶部控制条:模型选择 / 预测期数 / 刷新 -->
|
||
<el-card shadow="never" class="ctrl-card">
|
||
<div class="ctrl-bar">
|
||
<div class="ctrl-left">
|
||
<span class="ctrl-label">预测模型</span>
|
||
<el-select v-model="model" style="width: 150px" @change="loadData">
|
||
<el-option label="线性回归" value="linear" />
|
||
<el-option label="移动平均" value="moving_average" />
|
||
</el-select>
|
||
<span class="ctrl-label" style="margin-left: 16px;">预测期数</span>
|
||
<el-select v-model="periods" style="width: 100px" @change="loadData">
|
||
<el-option label="3期" :value="3" />
|
||
<el-option label="6期" :value="6" />
|
||
<el-option label="12期" :value="12" />
|
||
</el-select>
|
||
<span class="ctrl-label" style="margin-left: 16px;">敏感性幅度</span>
|
||
<el-select v-model="sensPct" style="width: 90px" @change="loadSensitivity">
|
||
<el-option label="±5%" :value="5" />
|
||
<el-option label="±10%" :value="10" />
|
||
<el-option label="±20%" :value="20" />
|
||
</el-select>
|
||
<el-button type="primary" :loading="loading" style="margin-left: 16px;" @click="loadAll">刷新预测</el-button>
|
||
</div>
|
||
<div class="ctrl-right">
|
||
<span class="mapping-hint">基于 kpi_values 历史数据 · 线性回归/移动平均 · 置信度诚实标注</span>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 概览统计 -->
|
||
<el-row :gutter="12" style="margin-top: 12px;">
|
||
<el-col :span="6">
|
||
<div class="stat-card"><div class="s-label">可预测KPI</div><div class="s-value blue">{{ list.length }}</div></div>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<div class="stat-card"><div class="s-label">高置信度(high)</div><div class="s-value green">{{ confCount('high') }}</div></div>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<div class="stat-card"><div class="s-label">上升趋势(up)</div><div class="s-value red">{{ trendCount('up') }}</div></div>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<div class="stat-card"><div class="s-label">下降趋势(down)</div><div class="s-value orange">{{ trendCount('down') }}</div></div>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 主体:KPI列表 + 趋势图 -->
|
||
<el-row :gutter="12" style="margin-top: 12px;">
|
||
<el-col :span="9">
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>📊 财务KPI预测结果</span>
|
||
<el-tag size="small" type="info">{{ model === 'linear' ? '线性回归' : '移动平均' }}</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-table :data="list" size="small" border stripe highlight-current-row
|
||
height="520" @row-click="onSelect" @current-change="onSelect">
|
||
<el-table-column prop="kpi.name" label="KPI名称" min-width="120" show-overflow-tooltip />
|
||
<el-table-column label="最近值" width="90" align="right">
|
||
<template #default="{ row }">{{ fmtVal(lastHistory(row)) }}{{ row.kpi.unit }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="下一期预测" width="110" align="right">
|
||
<template #default="{ row }">
|
||
<span :style="{ color: trendColor(row.trend), fontWeight: 600 }">{{ fmtVal(row.next_target) }}{{ row.kpi.unit }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="趋势" width="70" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="trendTagType(row.trend)">{{ trendLabel(row.trend) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="置信度" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="confTagType(row.confidence)">{{ confLabel(row.confidence) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="变化" width="80" align="right">
|
||
<template #default="{ row }">
|
||
<span :style="{ color: row.trend_pct >= 0 ? '#F56C6C' : '#67C23A' }">{{ row.trend_pct >= 0 ? '+' : '' }}{{ row.trend_pct?.toFixed?.(1) ?? '-' }}%</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="15">
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>📈 趋势与预测</span>
|
||
<el-tag v-if="selected" size="small" type="primary">{{ selected.kpi.name }}</el-tag>
|
||
<span v-else class="empty-hint">点击左侧KPI查看趋势</span>
|
||
</div>
|
||
</template>
|
||
<div ref="chartRef" class="trend-chart"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 宏观敏感性联动(IMA 2026.7) -->
|
||
<el-card shadow="never" style="margin-top: 12px;">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>🌐 宏观敏感性因素联动</span>
|
||
<el-tag size="small" type="warning">模型弹性(规则推断,非历史回归)</el-tag>
|
||
<span class="empty-hint">因素变动 {{ sensPct }}% 对预测值的影响</span>
|
||
</div>
|
||
</template>
|
||
<el-table :data="sensList" size="small" border stripe max-height="360">
|
||
<el-table-column prop="kpi.name" label="KPI" min-width="110" show-overflow-tooltip />
|
||
<el-table-column label="类别" width="70" align="center">
|
||
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="基准预测" width="100" align="right">
|
||
<template #default="{ row }">{{ fmtVal(row.next_target) }}</template>
|
||
</el-table-column>
|
||
<el-table-column v-for="f in sensFactors" :key="f.key" :label="f.name" min-width="150" align="center">
|
||
<template #default="{ row }">
|
||
<template v-if="factorOf(row, f.key)">
|
||
<span :style="{ color: factorOf(row, f.key).direction === '+' ? '#F56C6C' : '#67C23A' }">
|
||
{{ factorOf(row, f.key).direction === '+' ? '同向' : '反向' }}
|
||
</span>
|
||
<span class="sens-val">↑{{ fmtVal(factorOf(row, f.key).adj_up) }} ↓{{ fmtVal(factorOf(row, f.key).adj_down) }}</span>
|
||
</template>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="note-line" style="margin-top:8px;">
|
||
弹性系数按KPI类别推断(成本类对油价最敏感0.15、利润类0.12、营收类0.08),MVP规则模型,后续可用宏观历史数据回归校准。
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 预测说明 -->
|
||
<el-card shadow="never" style="margin-top: 12px;">
|
||
<div class="note-line">
|
||
<b>说明:</b>本模块基于 kpi_values 历史数据做趋势预测(线性回归/移动平均),输出预测值+趋势方向+模型置信度。
|
||
<el-tag size="small" type="warning" style="margin-left:6px;">置信度 low 表示历史数据不足或波动大,预测仅供参考,不构成决策依据</el-tag>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||
import { predictApi } from '../api/index'
|
||
|
||
const model = ref('linear')
|
||
const periods = ref(3)
|
||
const loading = ref(false)
|
||
const list = ref<any[]>([])
|
||
const selected = ref<any>(null)
|
||
const chartRef = ref<HTMLElement | null>(null)
|
||
let chart: any = null
|
||
|
||
// ── 宏观敏感性联动 ──
|
||
const sensPct = ref(10)
|
||
const sensList = ref<any[]>([])
|
||
const sensFactors = ref<any[]>([])
|
||
|
||
function catLabel(c: string) {
|
||
return { profit: '利润', revenue: '营收', cost: '成本', cash: '现金流' }[c] || c
|
||
}
|
||
function factorOf(row: any, key: string) {
|
||
return (row.factors || []).find((f: any) => f.factor_key === key)
|
||
}
|
||
|
||
function lastHistory(row: any) {
|
||
const h = row.history || []
|
||
return h.length ? h[h.length - 1].value : null
|
||
}
|
||
function fmtVal(v: any) {
|
||
if (v === null || v === undefined) return '-'
|
||
return Number(v).toFixed(2)
|
||
}
|
||
function trendLabel(t: string) {
|
||
return { up: '上升', down: '下降', flat: '平稳' }[t] || t
|
||
}
|
||
function trendTagType(t: string) {
|
||
return { up: 'danger', down: 'success', flat: 'info' }[t] || 'info'
|
||
}
|
||
function trendColor(t: string) {
|
||
return { up: '#F56C6C', down: '#67C23A', flat: '#909399' }[t] || '#909399'
|
||
}
|
||
function confLabel(c: string) {
|
||
return { high: '高', medium: '中', low: '低' }[c] || c
|
||
}
|
||
function confTagType(c: string) {
|
||
return { high: 'success', medium: 'warning', low: 'info' }[c] || 'info'
|
||
}
|
||
function confCount(c: string) {
|
||
return list.value.filter(x => x.confidence === c).length
|
||
}
|
||
function trendCount(t: string) {
|
||
return list.value.filter(x => x.trend === t).length
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const r: any = await predictApi.kpiForecastFinance({ model: model.value, periods: periods.value })
|
||
list.value = r.data || []
|
||
if (list.value.length && !selected.value) {
|
||
selected.value = list.value[0]
|
||
} else if (list.value.length) {
|
||
const still = list.value.find(x => x.kpi?.code === selected.value?.kpi?.code)
|
||
selected.value = still || list.value[0]
|
||
}
|
||
await nextTick()
|
||
renderChart()
|
||
} catch (e: any) {
|
||
console.error('预测加载失败', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadSensitivity() {
|
||
try {
|
||
const r: any = await predictApi.kpiForecastSensitivity({ pct: sensPct.value, periods: periods.value, model: model.value })
|
||
sensList.value = r.data || []
|
||
sensFactors.value = r.factors || []
|
||
} catch (e: any) {
|
||
console.error('敏感性加载失败', e)
|
||
}
|
||
}
|
||
|
||
async function loadAll() {
|
||
await Promise.all([loadData(), loadSensitivity()])
|
||
}
|
||
|
||
function onSelect(row: any) {
|
||
selected.value = row
|
||
renderChart()
|
||
}
|
||
|
||
function renderChart() {
|
||
if (!chartRef.value) return
|
||
if (!chart) {
|
||
import('echarts').then(echarts => {
|
||
chart = echarts.init(chartRef.value!)
|
||
draw()
|
||
})
|
||
return
|
||
}
|
||
draw()
|
||
}
|
||
|
||
function draw() {
|
||
if (!chart || !selected.value) return
|
||
const row = selected.value
|
||
const hist = row.history || []
|
||
const fc = row.forecast || []
|
||
const labels = [...hist.map((h: any) => h.period), ...fc.map((f: any) => f.period)]
|
||
const histVals = hist.map((h: any) => h.value)
|
||
const fcVals = fc.map((f: any) => f.value ?? f.next_value)
|
||
chart.setOption({
|
||
tooltip: { trigger: 'axis' },
|
||
legend: { data: ['历史', '预测'] },
|
||
grid: { left: 50, right: 20, top: 40, bottom: 40 },
|
||
xAxis: { type: 'category', data: labels },
|
||
yAxis: { type: 'value', scale: true },
|
||
series: [
|
||
{ name: '历史', type: 'line', smooth: true, data: histVals, itemStyle: { color: '#409EFF' }, areaStyle: { opacity: 0.08 } },
|
||
{ name: '预测', type: 'line', smooth: true, data: fcVals, itemStyle: { color: '#E6A23C' }, lineStyle: { type: 'dashed' } },
|
||
],
|
||
})
|
||
}
|
||
|
||
function resizeChart() {
|
||
chart?.resize()
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadAll()
|
||
window.addEventListener('resize', resizeChart)
|
||
})
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', resizeChart)
|
||
chart?.dispose()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cost-intelligence {
|
||
padding: 16px 20px;
|
||
}
|
||
.ctrl-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.ctrl-left {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.ctrl-label {
|
||
font-size: 13px;
|
||
color: #606266;
|
||
margin-right: 8px;
|
||
}
|
||
.mapping-hint {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
.stat-card {
|
||
background: #FAFBFC;
|
||
border: 1px solid #EBEEF5;
|
||
border-radius: 8px;
|
||
padding: 14px 16px;
|
||
text-align: center;
|
||
}
|
||
.s-label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
.s-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
margin-top: 4px;
|
||
}
|
||
.s-value.blue { color: #409EFF; }
|
||
.s-value.green { color: #67C23A; }
|
||
.s-value.red { color: #F56C6C; }
|
||
.s-value.orange { color: #E6A23C; }
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.empty-hint {
|
||
font-size: 12px;
|
||
color: #C0C4CC;
|
||
}
|
||
.sens-val {
|
||
font-size: 12px;
|
||
color: #606266;
|
||
margin-left: 4px;
|
||
}
|
||
.trend-chart {
|
||
height: 520px;
|
||
width: 100%;
|
||
}
|
||
.note-line {
|
||
font-size: 12px;
|
||
color: #606266;
|
||
}
|
||
</style>
|