init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
+565
View File
@@ -0,0 +1,565 @@
<template>
<div class="kpi-dict-page">
<!-- 页头 -->
<div class="page-header">
<h3>KPI字典</h3>
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
</div>
<div class="main-layout">
<!-- 左侧BSC分类导航树 -->
<div class="left-tree">
<div class="tree-header">BSC分类导航</div>
<el-tree
ref="treeRef"
:data="categoryTree"
node-key="key"
:props="{ label: 'label', children: 'children' }"
:default-expanded-keys="defaultExpanded"
:show-checkbox="true"
:check-strictly="true"
@check="onTreeCheck"
highlight-current
>
<template #default="{ node, data }">
<span class="tree-node">
<span class="tree-node-label">{{ data.label }}</span>
<el-tag size="small" :type="data.children && data.children.length > 0 ? '' : 'info'" class="tree-count">{{ data.count }}</el-tag>
</span>
</template>
</el-tree>
</div>
<!-- 右侧搜索+表格 -->
<div class="right-content">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchKeyword" placeholder="搜索KPI名称/编码" clearable style="width:220px" @clear="loadKpis" @keyup.enter="loadKpis" />
<el-select v-model="searchDimension" placeholder="维度" clearable style="width:110px" @change="loadKpis">
<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="searchCategory" placeholder="二级类别" clearable style="width:140px" @change="loadKpis">
<el-option v-for="c in allCategories" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-button type="primary" @click="loadKpis">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 批量操作栏 -->
<div class="batch-bar" v-if="selectedIds.length > 0">
<span class="selected-info">已选 {{ selectedIds.length }} </span>
<el-button size="small" @click="batchDelete">批量删除</el-button>
<el-button size="small" @click="batchExport">导出CSV</el-button>
<el-button size="small" @click="selectedIds=[]">取消选择</el-button>
</div>
<!-- 表格 -->
<el-table
:data="kpis"
v-loading="loading"
style="width:100%"
@selection-change="onSelectionChange"
@row-click="(row) => $router.push('/kpis/' + row.id)"
border
stripe
size="small"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="kpi_code" label="编码" width="120">
<template #default="{ row }">
<span class="kpi-code">{{ row.kpi_code }}</span>
</template>
</el-table-column>
<el-table-column prop="kpi_name" label="名称" min-width="170" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="二级类别" width="90">
<template #default="{ row }">{{ catLabel(row.category) }}</template>
</el-table-column>
<el-table-column prop="target_value" label="目标值" width="90">
<template #default="{ row }">{{ formatTarget(row.target_value, row.unit) }}</template>
</el-table-column>
<el-table-column prop="frequency" label="频率" width="70">
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
</el-table-column>
<el-table-column prop="data_source_type" label="数据源" width="70">
<template #default="{ row }">{{ sourceLabel(row.data_source_type) }}</template>
</el-table-column>
<el-table-column label="当前值" width="100">
<template #default="{ row }">
<span v-if="row.actual_value !== null" :class="'status-' + (row.alert_level || 'none')">
{{ row.actual_value }}{{ row.unit }}
</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="预警" width="70">
<template #default="{ row }">
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
{{ row.alert_level === 'red' ? '红灯' : row.alert_level === 'yellow' ? '黄灯' : '绿灯' }}
</el-tag>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click.stop="$router.push('/kpis/' + row.id)">编辑</el-button>
<el-button size="small" text type="danger" @click.stop="doDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<span class="total-info">共 {{ total }} 条</span>
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadKpis"
/>
</div>
</div>
</div>
<!-- 新建/编辑对话框 -->
<MyDialog v-model="showForm" :title="editMode ? '编辑KPI' : '新建KPI'" :width="640">
<el-form :model="form" label-width="110px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="KPI编码"><el-input v-model="form.kpi_code" :disabled="editMode" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="KPI名称"><el-input v-model="form.kpi_name" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="维度">
<el-select v-model="form.dimension" style="width:100%" @change="onDimensionChange">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="二级类别">
<el-select v-model="form.category" style="width:100%" :disabled="!form.dimension">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标值"><el-input-number v-model="form.target_value" :min="0" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, , , ..." /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="频率">
<el-select v-model="form.frequency" style="width:100%">
<el-option label="月度" value="monthly" />
<el-option label="季度" value="quarterly" />
<el-option label="年度" value="yearly" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据源">
<el-select v-model="form.data_source_type" style="width:100%">
<el-option label="ERP自动" value="erp" />
<el-option label="Excel导入" value="excel" />
<el-option label="手工填报" value="manual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="负责部门"><el-input v-model="form.responsible_dept" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人"><el-input v-model="form.responsible_user" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="计算公式"><el-input v-model="form.formula" type="textarea" :rows="2" /></el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="绿灯阈值"><el-input v-model="form.threshold_green" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="黄灯阈值"><el-input v-model="form.threshold_yellow" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="红灯阈值"><el-input v-model="form.threshold_red" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="所属Epic">
<el-select v-model="form.epic" style="width:100%">
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm=false">取消</el-button>
<el-button type="primary" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { kpiApi, dashboardApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
// ── 数据 ──
const kpis = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 20
// ── 搜索筛选 ──
const searchKeyword = ref('')
const searchDimension = ref('')
const searchCategory = ref('')
// ── 分类树 ──
const categoryTree = ref<any[]>([])
const defaultExpanded = ref<string[]>([])
const treeRef = ref<any>(null)
const treeFilterDims = ref<string[]>([])
const treeFilterCats = ref<string[]>([])
// ── 多选 ──
const selectedIds = ref<number[]>([])
// ── 新建/编辑表单 ──
const showForm = ref(false)
const editMode = ref(false)
const form = ref<any>({})
// ── 维度映射 ──
const DIM_MAP: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const CAT_MAP: Record<string, string> = {
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
asset_efficiency: '资产效率', cash_risk: '现金流风控',
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
supply_chain: '供应链效率', delivery_quality: '交付质量',
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation: '创新改善',
}
const FREQ_MAP: Record<string, string> = { daily: '日', weekly: '周', monthly: '月', quarterly: '季', yearly: '年' }
const SOURCE_MAP: Record<string, string> = { erp: 'ERP', excel: 'Excel', manual: '手工' }
// 维度→二级类别 对照映射
const DIM_CAT_MAP: Record<string, string[]> = {
finance: ['revenue_growth', 'profitability', 'cost_control', 'asset_efficiency', 'cash_risk'],
customer: ['customer_scale', 'customer_concentration', 'customer_satisfaction'],
process: ['supply_chain', 'delivery_quality'],
learning: ['talent_pipeline', 'employee_engagement', 'innovation'],
}
// 二级类别选项(联动维度)
const categoryOptions = computed(() => {
const dim = form.value?.dimension
if (!dim) return []
const catKeys = DIM_CAT_MAP[dim] || []
return catKeys.map(key => ({ value: key, label: CAT_MAP[key] || key }))
})
// 所有二级类别(搜索栏用)
const allCategories = computed(() => {
const seen = new Set<string>()
const result: { value: string; label: string }[] = []
for (const key in DIM_CAT_MAP) {
for (const cat of DIM_CAT_MAP[key]) {
if (!seen.has(cat)) {
seen.add(cat)
result.push({ value: cat, label: CAT_MAP[cat] || cat })
}
}
}
return result
})
function dimLabel(d: string) { return DIM_MAP[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c }
function freqLabel(f: string) { return FREQ_MAP[f] || f }
function sourceLabel(s: string) { return SOURCE_MAP[s] || s }
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
function formatTarget(val: number, unit: string) {
if (val === null || val === undefined) return '-'
if (val >= 10000) return (val / 10000).toFixed(0) + '万' + (unit || '')
return val + (unit || '')
}
// ── 加载分类树 ──
async function loadCategories() {
try {
const r: any = await kpiApi.listCategories()
categoryTree.value = [
{ key: '_all', label: '全部', count: r.total, children: r.tree || [] }
]
defaultExpanded.value = ['_all', ...(r.tree || []).map((n: any) => n.key)]
} catch (e) {
console.error('加载分类树失败', e)
}
}
// ── 树节点勾选 ──
function onTreeCheck(data: any, { checkedKeys }: any) {
// 简单模式:只支持单选过滤
if (data.key === '_all') {
// 勾选全部=清空过滤
treeFilterDims.value = []
treeFilterCats.value = []
} else if (data.children) {
// 维度节点
treeFilterDims.value = checkedKeys.includes(data.key) ? [data.key] : []
treeFilterCats.value = []
} else {
// 类别节点
treeFilterCats.value = checkedKeys.includes(data.key) ? [data.key] : []
treeFilterDims.value = []
}
loadKpis()
}
// ── 加载KPI列表 ──
async function loadKpis() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (searchKeyword.value) params.keyword = searchKeyword.value
if (searchDimension.value) params.dimension = searchDimension.value
if (searchCategory.value) params.category = searchCategory.value
if (treeFilterDims.value.length > 0 && !params.dimension) params.dimension = treeFilterDims.value[0]
if (treeFilterCats.value.length > 0 && !params.category) params.category = treeFilterCats.value[0]
const r: any = await kpiApi.list(params)
kpis.value = r.data || []
total.value = r.total || 0
} catch (e) {
console.error('加载KPI失败', e)
}
loading.value = false
}
// 获取当前值
async function loadActualValues() {
try {
const r: any = await dashboardApi.kpis({ role: 'ceo', period: 'month' })
const vals = r.data || []
const valMap: Record<number, any> = {}
for (const v of vals) {
valMap[v.id] = v
}
for (const kpi of kpis.value) {
if (valMap[kpi.id]) {
kpi.actual_value = valMap[kpi.id].actual_value
kpi.alert_level = valMap[kpi.id].alert_level
}
}
} catch (e) {
// 静默失败
}
}
function resetSearch() {
searchKeyword.value = ''
searchDimension.value = ''
searchCategory.value = ''
treeFilterDims.value = []
treeFilterCats.value = []
if (treeRef.value) treeRef.value.setCheckedKeys([])
page.value = 1
loadKpis()
}
// ── 多选 ──
function onSelectionChange(rows: any[]) {
selectedIds.value = rows.map((r: any) => r.id)
}
function batchDelete() {
ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 条KPI`, '提示').then(async () => {
for (const id of selectedIds.value) {
try { await kpiApi.delete(id) } catch (e) {}
}
ElMessage.success('批量删除完成')
selectedIds.value = []
loadKpis()
loadCategories()
}).catch(() => {})
}
function batchExport() {
const header = '编码,名称,维度,二级类别,目标值,频率,数据源,负责人,计算公式'
const rows = kpis.value.map((k: any) =>
[k.kpi_code, k.kpi_name, dimLabel(k.dimension), catLabel(k.category), k.target_value, freqLabel(k.frequency), sourceLabel(k.data_source_type), k.responsible_user, (k.formula || '')].join(',')
)
const bom = '\uFEFF' // Excel UTF-8 BOM
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'KPI字典_' + new Date().toISOString().slice(0, 10) + '.csv'
a.click()
URL.revokeObjectURL(url)
}
// ── 新建/保存 ──
function onDimensionChange(val: string) {
form.value.category = ''
}
async function saveKPI() {
try {
if (editMode.value) {
await kpiApi.update(form.value.id, form.value)
ElMessage.success('保存成功')
} else {
await kpiApi.create(form.value)
ElMessage.success('创建成功')
}
showForm.value = false
loadKpis()
loadCategories()
} catch (e) {
ElMessage.error(editMode.value ? '保存失败' : '创建失败')
}
}
function doDelete(id: number) {
ElMessageBox.confirm('确定删除此KPI', '提示').then(async () => {
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
catch (e) { ElMessage.error('删除失败') }
}).catch(() => {})
}
onMounted(() => {
loadCategories()
loadKpis()
})
</script>
<style scoped>
.kpi-dict-page {
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.main-layout {
display: flex;
gap: 12px;
flex: 1;
overflow: hidden;
}
.left-tree {
width: 260px;
min-width: 260px;
background: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
padding: 8px 0;
overflow-y: auto;
}
.tree-header {
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 4px;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding-right: 8px;
}
.tree-node-label {
font-size: 13px;
}
.tree-count {
font-size: 11px;
min-width: 20px;
text-align: center;
}
.right-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #ecf5ff;
border-radius: 4px;
margin-bottom: 8px;
}
.selected-info {
font-size: 13px;
color: #409eff;
font-weight: 500;
}
.kpi-code {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #606266;
}
.status-red { color: #f56c6c; font-weight: 500; }
.status-yellow { color: #e6a23c; font-weight: 500; }
.status-green { color: #67c23a; font-weight: 500; }
.status-none { color: #606266; }
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.total-info {
font-size: 13px;
color: #909399;
}
:deep(.el-tree-node__content) {
height: 32px;
}
</style>