init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
<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="loadReport">
|
||||
<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-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_budget || 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 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>
|
||||
</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>
|
||||
|
||||
<!-- 差异明细表格 -->
|
||||
<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>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<el-empty v-if="!loading && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } 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 stats = ref<any>({})
|
||||
|
||||
// ── 维度标签 ──
|
||||
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 deviationTag(rate: number) {
|
||||
if (rate > 20) return 'danger'
|
||||
if (rate > 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function formatValue(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// ── 加载差异报告 ──
|
||||
async function loadReport() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
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 = []
|
||||
}
|
||||
// 统计
|
||||
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('加载差异报告失败') }
|
||||
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 rows = reportData.value
|
||||
if (rows.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(','))
|
||||
}
|
||||
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: 8px 0; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: bold; color: #303133; }
|
||||
.red-text { color: #f56c6c; }
|
||||
.yellow-text { color: #e6a23c; }
|
||||
.green-text { color: #67c23a; }
|
||||
.trend-bar { width: 18px; border-radius: 3px 3px 0 0; transition: height 0.3s; }
|
||||
.actual-bar { background: #409eff; }
|
||||
.budget-bar { background: #e6a23c; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user