Files
cma-management/frontend/src/views/DeviationDashboard.vue.bak
T

411 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<h3>差异分析看板</h3>
<!-- 筛选栏 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadReport">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
<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="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="filterChanged">
<el-option label="全部" value="" />
<el-option label="红色预警(超20%" value="red" />
<el-option label="黄色预警(超10%" value="yellow" />
<el-option label="正常(10%以内)" value="normal" />
</el-select>
<el-button type="primary" @click="loadReport">刷新</el-button>
<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_kpis || 0 }}</div>
</div>
</el-card>
</el-col>
<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">{{ 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>
</el-row>
<!-- 差异趋势图(简易柱状图) -->
<el-card style="margin-bottom:16px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>差异趋势(各月实际 vs 预算)</span>
<el-select v-model="trendKpi" placeholder="选择KPI" style="width:220px;" @change="loadTrend">
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
</el-select>
</div>
</template>
<div v-if="trendData.length > 0" style="display:flex;align-items:flex-end;gap:8px;height:200px;padding:20px 0;overflow-x:auto;">
<div v-for="(item, idx) in trendData" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:60px;">
<div :title="`实际: ${item.actual}, 预算: ${item.budget}`" class="trend-bar-group" style="display:flex;gap:3px;height:160px;align-items:flex-end;">
<div
class="trend-bar actual-bar"
:style="{ height: barHeight(item.actual, maxTrend) + 'px' }"
:title="`实际 ${item.actual}`"
></div>
<div
class="trend-bar budget-bar"
:style="{ height: barHeight(item.budget, maxTrend) + 'px' }"
:title="`预算 ${item.budget}`"
></div>
</div>
<span style="font-size:11px;color:#909399;margin-top:4px;">{{ item.period }}</span>
<span v-if="item.deviation !== undefined" :style="{ fontSize:'10px', color: item.deviation > 0 ? '#f56c6c' : '#67c23a' }">
{{ item.deviation > 0 ? '+' : '' }}{{ item.deviation.toFixed(1) }}%
</span>
</div>
</div>
<div v-else style="text-align:center;color:#999;padding:40px;">
{{ trendLoading ? '加载中...' : '选择一个KPI查看趋势' }}
</div>
</el-card>
<!-- 维度→目标→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 && hierarchyData.length === 0 && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
const currentYear = new Date().getFullYear()
const yearOptions = computed(() => {
const years: number[] = []
for (let y = currentYear - 2; y <= currentYear + 2; y++) years.push(y)
return years
})
const filterYear = ref(currentYear)
const filterMonth = ref(new Date().getMonth() + 1)
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 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 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 = { 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)
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
}
} catch (e) {
ElMessage.error('加载差异报告失败')
}
loading.value = false
}
// ── 趋势图 ──
const trendKpi = ref<number | null>(null)
const kpiOptions = ref<any[]>([])
const trendData = ref<any[]>([])
const trendLoading = ref(false)
const maxTrend = ref(0)
async function loadKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200 })
const data = r.data || r || []
kpiOptions.value = Array.isArray(data) ? data : (data.items || [])
} catch (e) { /* ignore */ }
}
async function loadTrend() {
if (!trendKpi.value) { trendData.value = []; return }
trendLoading.value = true
try {
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 || ''
const keyB = b.period || ''
return keyA.localeCompare(keyB)
})
trendData.value = monthly.map((i: any) => ({
period: i.period,
actual: i.actual_value || 0,
budget: i.budget_value || 0,
deviation: i.deviation_rate,
}))
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
} catch (e) { /* ignore */ }
trendLoading.value = false
}
function barHeight(val: number, max: number) {
if (!max) return 0
return Math.max(4, (val / max) * 150)
}
// ── 导出CSV ──
function exportReport() {
const dims = hierarchyData.value
if (dims.length === 0) { ElMessage.warning('无数据可导出'); return }
const headers = ['维度', '战略目标', 'KPI编码', 'KPI名称', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
const csvRows = [headers.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)
const a = document.createElement('a')
a.href = url
a.download = `偏差分析报告_${filterYear.value}年${filterMonth.value}月.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
onMounted(() => {
loadReport()
loadKpiOptions()
})
</script>
<style scoped>
.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; }
</style>