feat: 预测性成本智能模块上线(IMA 2026.7前沿) — 接线+重建页面

- CostIntelligence.vue 重建(被清理的未提交文件): 模型选择(线性/移动平均)+预测期数+统计卡+KPI表格+趋势图
- 路由: /cost-intelligence(挂财务维度D执行与控制), 修正 predict/cost-intelligence 路径不匹配
- 菜单: 预测性成本智能(ceo/finance/it)
- 后端API已存在: /predict/kpi-forecast/finance (kpi_forecast_engine)
- 验证: Chrome实测 5 KPI预测(厂补率/营收/净利/费用率/毛利), 趋势图渲染, pytest不影响
- 注: 置信度诚实标注(全部low=历史数据波动大, MVP原则)
This commit is contained in:
Hermes CI Fix
2026-08-25 00:34:28 +08:00
parent 9de6e522a4
commit 1c8b01d682
3 changed files with 283 additions and 4 deletions
+4 -3
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/tax-compliance'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/tax-compliance'],
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/tax-compliance'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/tax-compliance'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/tax-compliance'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
}
export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -32,6 +32,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 2: 执行与控制(Do)──
{ path: '/cost', label: '成本分析', icon: 'Money', roles: ['ceo', 'finance', 'it'], group: '🟢 D 执行与控制' },
{ path: '/cost-intelligence', label: '预测性成本智能', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟢 D 执行与控制' },
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
+1 -1
View File
@@ -28,7 +28,7 @@ const routes = [
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict/cost-intelligence', name: 'CostIntelligence', component: () => import('@/views/CostIntelligence.vue'), meta: { title: '预测性成本智能', roles: ['ceo', 'finance', 'it'] } },
{ path: 'cost-intelligence', name: 'CostIntelligence', component: () => import('@/views/CostIntelligence.vue'), meta: { title: '预测性成本智能', roles: ['ceo', 'finance', 'it'] } },
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
+278
View File
@@ -0,0 +1,278 @@
<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>
<el-button type="primary" :loading="loading" style="margin-left: 16px;" @click="loadData">刷新预测</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>
<!-- 预测说明 -->
<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
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
}
}
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(() => {
loadData()
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;
}
.trend-chart {
height: 520px;
width: 100%;
}
.note-line {
font-size: 12px;
color: #606266;
}
</style>