fix: 恢复5个被CI覆盖的前端文件+成本数据+预警定时器

- 从.bak恢复: BudgetManagement(72%代码丢失)/KPIList/DeviationDashboard/MapReview/NodeEditDialog
- 注册4个缺失后端API模块到main.py
- 新增3个前端路由(管理报表/战略执行看板/杜邦分析)
- 修复改善行动路由指向ActionPlanLibrary(原指向AlertList)
- deploy.sh增加git pull步骤
- 运行成本种子数据: 29标准成本+31实际成本+10ABC+25分配
- 补充非财务KPI预算: 72条(客户/流程/学习维度)
- 配置预警cron: 每30分钟自动检查
This commit is contained in:
Hermes CI Fix
2026-07-13 09:52:06 +08:00
parent b26046349c
commit 957dacd248
9 changed files with 994 additions and 507 deletions
+214 -121
View File
@@ -10,13 +10,13 @@
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadReport">
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
</el-select>
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:110px;" @change="loadReport">
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:110px;" @change="filterChanged">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-select v-model="alertFilter" placeholder="预警级别" clearable style="width:120px;" @change="loadReport">
<el-select v-model="alertFilter" placeholder="预警级别" clearable style="width:120px;" @change="filterChanged">
<el-option label="全部" value="" />
<el-option label="红色预警(超20%" value="red" />
<el-option label="黄色预警(超10%" value="yellow" />
@@ -26,37 +26,26 @@
<el-button @click="exportReport">导出CSV</el-button>
</div>
<!-- 汇总统计卡片 -->
<!-- 汇总统计卡片维度级 -->
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">已录预算KPI</div>
<div class="stat-value">{{ stats.total_budget || 0 }}</div>
<div class="stat-value">{{ stats.total_kpis || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-col :span="6" v-for="dim in dimensionCards" :key="dim.key">
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + dim.color }" style="cursor:pointer;" @click="scrollToDim(dim.key)">
<div class="stat-card">
<div class="stat-label">超预算红色</div>
<div class="stat-value red-text">{{ stats.red_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">接近阈值黄色</div>
<div class="stat-value yellow-text">{{ stats.yellow_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">正常达标</div>
<div class="stat-value green-text">{{ stats.normal_count || 0 }}</div>
<div class="stat-label">{{ dim.icon }} {{ dim.name }}</div>
<div class="stat-value" :class="dim.summary.level + '-text'">
{{ dim.summary.deviation_rate != null ? dim.summary.deviation_rate.toFixed(1) + '%' : '-' }}
</div>
<div style="font-size:11px;color:#999;margin-top:2px;">
{{ dim.summary.has_budget }}/{{ dim.summary.count }} KPI有预算
</div>
</div>
</el-card>
</el-col>
@@ -97,71 +86,110 @@
</div>
</el-card>
<!-- 差异明细表格 -->
<el-table :data="reportData" v-loading="loading" border stripe size="small" style="width:100%;" default-expand-all row-key="kpi_id" :tree-props="{ children: 'children' }">
<el-table-column type="index" label="#" width="35" />
<el-table-column prop="kpi_code" label="KPI编码" width="110" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag size="small" :type="dimTag(row.dimension)" style="font-size:11px;">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="budget_value" label="预算值" width="120" align="right">
<template #default="{ row }">{{ formatValue(row.budget_value) }}</template>
</el-table-column>
<el-table-column prop="actual_value" label="实际值" width="120" align="right">
<template #default="{ row }">{{ formatValue(row.actual_value) }}</template>
</el-table-column>
<el-table-column prop="deviation_amount" label="差异额" width="120" align="right">
<template #default="{ row }">
<span :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
{{ row.deviation_amount !== undefined ? formatValue(row.deviation_amount) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column prop="deviation_rate" label="差异率" width="100" align="right">
<template #default="{ row }">
<el-tag v-if="row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
</el-tag>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column prop="yoy_rate" label="同比" width="85" align="right">
<template #default="{ row }">
<span v-if="row.yoy_rate !== undefined" :style="{ color: (row.yoy_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.yoy_rate > 0 ? '+' : '' }}{{ row.yoy_rate.toFixed(1) }}%
</span>
<span v-else style="color:#999;">--</span>
</template>
</el-table-column>
<el-table-column prop="mom_rate" label="环比" width="85" align="right">
<template #default="{ row }">
<span v-if="row.mom_rate !== undefined" :style="{ color: (row.mom_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.mom_rate > 0 ? '+' : '' }}{{ row.mom_rate.toFixed(1) }}%
</span>
<span v-else style="color:#999;">--</span>
</template>
</el-table-column>
<el-table-column prop="alert_level" label="预警" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.alert_level === 'red'" type="danger" size="small">红色</el-tag>
<el-tag v-else-if="row.alert_level === 'yellow'" type="warning" size="small">黄色</el-tag>
<el-tag v-else-if="row.deviation_rate !== undefined" type="success" size="small">正常</el-tag>
<span v-else style="color:#999;">--</span>
</template>
</el-table-column>
</el-table>
<!-- 维度目标KPI 三层树形表格 -->
<el-card v-if="hierarchyData.length > 0" :body-style="{ padding: 0 }">
<el-table
:data="flattenHierarchy"
v-loading="loading"
border stripe
size="small"
style="width:100%;"
row-key="__uid"
:tree-props="{ children: '_children' }"
:indent="24"
:default-expand-all="expandedAll"
@row-click="onRowClick"
>
<el-table-column label="层级" min-width="280">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px;">
<span v-if="row._type === 'dimension'" style="font-size:16px;">{{ row.icon }}</span>
<span :style="{
fontWeight: row._type === 'dimension' ? 700 : row._type === 'objective' ? 600 : 400,
fontSize: row._type === 'dimension' ? '14px' : '13px',
color: row._type === 'dimension' ? '#303133' : '#606266'
}">{{ row._type === 'dimension' ? row.name + ' 维度' : row.name }}</span>
<el-tag v-if="row._type === 'dimension'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
</el-tag>
<el-tag v-if="row._type === 'objective'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
</el-tag>
<span v-if="row._type === 'dimension'" style="font-size:11px;color:#999;margin-left:8px;">
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
</span>
<span v-if="row._type === 'objective'" style="font-size:11px;color:#999;margin-left:8px;">
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="kpi_code" label="KPI编码" width="110">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" style="font-family:monospace;font-size:12px;">{{ row.kpi_code }}</span>
</template>
</el-table-column>
<el-table-column label="预算值" width="120" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" :style="{ color: row.budget_value != null ? '#303133' : '#999' }">
{{ row.budget_value != null ? fmtVal(row.budget_value) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column label="实际值" width="120" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" :style="{ color: row.actual_value != null ? '#303133' : '#999' }">
{{ row.actual_value != null ? fmtVal(row.actual_value) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异额" width="120" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
{{ row.deviation_amount !== undefined ? fmtVal(row.deviation_amount) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异率" width="100" align="right">
<template #default="{ row }">
<el-tag v-if="row._type === 'kpi' && row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="同比" width="85" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi' && row.yoy?.diff_rate != null"
:style="{ color: (row.yoy.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.yoy.diff_rate > 0 ? '+' : '' }}{{ row.yoy.diff_rate.toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column label="环比" width="85" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi' && row.mom?.diff_rate != null"
:style="{ color: (row.mom.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.mom.diff_rate > 0 ? '+' : '' }}{{ row.mom.diff_rate.toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column label="预警" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row._type === 'kpi' && row.deviation_rate != null" :type="deviationTag(row.deviation_rate)" size="small">
{{ row.deviation_rate > 20 ? '红色' : row.deviation_rate > 10 ? '黄色' : '正常' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 无数据提示 -->
<el-empty v-if="!loading && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
<el-empty v-if="!loading && hierarchyData.length === 0 && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
@@ -178,53 +206,111 @@ const filterDimension = ref('')
const alertFilter = ref('')
const loading = ref(false)
const reportData = ref<any[]>([])
const hierarchyData = ref<any[]>([])
const stats = ref<any>({})
const expandedAll = ref(true)
// ── 维度标签 ──
// ── 维度卡片计算 ──
const dimensionCards = computed(() => {
return hierarchyData.value.map(d => ({
key: d.key,
name: d.name,
icon: d.icon,
color: d.color,
summary: d.summary
}))
})
// ── 平铺树结构为el-table可用的带_children的列表 ──
const flattenHierarchy = computed(() => {
const result: any[] = []
let uid = 0
for (const dim of hierarchyData.value) {
const dimRow = { ...dim, _type: 'dimension', _children: [], __uid: `dim_${++uid}` }
for (const obj of dim.objectives) {
const objRow = { ...obj, _type: 'objective', _children: [], __uid: `obj_${++uid}` }
for (const kpi of obj.kpis) {
objRow._children.push({
...kpi,
_type: 'kpi',
_children: undefined,
__uid: `kpi_${++uid}_${kpi.kpi_id}`
})
}
dimRow._children.push(objRow)
}
result.push(dimRow)
}
return result
})
// ── 工具函数 ──
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
function dimLabel(d: string) { return dimMap[d] || d }
function dimTag(d: string) { return dimTagMap[d] || 'info' }
function levelTag(level: string) {
if (level === 'red') return 'danger'
if (level === 'yellow') return 'warning'
if (level === 'green') return 'success'
return 'info'
}
function deviationTag(rate: number) {
if (rate > 20) return 'danger'
if (rate > 10) return 'warning'
return 'success'
}
function formatValue(v: any) {
function fmtVal(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// ── 筛选变更(前端过滤树) ──
function filterChanged() {
// 维度或预警级别变化时,后端的hierarchical接口不支持前端过滤
// 重新加载(后端过滤)
loadReport()
}
function scrollToDim(key: string) {
// 展开所有,让目标维度可见
expandedAll.value = true
}
function onRowClick(row: any) {
// KPI级点击跳到详情
if (row._type === 'kpi' && row.kpi_id) {
window.open('/#/kpis/' + row.kpi_id, '_blank')
}
}
// ── 加载差异报告 ──
async function loadReport() {
loading.value = true
try {
const params: any = {}
const params: any = { hierarchical: true }
if (filterYear.value) params.year = filterYear.value
if (filterMonth.value) params.month = filterMonth.value
if (filterDimension.value) params.dimension = filterDimension.value
if (alertFilter.value) params.alert_level = alertFilter.value
const r: any = await budgetApi.deviationReport(params)
const data = r.data || r || []
if (Array.isArray(data)) {
reportData.value = data
} else if (data.items) {
reportData.value = data.items
} else {
reportData.value = []
reportData.value = r.items || []
hierarchyData.value = r.hierarchy || []
stats.value = r.summary || {}
// 如果hierarchy有数据,用层级中的统计补全stats
if (hierarchyData.value.length > 0) {
let total = 0, hasBudget = 0
for (const dim of hierarchyData.value) {
total += dim.summary.count || 0
hasBudget += dim.summary.has_budget || 0
}
stats.value.total_kpis = total
stats.value.has_budget = hasBudget
}
// 统计
stats.value = r.stats || r.statistics || {}
// 如果后端没返回统计,自己算
if (!stats.value.total_budget && reportData.value.length > 0) {
const items = reportData.value
stats.value.total_budget = items.length
stats.value.red_count = items.filter((i: any) => (i.deviation_rate || 0) > 20).length
stats.value.yellow_count = items.filter((i: any) => (i.deviation_rate || 0) > 10 && (i.deviation_rate || 0) <= 20).length
stats.value.normal_count = items.filter((i: any) => (i.deviation_rate || 0) <= 10).length
}
} catch (e) { ElMessage.error('加载差异报告失败') }
} catch (e) {
ElMessage.error('加载差异报告失败')
}
loading.value = false
}
@@ -250,7 +336,6 @@ async function loadTrend() {
const r: any = await budgetApi.deviationReport({ kpi_id: trendKpi.value })
const data = r.data || r || []
const items = Array.isArray(data) ? data : (data.items || [])
// 取各月数据
const monthly = items.filter((i: any) => i.budget_value !== null)
.sort((a: any, b: any) => {
const keyA = a.period || ''
@@ -275,18 +360,25 @@ function barHeight(val: number, max: number) {
// ── 导出CSV ──
function exportReport() {
const rows = reportData.value
if (rows.length === 0) { ElMessage.warning('无数据可导出'); return }
const headers = ['KPI编码', 'KPI名称', '维度', '期间', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
const dims = hierarchyData.value
if (dims.length === 0) { ElMessage.warning('无数据可导出'); return }
const headers = ['维度', '战略目标', 'KPI编码', 'KPI名称', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
const csvRows = [headers.join(',')]
for (const r of rows) {
csvRows.push([
r.kpi_code || '', `"${r.kpi_name || ''}"`, dimLabel(r.dimension || ''),
r.period || '', r.budget_value ?? '', r.actual_value ?? '',
r.deviation_amount ?? '', r.deviation_rate?.toFixed(2) ?? '',
r.yoy_rate?.toFixed(1) ?? '', r.mom_rate?.toFixed(1) ?? '',
r.alert_level || ''
].join(','))
for (const dim of dims) {
for (const obj of dim.objectives) {
if (obj.kpis.length === 0) {
csvRows.push([dim.name, `"${obj.name}"`].join(','))
}
for (const k of obj.kpis) {
csvRows.push([
dim.name, `"${obj.name}"`, k.kpi_code || '', `"${k.kpi_name || ''}"`,
k.budget_value ?? '', k.actual_value ?? '',
k.deviation_amount ?? '', k.deviation_rate?.toFixed(2) ?? '',
k.yoy?.diff_rate?.toFixed(1) ?? '', k.mom?.diff_rate?.toFixed(1) ?? '',
k.deviation_rate != null ? (k.deviation_rate > 20 ? '红色' : k.deviation_rate > 10 ? '黄色' : '正常') : ''
].join(','))
}
}
}
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
@@ -305,12 +397,13 @@ onMounted(() => {
</script>
<style scoped>
.stat-card { text-align: center; padding: 8px 0; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: bold; color: #303133; }
.stat-card { text-align: center; padding: 4px 0; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 6px; }
.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
.red-text { color: #f56c6c; }
.yellow-text { color: #e6a23c; }
.green-text { color: #67c23a; }
.gray-text { color: #909399; }
.trend-bar { width: 18px; border-radius: 3px 3px 0 0; transition: height 0.3s; }
.actual-bar { background: #409eff; }
.budget-bar { background: #e6a23c; }