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

476 lines
44 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<h3>预算管理</h3>
<div 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;">
<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-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 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>
<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="period" label="期间" width="90" />
<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 }">
<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 label="操作" width="160" fixed="right">
<template #default="{ row }">
<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>
</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>
<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>
<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>
<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="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, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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 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 publishedMaps = ref<any[]>([])
const selectedMapId = ref<number | null>(null)
const selectedMap = ref<any>(null)
async function loadPublishedMaps() {
try {
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 { publishedMaps.value = []; noMap.value = true }
}
function onMapSelect(id: number) {
selectedMap.value = publishedMaps.value.find(m => m.id === id) || null
loadBudget()
}
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 = ''; 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
}
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>