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:
@@ -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 执行与控制' },
|
||||
|
||||
@@ -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'] } },
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user