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:
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""补充非财务KPI预算数据 — 客户/流程/学习维度
|
||||
运行: python3 scripts/seed_nonfinance_budget.py
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, BudgetPlan
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger("seed_nonfinance")
|
||||
|
||||
def seed():
|
||||
db = get_session_local()()
|
||||
try:
|
||||
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
|
||||
codes = ["C_SATISFACTION", "C_NEW_CLIENTS", "P_DELIVERY", "P_BUG_RATE", "L_TRAINING", "L_EMPLOYEE_SAT"]
|
||||
# 年度预算值
|
||||
annual_budget = {
|
||||
"C_SATISFACTION": 92, "C_NEW_CLIENTS": 120,
|
||||
"P_DELIVERY": 95, "P_BUG_RATE": 2,
|
||||
"L_TRAINING": 90, "L_EMPLOYEE_SAT": 85,
|
||||
}
|
||||
cnt = 0
|
||||
existing = set((r.kpi_id, r.period) for r in db.query(BudgetPlan).all())
|
||||
for code in codes:
|
||||
kid = kpi_map.get(code)
|
||||
if not kid: continue
|
||||
annual = annual_budget[code]
|
||||
for month in range(1, 13):
|
||||
if (kid, f"2026-{month:02d}") in existing:
|
||||
continue
|
||||
db.add(BudgetPlan(
|
||||
kpi_id=kid, period=f"2026-{month:02d}",
|
||||
budget_value=annual if code != "C_NEW_CLIENTS" else annual / 12,
|
||||
budget_year=2026, budget_month=month,
|
||||
version="v1.0", status="active",
|
||||
))
|
||||
cnt += 1
|
||||
db.commit()
|
||||
logger.info(f"✅ 补充非财务预算: {cnt}条")
|
||||
logger.info(f"📊 预算总数: {db.query(BudgetPlan).count()}条")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"❌ 失败: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 管理会计OS — 一键构建并部署到生产
|
||||
# 管理会计OS — 一键拉取、构建并部署到生产
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
@@ -7,6 +7,12 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
echo "===== 管理会计OS 生产部署 ====="
|
||||
echo ""
|
||||
|
||||
# 0. 拉取最新代码
|
||||
echo "[0/4] 拉取最新代码..."
|
||||
cd "$SCRIPT_DIR"
|
||||
git pull origin main
|
||||
echo " ✓ 代码已更新"
|
||||
|
||||
# 1. 构建前端
|
||||
echo "[1/4] 构建前端..."
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
|
||||
Vendored
+2
@@ -47,6 +47,7 @@ declare module 'vue' {
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
@@ -54,6 +55,7 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
GaugeChart: typeof import('./src/components/charts/GaugeChart.vue')['default']
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
<template>
|
||||
<div v-if="visible" class="mc-dialog-overlay" @click.self="emit('update:visible', false)">
|
||||
<div v-if="visible" class="mc-dialog-overlay" @click.self="$emit('close')">
|
||||
<div class="mc-dialog-box">
|
||||
<div class="mc-dialog-header">
|
||||
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
|
||||
<button class="mc-dialog-close" @click="emit('update:visible', false)">×</button>
|
||||
<button class="mc-dialog-close" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="mc-dialog-body">
|
||||
<!-- 目标名称(必填) -->
|
||||
<div class="mc-form-item">
|
||||
<label>目标名称 <span class="mc-required">*</span></label>
|
||||
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="mc-form-item">
|
||||
<label>描述</label>
|
||||
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="2" placeholder="目标描述(可选)"></textarea>
|
||||
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 目标值 -->
|
||||
<div class="mc-form-item">
|
||||
<label>目标值</label>
|
||||
<input v-model.number="localForm.targetValue" class="mc-input" type="number" placeholder="预期达成值" />
|
||||
</div>
|
||||
|
||||
<!-- 当前值 -->
|
||||
<div class="mc-form-item">
|
||||
<label>当前值</label>
|
||||
<input v-model.number="localForm.currentValue" class="mc-input" type="number" placeholder="当前实际值" />
|
||||
</div>
|
||||
|
||||
<!-- 单位 -->
|
||||
<div class="mc-form-row">
|
||||
<div class="mc-form-item mc-form-item-flex">
|
||||
<label>单位</label>
|
||||
<input v-model="localForm.unit" class="mc-input" placeholder="如:万元、%" />
|
||||
</div>
|
||||
<div class="mc-form-item mc-form-item-flex">
|
||||
<label>责任人</label>
|
||||
<input v-model="localForm.owner" class="mc-input" placeholder="责任人姓名" />
|
||||
<label>图标</label>
|
||||
<div class="mc-icon-picker">
|
||||
<button
|
||||
v-for="(ico, key) in iconOptions" :key="key"
|
||||
class="mc-icon-btn"
|
||||
:class="{ active: localForm.icon === key }"
|
||||
@click="localForm.icon = key"
|
||||
:title="ico"
|
||||
>{{ iconEmoji(key) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 领先/滞后指标 -->
|
||||
<div class="mc-form-item">
|
||||
<label>指标类型</label>
|
||||
<div class="mc-leading-switch">
|
||||
<label class="mc-radio-label" :class="{ active: !localForm.isLeading }">
|
||||
<input type="radio" v-model="localForm.isLeading" :value="false" />
|
||||
滞后指标(结果)
|
||||
</label>
|
||||
<label class="mc-radio-label" :class="{ active: localForm.isLeading }">
|
||||
<input type="radio" v-model="localForm.isLeading" :value="true" />
|
||||
领先指标(驱动)
|
||||
</label>
|
||||
<label>关联KPI <span class="mc-hint">(最多3个)</span></label>
|
||||
<div class="mc-kpi-select-wrap">
|
||||
<div class="mc-kpi-tags" v-if="localForm.kpis.length > 0">
|
||||
<span class="mc-kpi-tag" v-for="(code, i) in localForm.kpis" :key="code">
|
||||
{{ getKpiName(code) }}
|
||||
<button class="mc-tag-remove" @click="localForm.kpis.splice(i, 1)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mc-kpi-add-row" v-if="localForm.kpis.length < 3 && allKpis.length > 0">
|
||||
<select class="mc-select mc-select-sm" @change="addKpi($event)" style="flex:1;">
|
||||
<option value="">+ 选择KPI({{ dimLabel }})</option>
|
||||
<option v-for="k in availableKpis" :key="k.id" :value="k.kpi_code">{{ k.kpi_name }}</option>
|
||||
<option value="__new__">+ 新建KPI...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="allKpis.length === 0" style="color:#999;font-size:12px;">
|
||||
暂无该维度KPI,
|
||||
<el-button text size="small" type="primary" @click="onNewKpi">点击新建</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-dialog-footer">
|
||||
<button class="mc-btn" @click="emit('update:visible', false)">取消</button>
|
||||
<button class="mc-btn" @click="$emit('close')">取消</button>
|
||||
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,83 +58,80 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
nodeData: any | null
|
||||
layerKey: string
|
||||
form: { name: string; description: string; icon: string; kpis: string[] }
|
||||
dimKey: string
|
||||
dimLabel: string
|
||||
isEditing: boolean
|
||||
allKpis: any[]
|
||||
kpiNameMap: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
(e: 'save', data: {
|
||||
name: string
|
||||
description: string
|
||||
targetValue: number | null
|
||||
currentValue: number | null
|
||||
unit: string
|
||||
owner: string
|
||||
isLeading: boolean
|
||||
layer: string
|
||||
icon?: string
|
||||
kpis?: string[]
|
||||
}): void
|
||||
(e: 'close'): void
|
||||
(e: 'save', form: { name: string; description: string; icon: string; kpis: string[] }): void
|
||||
(e: 'new-kpi'): void
|
||||
}>()
|
||||
|
||||
const nameInputRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const defaultForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
targetValue: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
unit: '%',
|
||||
owner: '',
|
||||
isLeading: false,
|
||||
icon: 'target',
|
||||
kpis: [] as string[],
|
||||
}
|
||||
|
||||
const localForm = reactive({ ...defaultForm })
|
||||
|
||||
const isEditing = ref(false)
|
||||
const localForm = reactive({ name: '', description: '', icon: 'target', kpis: [] as string[] })
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
if (props.nodeData) {
|
||||
// 编辑模式
|
||||
isEditing.value = true
|
||||
localForm.name = props.nodeData.name || ''
|
||||
localForm.description = props.nodeData.description || ''
|
||||
localForm.targetValue = props.nodeData.targetValue ?? null
|
||||
localForm.currentValue = props.nodeData.currentValue ?? null
|
||||
localForm.unit = props.nodeData.unit || '%'
|
||||
localForm.owner = props.nodeData.owner || ''
|
||||
localForm.isLeading = props.nodeData.isLeading ?? false
|
||||
localForm.icon = props.nodeData.icon || 'target'
|
||||
localForm.kpis = props.nodeData.kpis ? [...props.nodeData.kpis] : []
|
||||
} else {
|
||||
// 新增模式 - 根据层智能设置默认单位
|
||||
isEditing.value = false
|
||||
Object.assign(localForm, { ...defaultForm })
|
||||
const layerDefaults: Record<string, { unit: string; isLeading: boolean }> = {
|
||||
financial: { unit: '万元', isLeading: false },
|
||||
customer: { unit: '%', isLeading: true },
|
||||
process: { unit: '%', isLeading: true },
|
||||
learning: { unit: '%', isLeading: true },
|
||||
}
|
||||
const defaults = layerDefaults[props.layerKey]
|
||||
if (defaults) {
|
||||
localForm.unit = defaults.unit
|
||||
localForm.isLeading = defaults.isLeading
|
||||
}
|
||||
}
|
||||
localForm.name = props.form.name
|
||||
localForm.description = props.form.description
|
||||
localForm.icon = props.form.icon || 'target'
|
||||
localForm.kpis = [...(props.form.kpis || [])]
|
||||
nextTick(() => nameInputRef.value?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
const iconOptions: Record<string, string> = {
|
||||
target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队',
|
||||
light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌',
|
||||
}
|
||||
|
||||
function iconEmoji(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥',
|
||||
light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅',
|
||||
}
|
||||
return map[key] || '📌'
|
||||
}
|
||||
|
||||
const availableKpis = computed(() => {
|
||||
return props.allKpis.filter((k: any) => {
|
||||
if (k.dimension !== props.dimKey) return false
|
||||
if (localForm.kpis.includes(k.kpi_code)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function getKpiName(code: string): string {
|
||||
return props.kpiNameMap[code] || code
|
||||
}
|
||||
|
||||
function addKpi(e: any) {
|
||||
const val = e.target.value
|
||||
if (val === '__new__') {
|
||||
e.target.value = ''
|
||||
emit('new-kpi')
|
||||
return
|
||||
}
|
||||
if (val && !localForm.kpis.includes(val) && localForm.kpis.length < 3) {
|
||||
localForm.kpis.push(val)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function onNewKpi() {
|
||||
emit('new-kpi')
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!localForm.name?.trim()) {
|
||||
ElMessage.warning('请输入目标名称')
|
||||
@@ -152,12 +141,6 @@ function onSave() {
|
||||
emit('save', {
|
||||
name: localForm.name.trim(),
|
||||
description: localForm.description?.trim() || '',
|
||||
targetValue: localForm.targetValue ?? null,
|
||||
currentValue: localForm.currentValue ?? null,
|
||||
unit: localForm.unit?.trim() || '%',
|
||||
owner: localForm.owner?.trim() || '',
|
||||
isLeading: localForm.isLeading ?? false,
|
||||
layer: props.layerKey,
|
||||
icon: localForm.icon || 'target',
|
||||
kpis: [...localForm.kpis],
|
||||
})
|
||||
@@ -193,39 +176,46 @@ function onSave() {
|
||||
.mc-form-item label {
|
||||
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
|
||||
}
|
||||
.mc-form-row {
|
||||
display:flex; gap:12px;
|
||||
}
|
||||
.mc-form-item-flex {
|
||||
flex:1;
|
||||
}
|
||||
.mc-required { color:#f56c6c; margin-left:2px; }
|
||||
.mc-hint { font-weight:400; color:#999; font-size:12px; }
|
||||
.mc-input {
|
||||
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
|
||||
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.mc-input:focus { border-color:#409eff; }
|
||||
.mc-input[type="number"] { font-variant-numeric: tabular-nums; }
|
||||
.mc-textarea { resize:vertical; min-height:50px; font-family:inherit; }
|
||||
.mc-textarea { resize:vertical; min-height:60px; font-family:inherit; }
|
||||
.mc-select {
|
||||
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
|
||||
border-radius:6px; font-size:14px; outline:none; background:#fff; box-sizing:border-box;
|
||||
}
|
||||
.mc-select:focus { border-color:#409eff; }
|
||||
.mc-select-sm { font-size:13px; padding:6px 10px; }
|
||||
.mc-btn {
|
||||
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
|
||||
background:#fff; color:#333; font-size:14px; cursor:pointer;
|
||||
}
|
||||
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
|
||||
.mc-btn:hover { opacity:0.85; }
|
||||
.mc-leading-switch {
|
||||
display:flex; gap:12px;
|
||||
.mc-icon-picker {
|
||||
display:flex; flex-wrap:wrap; gap:6px;
|
||||
}
|
||||
.mc-radio-label {
|
||||
display:flex; align-items:center; gap:6px;
|
||||
padding:8px 14px; border:2px solid #e8e8e8; border-radius:8px;
|
||||
cursor:pointer; font-size:13px; color:#666; transition:all .15s;
|
||||
flex:1; justify-content:center;
|
||||
.mc-icon-btn {
|
||||
width:40px; height:40px; display:flex; align-items:center; justify-content:center;
|
||||
font-size:20px; border:2px solid #e8e8e8; border-radius:8px;
|
||||
background:#fff; cursor:pointer; transition:all .15s;
|
||||
}
|
||||
.mc-radio-label:hover { border-color:#409eff; }
|
||||
.mc-radio-label.active {
|
||||
border-color:#409eff; background:#ecf5ff; color:#409eff; font-weight:500;
|
||||
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
|
||||
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; box-shadow:0 0 0 2px rgba(64,158,255,0.2); }
|
||||
.mc-kpi-select-wrap { display:flex; flex-direction:column; gap:6px; }
|
||||
.mc-kpi-tags { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.mc-kpi-tag {
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
padding:4px 10px; background:#ecf5ff; color:#409eff;
|
||||
border-radius:4px; font-size:12px;
|
||||
}
|
||||
.mc-radio-label input { display:none; }
|
||||
.mc-tag-remove {
|
||||
background:none; border:none; color:#a0cfff; cursor:pointer;
|
||||
font-size:14px; padding:0; line-height:1;
|
||||
}
|
||||
.mc-tag-remove:hover { color:#f56c6c; }
|
||||
</style>
|
||||
|
||||
@@ -1,311 +1,475 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预算管理</h3>
|
||||
|
||||
<div v-if="noMap" style="padding:60px 0;text-align:center;color:#999;">
|
||||
<p style="font-size:16px;margin-bottom:12px;">暂无已发布的战略地图</p>
|
||||
<p style="font-size:13px;">预算编制需要基于已发布的战略地图,请先在【战略地图】中创建并发布</p>
|
||||
<el-button type="primary" style="margin-top:16px;" @click="$router.push('/maps')">去创建战略地图</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: 预算录入 -->
|
||||
<el-tab-pane label="预算录入" name="input">
|
||||
<!-- 筛选栏 -->
|
||||
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
选择战略地图
|
||||
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
|
||||
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
|
||||
<span style="color:#999;">共 {{ total }} 条KPI</span>
|
||||
</div>
|
||||
<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="loadBudget">
|
||||
<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="loadBudget">
|
||||
<el-option label="全年" :value="0" />
|
||||
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
|
||||
</el-select>
|
||||
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><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="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
|
||||
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
|
||||
<el-button type="primary" @click="loadBudget">查询</el-button>
|
||||
<el-button @click="showBatchForm = true">批量录入</el-button>
|
||||
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
|
||||
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
|
||||
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
|
||||
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
|
||||
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 预算表格 -->
|
||||
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;">
|
||||
<template v-if="budgetViewMode === 'list'">
|
||||
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
|
||||
<el-table-column type="index" label="#" width="40" />
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
|
||||
<el-table-column prop="dimension" label="维度" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="budget_value" label="预算值" width="140">
|
||||
<el-table-column prop="budget_value" label="预算值" width="170">
|
||||
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<!-- 行动方案列(非财务维度显示) -->
|
||||
<el-table-column label="行动方案" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-if="row.editing" v-model="row.editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:130px;" />
|
||||
<span v-else>{{ formatNumber(row.budget_value) }}</span>
|
||||
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;">—</span>
|
||||
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
|
||||
{{ row._actionCount != null ? `${row._actionCount}项` : '关联' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version" label="版本" width="70" />
|
||||
<el-table-column prop="status" label="状态" width="70">
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.editing" size="small" @click="startEdit(row)">编辑</el-button>
|
||||
<template v-else>
|
||||
<el-button size="small" type="primary" @click="saveEdit(row)" :loading="row.saving">保存</el-button>
|
||||
<el-button size="small" @click="cancelEdit(row)">取消</el-button>
|
||||
</template>
|
||||
<el-button size="small" type="danger" @click="doDelete(row)">删除</el-button>
|
||||
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
|
||||
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
|
||||
<el-button v-if="!batchEditMode && row.id" size="small" :type="row.dimension === 'finance' ? 'info' : 'danger'" :disabled="row.dimension === 'finance'" @click="row.dimension !== 'finance' && doDelete(row)">{{ row.dimension === 'finance' ? '财务指标' : '删除' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div style="margin-top:16px;display:flex;justify-content:flex-end;">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadBudget"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="budgetViewMode === 'summary'">
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
|
||||
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
|
||||
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="kpiCount" label="KPI数" width="80" />
|
||||
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
|
||||
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
|
||||
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 年度自动分解 -->
|
||||
<el-tab-pane label="年度分解" name="decompose">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>年度预算 → 月度自动分解</span>
|
||||
<el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 → 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
|
||||
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
|
||||
<el-form-item label="年份">
|
||||
<el-select v-model="decomposeForm.year" style="width:150px;">
|
||||
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分解方式">
|
||||
<el-radio-group v-model="decomposeForm.method">
|
||||
<el-radio value="equal">均分(1/12)</el-radio>
|
||||
<el-radio value="weighted">按历史权重</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
|
||||
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12)</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
|
||||
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
|
||||
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
|
||||
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="战略预算编制" name="strategy">
|
||||
<div v-if="!selectedMap" style="padding:40px 0;text-align:center;color:#999;"><p>请先在预算录入中选择战略地图</p></div>
|
||||
<template v-else>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<span style="font-weight:500;">{{ selectedMap.title }}</span><el-tag size="small" type="success">已发布</el-tag>
|
||||
<el-select v-model="strategyFilterYear" placeholder="年份" style="width:100px;" @change="loadStrategyBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="strategyFilterMonth" placeholder="月份" style="width:90px;" @change="loadStrategyBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
|
||||
<el-button type="primary" size="small" @click="loadStrategyBudget">刷新</el-button>
|
||||
<el-divider direction="vertical" /><span style="font-size:13px;color:#666;">已选 {{ strategySelectedKpis.length }} 个KPI</span>
|
||||
<el-button size="small" @click="strategyBatchSetSame">统一设值</el-button>
|
||||
<el-input-number v-model="strategyBatchValue" :min="0" controls-position="right" style="width:120px;" size="small" />
|
||||
<el-button v-if="strategySelectedKpis.length > 0" size="small" type="primary" :loading="strategyBatchSaving" @click="strategyBatchSave">批量保存</el-button>
|
||||
</div>
|
||||
<div v-for="dim in strategyTree" :key="dim.key" class="strategy-dim-block">
|
||||
<div class="strategy-dim-header" :style="{ borderLeftColor: dim.color }">
|
||||
<span class="strategy-dim-icon">{{ dim.icon }}</span><span class="strategy-dim-name">{{ dim.name }}</span>
|
||||
<span class="strategy-dim-summary">({{ dim.kpiCount }}个KPI · 预算合计:{{ formatNumber(dim.totalBudget) }})</span>
|
||||
<el-button size="small" link style="margin-left:auto;" @click="strategySelectDim(dim.key)">全选</el-button>
|
||||
</div>
|
||||
<div v-for="obj in dim.objectives" :key="obj.name" class="strategy-obj-block">
|
||||
<div class="strategy-obj-header"><span class="strategy-obj-name">{{ obj.name }}</span></div>
|
||||
<el-table :data="obj.kpis" border stripe size="small" style="width:100%;" :show-header="false" @selection-change="(sel: any[]) => onStrategyKpiSelect(sel, dim.key)">
|
||||
<el-table-column type="selection" width="36" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
|
||||
<el-table-column label="预算值" width="190">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:150px;" @change="row._changed = (row._editValue !== (row.budget_value ?? 0))" />
|
||||
<span style="font-size:12px;color:#999;margin-left:2px;">{{ row.unit }}</span>
|
||||
<el-button v-if="row._changed" size="small" type="primary" link @click="saveStrategyKpi(row)" :loading="row._saving" style="margin-left:4px;">保存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="period" label="期间" width="90" /><el-table-column prop="version" label="版本" width="60" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="版本管理" name="versions">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
|
||||
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-button type="primary" @click="loadVersions">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="approval_status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_count" label="预算条目数" width="100" />
|
||||
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
|
||||
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.approval_status === 'draft'" size="small" @click="submitVersion(row)">提交审批</el-button>
|
||||
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
|
||||
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
|
||||
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="预算执行" name="execution">
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
|
||||
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
|
||||
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
|
||||
</div>
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
|
||||
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
|
||||
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
|
||||
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
|
||||
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 批量录入弹窗 -->
|
||||
<MyDialog v-model="showBatchForm" title="批量录入预算" :width="600">
|
||||
<div style="margin-bottom:12px;">
|
||||
<p style="color:#666;font-size:13px;">从KPI字典中选择需要设置预算的KPI,统一填写预算值</p>
|
||||
<el-select v-model="batchYear" placeholder="年份" style="width:100px;margin-right:8px;">
|
||||
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
|
||||
</el-select>
|
||||
<el-select v-model="batchMonth" placeholder="月份" style="width:90px;">
|
||||
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="batchKpis" v-loading="batchLoading" border size="small" max-height="400" style="width:100%;" @selection-change="onBatchSelect">
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" />
|
||||
<el-table-column prop="kpi_name" label="名称" 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 label="预算值" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.batchValue" :min="0" :precision="0" controls-position="right" style="width:110px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:12px;display:flex;gap:12px;align-items:center;">
|
||||
<span style="font-size:13px;color:#666;">已选 {{ batchSelected.length }} 条</span>
|
||||
<el-button size="small" @click="batchSetSame">设为相同值</el-button>
|
||||
<el-input-number v-model="batchSameValue" :min="0" controls-position="right" style="width:130px;" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showBatchForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="doBatchCreate" :loading="batchSaving">批量创建</el-button>
|
||||
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
|
||||
<el-form :model="addForm" label-width="80px">
|
||||
<el-form-item label="KPI"><el-select v-model="addForm.kpi_id" placeholder="搜索并选择KPI" filterable remote :remote-method="searchKpiForAdd" :loading="addKpiLoading" style="width:100%;" :teleported="false"><el-option v-for="k in addKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" /></el-select></el-form-item>
|
||||
<el-form-item label="年份"><el-select v-model="addForm.year" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
|
||||
<el-form-item label="月份"><el-select v-model="addForm.month" style="width:120px;"><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select></el-form-item>
|
||||
<el-form-item label="预算值"><el-input-number v-model="addForm.value" :min="0" :precision="0" style="width:200px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showAddBudget = false">取消</el-button><el-button type="primary" @click="doAddBudget" :loading="addSaving">创建</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<MyDialog v-model="showVersionDiff" title="版本差异对比" :width="900">
|
||||
<template v-if="diffData">
|
||||
<el-alert show-icon :closable="false" style="margin-bottom:12px;"><template #title><span>对比 {{ diffData.summary.version_a }} → {{ diffData.summary.version_b }}</span></template></el-alert>
|
||||
<el-table :data="diffData.diffs" border stripe size="small" max-height="500" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="名称" min-width="140" /><el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="old_value" label="旧值" width="110"><template #default="{ row }">{{ formatNumber(row.old_value) }}</template></el-table-column>
|
||||
<el-table-column prop="new_value" label="新值" width="110"><template #default="{ row }">{{ formatNumber(row.new_value) }}</template></el-table-column>
|
||||
<el-table-column prop="diff_value" label="差值" width="110"><template #default="{ row }"><span :style="{color: row.diff_value > 0 ? '#f56c6c' : row.diff_value < 0 ? '#67c23a' : '#999'}">{{ row.diff_value > 0 ? '+' : '' }}{{ formatNumber(row.diff_value) }}</span></template></el-table-column>
|
||||
<el-table-column prop="diff_rate" label="变动率" width="90"><template #default="{ row }"><el-tag v-if="row.diff_rate === 0" size="small" type="info">持平</el-tag><el-tag v-else :type="row.diff_rate > 0 ? 'danger' : 'success'" size="small">{{ row.diff_rate > 0 ? '+' : '' }}{{ row.diff_rate }}%</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #footer><el-button @click="showVersionDiff = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 行动方案关联弹窗 -->
|
||||
<MyDialog v-model="showActionDialog" :title="actionDialogTitle" :width="600">
|
||||
<template v-if="actionPlansForKpi.length > 0">
|
||||
<div style="margin-bottom:12px;font-size:13px;color:#666;">已关联 {{ actionPlansForKpi.length }} 项行动方案</div>
|
||||
<div v-for="plan in actionPlansForKpi" :key="plan.id" style="border:1px solid #e8e8e8;border-radius:8px;padding:12px;margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
|
||||
<div>
|
||||
<div style="font-weight:500;">{{ plan.title }}</div>
|
||||
<div v-if="plan.description" style="font-size:12px;color:#888;margin-top:4px;">{{ plan.description }}</div>
|
||||
</div>
|
||||
<el-tag v-if="plan.status === 'completed'" type="success" size="small">已完成</el-tag>
|
||||
<el-tag v-else-if="plan.status === 'in_progress'" type="warning" size="small">进行中</el-tag>
|
||||
<el-tag v-else type="info" size="small">{{ plan.status }}</el-tag>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-top:8px;font-size:12px;color:#999;">
|
||||
<span>负责人: {{ plan.assignee || '未指定' }}</span>
|
||||
<span v-if="plan.due_date">截止: {{ plan.due_date?.slice(0,10) }}</span>
|
||||
<span>优先级: {{ plan.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else style="padding:40px 0;text-align:center;color:#999;">
|
||||
<p>暂无关联行动方案</p>
|
||||
<p style="font-size:12px;margin-top:4px;">非财务维度KPI需要配套行动方案才能将战略意图转化为实际行动</p>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<el-button type="primary" @click="goCreateActionPlan">+ 新建行动方案</el-button>
|
||||
<el-button @click="goActionPlansPage">去行动方案库查看</el-button>
|
||||
</div>
|
||||
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { budgetApi, kpiApi } from '../api/index'
|
||||
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const activeTab = ref('input')
|
||||
|
||||
// ── 年份/月份选项 ──
|
||||
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 yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
|
||||
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
|
||||
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
|
||||
const batchEditMode = ref(false); const batchSavingAll = ref(false)
|
||||
|
||||
const filterYear = ref(currentYear)
|
||||
const filterMonth = ref(0)
|
||||
const searchKpi = ref('')
|
||||
const loading = ref(false)
|
||||
const budgetList = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
// ── 战略地图选择 ──
|
||||
const publishedMaps = ref<any[]>([])
|
||||
const selectedMapId = ref<number | null>(null)
|
||||
const selectedMap = ref<any>(null)
|
||||
|
||||
// ── 维度标签 ──
|
||||
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 formatNumber(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// ── 加载预算列表 ──
|
||||
async function loadBudget() {
|
||||
loading.value = true
|
||||
async function loadPublishedMaps() {
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (filterYear.value) params.budget_year = filterYear.value
|
||||
if (filterMonth.value > 0) params.budget_month = filterMonth.value
|
||||
if (searchKpi.value) params.keyword = searchKpi.value
|
||||
const r: any = await budgetApi.list(params)
|
||||
const data = r.data || r || []
|
||||
// 如果后端返回分页格式
|
||||
if (Array.isArray(data)) {
|
||||
budgetList.value = data.map((item: any) => ({ ...item, editing: false, editValue: item.budget_value, saving: false }))
|
||||
total.value = data.length
|
||||
} else if (data.items) {
|
||||
budgetList.value = (data.items || []).map((item: any) => ({ ...item, editing: false, editValue: item.budget_value, saving: false }))
|
||||
total.value = data.total || data.items.length
|
||||
} else {
|
||||
budgetList.value = []
|
||||
total.value = 0
|
||||
const r: any = await mapApi.list(); const maps = Array.isArray(r) ? r : (r.data || r.items || [])
|
||||
const published = maps.filter((m: any) => m.status === 'published')
|
||||
.sort((a: any, b: any) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
|
||||
publishedMaps.value = published
|
||||
noMap.value = published.length === 0
|
||||
// 如果有默认选中的地图ID没变,保持
|
||||
if (selectedMapId.value && published.find(m => m.id === selectedMapId.value)) return
|
||||
// 默认选中第一个
|
||||
if (published.length > 0) {
|
||||
selectedMapId.value = published[0].id
|
||||
selectedMap.value = published[0]
|
||||
}
|
||||
} catch (e) { ElMessage.error('加载预算数据失败') }
|
||||
loading.value = false
|
||||
} catch { publishedMaps.value = []; noMap.value = true }
|
||||
}
|
||||
|
||||
// ── 编辑操作 ──
|
||||
function startEdit(row: any) {
|
||||
row.editing = true
|
||||
row.editValue = row.budget_value
|
||||
}
|
||||
|
||||
function cancelEdit(row: any) {
|
||||
row.editing = false
|
||||
row.editValue = row.budget_value
|
||||
}
|
||||
|
||||
async function saveEdit(row: any) {
|
||||
row.saving = true
|
||||
try {
|
||||
await budgetApi.update(row.id, { budget_value: row.editValue })
|
||||
ElMessage.success('已更新')
|
||||
row.budget_value = row.editValue
|
||||
row.editing = false
|
||||
} catch (e) { ElMessage.error('更新失败') }
|
||||
row.saving = false
|
||||
}
|
||||
|
||||
async function doDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认')
|
||||
await budgetApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadBudget()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 批量录入 ──
|
||||
const showBatchForm = ref(false)
|
||||
const batchYear = ref(currentYear)
|
||||
const batchMonth = ref(new Date().getMonth() + 1)
|
||||
const batchLoading = ref(false)
|
||||
const batchKpis = ref<any[]>([])
|
||||
const batchSelected = ref<any[]>([])
|
||||
const batchSameValue = ref(0)
|
||||
const batchSaving = ref(false)
|
||||
|
||||
async function loadBatchKpis() {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 500 })
|
||||
const data = r.data || r || []
|
||||
const items = Array.isArray(data) ? data : (data.items || [])
|
||||
batchKpis.value = items.map((k: any) => ({ ...k, batchValue: 0 }))
|
||||
} catch (e) { ElMessage.error('加载KPI列表失败') }
|
||||
batchLoading.value = false
|
||||
}
|
||||
|
||||
function onBatchSelect(sel: any[]) {
|
||||
batchSelected.value = sel
|
||||
}
|
||||
|
||||
function batchSetSame() {
|
||||
batchSelected.value.forEach(item => { item.batchValue = batchSameValue.value })
|
||||
}
|
||||
|
||||
async function doBatchCreate() {
|
||||
if (batchSelected.value.length === 0) { ElMessage.warning('请先选择KPI'); return }
|
||||
batchSaving.value = true
|
||||
let success = 0
|
||||
for (const kpi of batchSelected.value) {
|
||||
try {
|
||||
await budgetApi.create({
|
||||
kpi_id: kpi.id,
|
||||
period: `${batchYear.value}-${String(batchMonth.value).padStart(2, '0')}`,
|
||||
budget_value: kpi.batchValue,
|
||||
budget_year: batchYear.value,
|
||||
budget_month: batchMonth.value,
|
||||
})
|
||||
success++
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
ElMessage.success(`批量创建完成:${success}/${batchSelected.value.length} 条成功`)
|
||||
batchSaving.value = false
|
||||
showBatchForm.value = false
|
||||
function onMapSelect(id: number) {
|
||||
selectedMap.value = publishedMaps.value.find(m => m.id === id) || null
|
||||
loadBudget()
|
||||
}
|
||||
|
||||
// ── 年度分解 ──
|
||||
const showDecompose = ref(false)
|
||||
const decomposeForm = ref({ year: currentYear, method: 'equal' })
|
||||
const decomposing = ref(false)
|
||||
const decomposeResult = ref('')
|
||||
function getMapKpiCodes(): Set<string> {
|
||||
const codes = new Set<string>(); if (!selectedMap.value) return codes
|
||||
let dims = selectedMap.value.dimensions
|
||||
if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { return codes } }
|
||||
if (!Array.isArray(dims)) return codes
|
||||
for (const dim of dims) for (const obj of (dim.objectives || [])) for (const code of (obj.kpis || [])) if (code) codes.add(code)
|
||||
return codes
|
||||
}
|
||||
|
||||
const budgetViewMode = ref('list'); const summaryCards = ref<any[]>([]); const dimSummaryRows = ref<any[]>([])
|
||||
function onViewModeChange(mode: string) { if (mode === 'summary') loadSummary() }
|
||||
function loadSummary() {
|
||||
const cfg: Record<string, any> = { finance: { n: '财务维度', i: '💰', c: '#409eff' }, customer: { n: '客户维度', i: '🤝', c: '#67c23a' }, process: { n: '内部流程', i: '⚙️', c: '#e6a23c' }, learning: { n: '学习成长', i: '📚', c: '#f56c6c' } }
|
||||
const dd: Record<string, { t: number; n: number }> = {}
|
||||
for (const item of budgetList.value) { const d = item.dimension || 'other'; if (!dd[d]) dd[d] = { t: 0, n: 0 }; dd[d].t += (item.budget_value || 0); dd[d].n++ }
|
||||
const tb = Object.values(dd).reduce((s, v) => s + v.t, 0); const cards: any[] = []; const rows: any[] = []
|
||||
for (const [k, v] of Object.entries(dd)) { const c = cfg[k] || { n: k, i: '📊', c: '#909399' }; const r = tb > 0 ? Math.round(v.t / tb * 100) : 0; cards.push({ key: k, name: c.n, icon: c.i, color: c.c, totalBudget: v.t, kpiCount: v.n, ratio: r }); rows.push({ dimension: k, kpiCount: v.n, totalBudget: v.t, ratio: r, avgPerKpi: v.n > 0 ? Math.round(v.t / v.n) : 0 }) }
|
||||
summaryCards.value = cards; dimSummaryRows.value = rows
|
||||
}
|
||||
|
||||
const showAddBudget = ref(false)
|
||||
const addForm = ref({ kpi_id: null as any, year: currentYear, month: new Date().getMonth() + 1, value: 0 })
|
||||
const addKpiOptions = ref<any[]>([]); const addKpiLoading = ref(false); const addSaving = ref(false)
|
||||
async function searchKpiForAdd(query: string) {
|
||||
addKpiLoading.value = true
|
||||
try { const r: any = await kpiApi.list({ keyword: query, page_size: 20 }); const d = r.data || r || []; addKpiOptions.value = Array.isArray(d) ? d : (d.items || []) } catch { }
|
||||
addKpiLoading.value = false
|
||||
}
|
||||
async function doAddBudget() {
|
||||
if (!addForm.value.kpi_id) { ElMessage.warning('请选择KPI'); return }; addSaving.value = true
|
||||
try { await budgetApi.create({ kpi_id: addForm.value.kpi_id, period: `${addForm.value.year}-${String(addForm.value.month).padStart(2, '0')}`, budget_value: addForm.value.value, budget_year: addForm.value.year, budget_month: addForm.value.month }); ElMessage.success('预算已创建'); showAddBudget.value = false; loadBudget(); addForm.value = { kpi_id: null, year: currentYear, month: new Date().getMonth() + 1, value: 0 } } catch (e: any) { ElMessage.error(e?.response?.data?.detail || e?.message || '创建失败') }
|
||||
addSaving.value = false
|
||||
}
|
||||
|
||||
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 formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
|
||||
function rowClass({ row }: any) { return row._changed ? 'row-changed' : '' }
|
||||
|
||||
async function loadBudget() {
|
||||
if (batchEditMode.value && changedRows.value.length > 0) { try { await ElMessageBox.confirm(`有 ${changedRows.value.length} 条修改未保存,是否放弃?`, '提示', { confirmButtonText: '放弃修改', cancelButtonText: '取消' }) } catch { return } }
|
||||
if (!selectedMap.value) return
|
||||
const mapCodes = getMapKpiCodes()
|
||||
if (mapCodes.size === 0) { budgetList.value = []; total.value = 0; return }
|
||||
noMap.value = false; loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }; if (filterYear.value) params.year = filterYear.value; if (filterMonth.value > 0) params.budget_month = filterMonth.value; if (searchKpi.value) params.keyword = searchKpi.value
|
||||
const r: any = await budgetApi.list(params); const d = r.data || r || []; const allPlans = (Array.isArray(d) ? d : (d.items || [])) as any[]
|
||||
// 只保留选中地图关联KPI的预算记录
|
||||
const plans = allPlans.filter((p: any) => mapCodes.has(p.kpi_code))
|
||||
const kr: any = await kpiApi.list({ page_size: 100, keyword: searchKpi.value || undefined }); const kd = kr.data || kr || []; const ak = Array.isArray(kd) ? kd : (kd.items || [])
|
||||
const mk = ak.filter((k: any) => mapCodes.has(k.kpi_code)); const pk = new Set(plans.map((p: any) => p.kpi_code))
|
||||
const ek = mk.filter((k: any) => !pk.has(k.kpi_code)).map((k: any) => ({ kpi_id: k.id, kpi_code: k.kpi_code, kpi_name: k.kpi_name, dimension: k.dimension, unit: k.unit || '', period: `${filterYear.value || currentYear}-${String(filterMonth.value || 1).padStart(2, '0')}`, budget_value: null, version: 'v1.0', status: '', precision: 0, step: 1 }))
|
||||
budgetList.value = [...plans.map((item: any) => initRow({ ...item, _empty: false })), ...ek.map(item => initRow({ ...item, _empty: true }))]
|
||||
total.value = budgetList.value.length; if (budgetViewMode.value === 'summary') loadSummary()
|
||||
// 加载非财务维度KPI的行动方案计数
|
||||
loadActionPlanCounts()
|
||||
} catch (e) { ElMessage.error('加载预算数据失败') }; loading.value = false
|
||||
}
|
||||
function initRow(item: any) { return { ...item, _editing: false, _editValue: item.budget_value ?? 0, _originalValue: item.budget_value, _saving: false, _changed: false } }
|
||||
|
||||
// ── 行动方案关联 ──
|
||||
const showActionDialog = ref(false)
|
||||
const actionDialogTitle = ref('')
|
||||
const actionPlansForKpi = ref<any[]>([])
|
||||
const actionDialogCurrentRow = ref<any>(null)
|
||||
|
||||
async function loadActionPlanCounts() {
|
||||
// 筛选出非财务维度且有关联kpi_id的行
|
||||
const rows = budgetList.value.filter(r => r.dimension !== 'finance' && r.kpi_id)
|
||||
for (const row of rows) {
|
||||
row._actionCount = null // 重置
|
||||
try {
|
||||
const r: any = await actionPlanApi.list({ kpi_id: row.kpi_id, page_size: 1 })
|
||||
const d = r.data || []
|
||||
row._actionCount = Array.isArray(d) ? d.length : 0
|
||||
} catch { row._actionCount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function showActionPlans(row: any) {
|
||||
actionDialogCurrentRow.value = row
|
||||
actionDialogTitle.value = `行动方案 - ${row.kpi_name || row.kpi_code}`
|
||||
actionPlansForKpi.value = []
|
||||
if (!row.kpi_id) { showActionDialog.value = true; return }
|
||||
actionPlanApi.list({ kpi_id: row.kpi_id }).then((r: any) => {
|
||||
const d = r.data || []
|
||||
actionPlansForKpi.value = Array.isArray(d) ? d : []
|
||||
}).catch(() => { actionPlansForKpi.value = [] })
|
||||
showActionDialog.value = true
|
||||
}
|
||||
|
||||
function goCreateActionPlan() {
|
||||
const row = actionDialogCurrentRow.value
|
||||
if (!row) return
|
||||
// 跳转到差异分析页面并预填KPI
|
||||
window.open(`/action-plans?kpi_id=${row.kpi_id}&kpi_name=${encodeURIComponent(row.kpi_name || '')}`, '_blank')
|
||||
}
|
||||
|
||||
function goActionPlansPage() {
|
||||
window.open('/action-plans', '_blank')
|
||||
}
|
||||
|
||||
function onBatchEditToggle(val: boolean) { budgetList.value.forEach(r => { r._editing = val; r._editValue = r.budget_value ?? 0; r._originalValue = r.budget_value; r._changed = false }) }
|
||||
function onEditChange(row: any) { row._changed = row._editValue !== row._originalValue }
|
||||
const changedRows = computed(() => budgetList.value.filter(r => r._changed))
|
||||
async function batchSaveAll() {
|
||||
const ts = changedRows.value; if (ts.length === 0) return; batchSavingAll.value = true; let ok = 0; let no = 0
|
||||
for (const r of ts) { try { if (r._empty || !r.id) { await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }) } else { await budgetApi.update(r.id, { budget_value: r._editValue }) }; r.budget_value = r._editValue; r._originalValue = r._editValue; r._changed = false; ok++ } catch { no++ } }
|
||||
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); batchSavingAll.value = false
|
||||
}
|
||||
function batchCancelAll() { budgetList.value.forEach(r => { r._editValue = r._originalValue ?? 0; r._changed = false }) }
|
||||
function startEdit(row: any) { row._editing = true; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value }
|
||||
function cancelEdit(row: any) { row._editing = false; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value; row._changed = false }
|
||||
async function saveEdit(row: any) {
|
||||
row._saving = true
|
||||
try { if (row._empty || !row.id) { await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }); ElMessage.success('预算已创建') } else { await budgetApi.update(row.id, { budget_value: row._editValue }); ElMessage.success('已更新') }; row.budget_value = row._editValue; row._originalValue = row._editValue; row._editing = false; row._changed = false; row._empty = false } catch (e) { ElMessage.error('保存失败') }
|
||||
row._saving = false
|
||||
}
|
||||
async function doDelete(row: any) { try { await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认'); await budgetApi.delete(row.id); ElMessage.success('已删除'); loadBudget() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
|
||||
|
||||
const showDecompose = ref(false); const decomposeForm = ref({ year: currentYear, method: 'equal' }); const decomposing = ref(false); const decomposeResult = ref(''); const decomposeDetails = ref<any[]>([])
|
||||
const strategyTree = ref<any[]>([]); const strategyFilterYear = ref(currentYear); const strategyFilterMonth = ref(0); const strategyAllBudgetPlans = ref<any[]>([])
|
||||
|
||||
async function loadStrategyBudget() {
|
||||
if (!selectedMap.value) return
|
||||
try { const p: any = { budget_year: strategyFilterYear.value }; if (strategyFilterMonth.value > 0) p.budget_month = strategyFilterMonth.value; const r: any = await budgetApi.list(p); const d = r.data || r || []; const plans = (Array.isArray(d) ? d : (d.items || [])) as any[]; strategyAllBudgetPlans.value = plans
|
||||
let dims = selectedMap.value.dimensions; if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { dims = [] } }; if (!Array.isArray(dims) || dims.length === 0) { strategyTree.value = []; return }
|
||||
const kr: any = await kpiApi.list({ page_size: 100 }); const kd = kr.data || kr || []; const km = new Map<string, any>(); (Array.isArray(kd) ? kd : (kd.items || [])).forEach((k: any) => km.set(k.kpi_code, k))
|
||||
const tree: any[] = []
|
||||
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
|
||||
strategyTree.value = tree
|
||||
} catch (e: any) { ElMessage.error('加载战略预算数据失败: ' + (e?.response?.data?.detail || e?.message || '未知错误')) }
|
||||
}
|
||||
|
||||
const strategySelectedKpis = ref<any[]>([]); const strategyBatchValue = ref(0); const strategyBatchSaving = ref(false)
|
||||
function onStrategyKpiSelect(sel: any[], dimKey: string) { sel.forEach((i: any) => { i._dimKey = dimKey }); strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...sel] }
|
||||
function strategySelectDim(dimKey: string) { const dim = strategyTree.value.find((d: any) => d.key === dimKey); if (!dim) return; const all: any[] = []; for (const o of dim.objectives) for (const k of o.kpis) { k._dimKey = dimKey; all.push(k) }; strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...all] }
|
||||
function strategyBatchSetSame() { strategySelectedKpis.value.forEach((k: any) => { k._editValue = strategyBatchValue.value; k._changed = (k._editValue !== (k.budget_value ?? 0)) }) }
|
||||
async function strategyBatchSave() {
|
||||
const ts = strategySelectedKpis.value.filter((k: any) => k._changed); if (ts.length === 0) { ElMessage.warning('没有需要保存的变更'); return }; strategyBatchSaving.value = true; let ok = 0; let no = 0
|
||||
for (const r of ts) { try { if (r.plan_id) { await budgetApi.update(r.plan_id, { budget_value: r._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); r.plan_id = rr.id }; r.budget_value = r._editValue; r._changed = false; ok++ } catch { no++ } }
|
||||
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); strategyBatchSaving.value = false; loadStrategyBudget()
|
||||
}
|
||||
async function saveStrategyKpi(row: any) {
|
||||
row._saving = true
|
||||
try { if (row.plan_id) { await budgetApi.update(row.plan_id, { budget_value: row._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); row.plan_id = rr.id }; row.budget_value = row._editValue; row._changed = false; ElMessage.success('已保存') } catch (e) { ElMessage.error('保存失败') }
|
||||
row._saving = false
|
||||
}
|
||||
|
||||
async function submitVersion(row: any) { try { await ElMessageBox.confirm(`确认将版本「${row.version}」提交审批?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已提交审批'); loadVersions() } catch { } }
|
||||
async function approveVersion(row: any, action: string) { const l = action === 'approved' ? '批准' : '驳回'; try { await ElMessageBox.confirm(`确认${l}版本「${row.version}」?`, '确认'); await budgetApi.versionApprove({ version: row.version, action }); ElMessage.success(`已${l}`); loadVersions() } catch { } }
|
||||
async function resubmitVersion(row: any) { try { await ElMessageBox.confirm(`确认重新提交版本「${row.version}」?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已重新提交'); loadVersions() } catch { } }
|
||||
|
||||
const versionYear = ref(currentYear); const versionsList = ref<any[]>([]); const versionsLoading = ref(false); const selectedVersions = ref<any[]>([]); const showVersionDiff = ref(false); const diffLoading = ref(false); const diffData = ref<any>(null); const currentVersion = ref('')
|
||||
async function loadVersions() { versionsLoading.value = true; try { const r: any = await budgetApi.versions({ year: versionYear.value }); const d = r.data || []; versionsList.value = d.map((v: any) => ({ ...v, _selected: false })); currentVersion.value = d[0]?.version || '' } catch (e) { ElMessage.error('加载版本列表失败') }; versionsLoading.value = false }
|
||||
function onVersionSelect(row: any) { selectedVersions.value = versionsList.value.filter((v: any) => v._selected); if (selectedVersions.value.length > 2) { row._selected = false; selectedVersions.value = versionsList.value.filter((v: any) => v._selected) } }
|
||||
async function loadVersionDiff() { if (selectedVersions.value.length !== 2) return; diffLoading.value = true; try { const [a, b] = selectedVersions.value; const r: any = await budgetApi.versionDiff({ version_a: a.version, version_b: b.version, year: versionYear.value }); diffData.value = r; showVersionDiff.value = true } catch (e) { ElMessage.error('加载版本对比失败') }; diffLoading.value = false }
|
||||
|
||||
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
|
||||
async function loadExecutionReport() {
|
||||
execLoading.value = true
|
||||
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
|
||||
execLoading.value = false
|
||||
}
|
||||
|
||||
async function doDecompose() {
|
||||
decomposing.value = true
|
||||
decomposeResult.value = ''
|
||||
try {
|
||||
const r: any = await budgetApi.autoDecompose({
|
||||
year: decomposeForm.value.year,
|
||||
method: decomposeForm.value.method,
|
||||
})
|
||||
decomposeResult.value = r.message || '分解成功'
|
||||
ElMessage.success('年度预算分解完成')
|
||||
loadBudget()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '分解失败')
|
||||
}
|
||||
decomposing.value = true; decomposeResult.value = ''; decomposeDetails.value = []
|
||||
try { const r: any = await budgetApi.autoDecompose({ year: decomposeForm.value.year, method: decomposeForm.value.method }); decomposeResult.value = r.message || '分解成功'; if (r.results) decomposeDetails.value = r.results.map((res: any) => ({ kpi_code: res.kpi_code, kpi_name: res.kpi_name, annual_budget: res.annual_budget, method: res.method === 'equal' ? '均分' : '加权', monthly_count: res.monthly.length })); ElMessage.success('年度预算分解完成'); loadBudget() } catch (e: any) { ElMessage.error(e.detail || e.message || '分解失败') }
|
||||
decomposing.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'decompose') loadBudget()
|
||||
else if (tab === 'strategy') loadStrategyBudget()
|
||||
else if (tab === 'versions') loadVersions()
|
||||
else if (tab === 'execution') loadExecutionReport()
|
||||
})
|
||||
onMounted(async () => {
|
||||
await loadPublishedMaps()
|
||||
loadBudget()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.row-changed) { --el-table-tr-bg-color: #fff7e6 !important; }
|
||||
.strategy-dim-block { margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 8px; overflow: hidden; }
|
||||
.strategy-dim-header { padding: 10px 16px; background: #f5f7fa; border-left: 4px solid #409eff; display: flex; align-items: center; gap: 8px; }
|
||||
.strategy-dim-icon { font-size: 20px; }
|
||||
.strategy-dim-name { font-weight: 600; font-size: 15px; }
|
||||
.strategy-dim-summary { font-size: 12px; color: #999; }
|
||||
.strategy-obj-block { padding: 8px 16px; }
|
||||
.strategy-obj-block:not(:last-child) { border-bottom: 1px solid #f0f0f0; }
|
||||
.strategy-obj-header { padding: 6px 0 8px; font-size: 13px; color: #555; font-weight: 500; }
|
||||
.strategy-obj-name::before { content: '◆ '; color: #999; font-size: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<h3>KPI字典</h3>
|
||||
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
|
||||
<div class="header-actions">
|
||||
<el-button @click="showTemplateSelect = true">📋 从模板创建</el-button>
|
||||
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
@@ -99,6 +102,12 @@
|
||||
<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.is_system" size="small" type="info">系统</el-tag>
|
||||
<el-tag v-else size="small" type="success">自定义</el-tag>
|
||||
</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">
|
||||
@@ -198,13 +207,22 @@
|
||||
<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-form-item label="绿灯阈值">
|
||||
<el-input v-model="form.threshold_green" placeholder="如 >=80" />
|
||||
<div class="threshold-hint">超过此值视为正常</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="黄灯阈值"><el-input v-model="form.threshold_yellow" /></el-form-item>
|
||||
<el-form-item label="黄灯阈值">
|
||||
<el-input v-model="form.threshold_yellow" placeholder="如 >=60" />
|
||||
<div class="threshold-hint">超过此值预警</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="红灯阈值"><el-input v-model="form.threshold_red" /></el-form-item>
|
||||
<el-form-item label="红灯阈值">
|
||||
<el-input v-model="form.threshold_red" placeholder="如 <60" />
|
||||
<div class="threshold-hint">触发此值紧急</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="所属Epic">
|
||||
@@ -218,13 +236,100 @@
|
||||
<el-button type="primary" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 从模板创建弹窗 -->
|
||||
<el-dialog v-model="showTemplateSelect" title="从模板创建KPI" width="760" :close-on-click-modal="false">
|
||||
<div v-loading="templateLoading">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="search-bar" style="margin-bottom:12px;">
|
||||
<el-input v-model="templateKeyword" placeholder="搜索模板名称/编码" clearable style="width:200px" />
|
||||
<el-select v-model="templateDimFilter" placeholder="维度" clearable style="width:110px">
|
||||
<el-option label="财务" value="finance" />
|
||||
<el-option label="客户" value="customer" />
|
||||
<el-option label="内部流程" value="process" />
|
||||
<el-option label="学习成长" value="learning" />
|
||||
</el-select>
|
||||
<el-button @click="loadTemplates">查询</el-button>
|
||||
</div>
|
||||
<!-- 模板列表 -->
|
||||
<el-table :data="templates" style="width:100%" border stripe size="small"
|
||||
:highlight-current-row="true"
|
||||
@current-change="onTemplateSelect"
|
||||
>
|
||||
<el-table-column type="index" 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="140" />
|
||||
<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="80">
|
||||
<template #default="{ row }">{{ catLabel(row.category) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" />
|
||||
<el-table-column prop="target_value" label="建议目标" width="80">
|
||||
<template #default="{ row }">{{ row.target_value ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="来源" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
|
||||
<el-tag v-else size="small" type="success">自定义</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!selectedTemplate" style="color:#999;font-size:13px;margin-top:8px;">请选择一个模板,然后填写实例化信息</div>
|
||||
<!-- 实例化表单 -->
|
||||
<div v-if="selectedTemplate" class="instantiate-form" style="margin-top:12px;border-top:1px solid #eee;padding-top:12px;">
|
||||
<div style="font-size:14px;font-weight:500;margin-bottom:8px;">从「{{ selectedTemplate.kpi_name }}」创建KPI实例</div>
|
||||
<el-form :model="instantiateForm" label-width="90px" size="small">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI编码"><el-input v-model="instantiateForm.kpi_code" :placeholder="selectedTemplate.kpi_code" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="KPI名称"><el-input v-model="instantiateForm.kpi_name" :placeholder="selectedTemplate.kpi_name" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标值"><el-input-number v-model="instantiateForm.target_value" :min="0" style="width:100%" :placeholder="String(selectedTemplate.target_value ?? '')" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="instantiateForm.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="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责部门"><el-input v-model="instantiateForm.responsible_dept" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人"><el-input v-model="instantiateForm.responsible_user" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<div style="color:#999;font-size:12px;margin-top:4px;">* 编码和名称不填则使用模板默认值,后续可在KPI字典中修改</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showTemplateSelect=false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedTemplate" @click="doInstantiate">从模板创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</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 { kpiApi, templateApi, dashboardApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
// ── 数据 ──
|
||||
@@ -448,10 +553,68 @@ async function saveKPI() {
|
||||
function doDelete(id: number) {
|
||||
ElMessageBox.confirm('确定删除此KPI?', '提示').then(async () => {
|
||||
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
|
||||
catch (e) { ElMessage.error('删除失败') }
|
||||
catch (e: any) { ElMessage.error(e?.response?.data?.detail || '删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ── 从模板创建 ──
|
||||
const showTemplateSelect = ref(false)
|
||||
const templateLoading = ref(false)
|
||||
const templates = ref<any[]>([])
|
||||
const templateKeyword = ref('')
|
||||
const templateDimFilter = ref('')
|
||||
const selectedTemplate = ref<any>(null)
|
||||
const instantiateForm = ref<any>({})
|
||||
|
||||
async function loadTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (templateKeyword.value) params.keyword = templateKeyword.value
|
||||
if (templateDimFilter.value) params.dimension = templateDimFilter.value
|
||||
const r: any = await templateApi.list(params)
|
||||
templates.value = r.data || []
|
||||
} catch (e) {
|
||||
console.error('加载模板失败', e)
|
||||
}
|
||||
templateLoading.value = false
|
||||
}
|
||||
|
||||
function onTemplateSelect(tpl: any) {
|
||||
selectedTemplate.value = tpl
|
||||
instantiateForm.value = {
|
||||
kpi_code: '',
|
||||
kpi_name: '',
|
||||
target_value: undefined,
|
||||
data_source_type: 'manual',
|
||||
responsible_dept: '',
|
||||
responsible_user: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstantiate() {
|
||||
if (!selectedTemplate.value) return
|
||||
try {
|
||||
const data: any = {}
|
||||
if (instantiateForm.value.kpi_code) data.kpi_code = instantiateForm.value.kpi_code
|
||||
if (instantiateForm.value.kpi_name) data.kpi_name = instantiateForm.value.kpi_name
|
||||
if (instantiateForm.value.target_value !== undefined && instantiateForm.value.target_value !== null)
|
||||
data.target_value = instantiateForm.value.target_value
|
||||
if (instantiateForm.value.data_source_type) data.data_source_type = instantiateForm.value.data_source_type
|
||||
if (instantiateForm.value.responsible_dept) data.responsible_dept = instantiateForm.value.responsible_dept
|
||||
if (instantiateForm.value.responsible_user) data.responsible_user = instantiateForm.value.responsible_user
|
||||
await templateApi.instantiate(selectedTemplate.value.id, data)
|
||||
ElMessage.success(`已从模板「${selectedTemplate.value.kpi_name}」创建KPI`)
|
||||
showTemplateSelect.value = false
|
||||
selectedTemplate.value = null
|
||||
instantiateForm.value = {}
|
||||
loadKpis()
|
||||
loadCategories()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadKpis()
|
||||
@@ -475,6 +638,10 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.main-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -545,6 +712,12 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.threshold-hint {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.status-red { color: #f56c6c; font-weight: 500; }
|
||||
.status-yellow { color: #e6a23c; font-weight: 500; }
|
||||
.status-green { color: #67c23a; font-weight: 500; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="review-header">
|
||||
<div class="header-left">
|
||||
<el-button text @click="$router.push('/maps')">← 返回战略地图</el-button>
|
||||
<el-button text type="primary" @click="goCanvas" v-if="selectedMap">🗺️ 打开画布</el-button>
|
||||
<h3>{{ mapTitle }} · 战略回顾</h3>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -155,11 +156,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { ElMessage } from "element-plus"
|
||||
import api from "../api/index"
|
||||
|
||||
const route = useRoute()
|
||||
const $router = useRouter()
|
||||
|
||||
// ── 状态 ──
|
||||
const maps = ref<any[]>([])
|
||||
@@ -266,6 +268,12 @@ function dueText(p: any) {
|
||||
return `剩余${diff}天`
|
||||
}
|
||||
|
||||
function goCanvas() {
|
||||
if (selectedMap.value) {
|
||||
$router.push('/maps/canvas/' + selectedMap.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await api.get('/maps')
|
||||
maps.value = r.data || []
|
||||
|
||||
Reference in New Issue
Block a user