Files
cma-management/frontend/src/views/KPIList.vue
T
Hermes CI Fix b06821a426 feat: KPI多粒度目标智能派生 — 基准值+按类型派生+手动覆盖
- 后端: target_calc_type字段(accumulate累计/ratio比率) + infer_calc_type名称/单位推断
- 派生规则: 累计型 月×3=季×12=年(季×4=年); 比率型 季/年沿用基准不可乘
- 虚拟派生不落库: kpi_to_dict返回derived_targets+derived_flags(自动标记)
- DB: 302个KPI回填类型(226累计/76比率)
- 前端KPIList: 指标类型选择 + 季/年自动派生预览(↳自动=N)
- 前端KPIDetail: 元数据卡片自动标记 + 编辑表单指标类型
- 回归: pytest 451 passed
2026-08-21 10:59:43 +08:00

1130 lines
42 KiB
Vue
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 class="kpi-dict-page">
<!-- 页头 -->
<div class="page-header">
<h3>KPI字典</h3>
<div class="header-actions">
<el-button @click="openTemplateDialog">📋 从模板创建</el-button>
<el-button type="primary" @click="openCreate">+ 新建KPI</el-button>
</div>
</div>
<div class="main-layout">
<!-- 左侧BSC分类导航树 -->
<div class="left-tree">
<div class="tree-header">BSC分类导航</div>
<el-tree
ref="treeRef"
:data="categoryTree"
node-key="key"
:props="{ label: 'label', children: 'children' }"
:default-expanded-keys="defaultExpanded"
:show-checkbox="true"
:check-strictly="true"
@check="onTreeCheck"
highlight-current
>
<template #default="{ node, data }">
<span class="tree-node">
<span class="tree-node-label">{{ data.label }}</span>
<el-tag size="small" :type="data.children && data.children.length > 0 ? '' : 'info'" class="tree-count">{{ data.count }}</el-tag>
</span>
</template>
</el-tree>
</div>
<!-- 右侧搜索+表格 -->
<div class="right-content">
<!-- 企业选择器 -->
<div class="entity-selector">
<span class="entity-label">🌐 企业</span>
<el-select v-model="currentEntityId" @change="onEntityChange" style="width:200px">
<el-option
v-for="e in entities"
:key="e.id"
:label="e.short_name + ' / ' + e.name"
:value="e.id"
/>
</el-select>
<span class="entity-name-hint">{{ currentEntityName }}</span>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchKeyword" placeholder="搜索KPI名称/编码" clearable style="width:220px" @clear="loadKpis" @keyup.enter="loadKpis" />
<el-select v-model="searchDimension" placeholder="维度" clearable style="width:110px" @change="loadKpis">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-select v-model="searchCategory" placeholder="二级类别" clearable style="width:140px" @change="loadKpis">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-select v-model="searchStatus" placeholder="状态" clearable style="width:100px" @change="loadKpis">
<el-option label="活跃" value="active" />
<el-option label="已归档" value="disabled" />
</el-select>
<el-button type="primary" @click="loadKpis">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 批量操作栏 -->
<div class="batch-bar" v-if="selectedIds.length > 0">
<span class="selected-info">已选 {{ selectedIds.length }} </span>
<el-button size="small" @click="batchDelete">批量删除</el-button>
<el-button size="small" @click="batchExport">导出CSV</el-button>
<el-button size="small" @click="selectedIds=[]">取消选择</el-button>
</div>
<!-- 表格 -->
<el-table
:data="kpis"
v-loading="loading"
style="width:100%"
@selection-change="onSelectionChange"
@row-click="(row) => $router.push('/kpis/' + row.id)"
border
stripe
size="small"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="kpi_code" label="编码" width="120">
<template #default="{ row }">
<span class="kpi-code">{{ row.kpi_code }}</span>
</template>
</el-table-column>
<el-table-column prop="kpi_name" label="名称" min-width="170" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="二级类别" width="90">
<template #default="{ row }">{{ catLabel(row.category) }}</template>
</el-table-column>
<el-table-column prop="target_value" label="目标值" width="110">
<template #default="{ row }">
<span class="target-cell">{{ targetLabel(row) }} <b>{{ formatTarget(targetForRow(row), row.unit) }}</b></span>
</template>
</el-table-column>
<el-table-column prop="frequency" label="频率" width="70">
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
</el-table-column>
<el-table-column prop="data_source_type" label="数据源" width="70">
<template #default="{ row }">{{ sourceLabel(row.data_source_type) }}</template>
</el-table-column>
<el-table-column label="所属地图" width="120">
<template #default="{ row }">
<span v-if="row.map_title" style="color:#409eff;font-size:12px;">{{ row.map_title }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="当前值" width="100">
<template #default="{ row }">
<span v-if="row.actual_value !== null" :class="'status-' + (row.alert_level || 'none')">
{{ row.actual_value }}{{ row.unit }}
</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="来源" width="70">
<template #default="{ row }">
<el-tag v-if="row.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">
{{ row.alert_level === 'red' ? '红灯' : row.alert_level === 'yellow' ? '黄灯' : '绿灯' }}
</el-tag>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click.stop="$router.push('/kpis/' + row.id)">详情</el-button>
<el-button size="small" text type="primary" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'active'" size="small" text type="danger" @click.stop="doDelete(row.id)">归档</el-button>
<el-button v-else size="small" text type="success" @click.stop="doRestore(row.id)">恢复</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && kpis.length === 0" description="暂无KPI数据请新建或搜索其他条件" />
<!-- 分页 -->
<div class="pagination-bar">
<span class="total-info">共 {{ total }} 条</span>
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadKpis"
/>
</div>
</div>
</div>
<!-- 新建/编辑对话框 -->
<MyDialog v-model="showForm" :title="editMode ? '编辑KPI' : '新建KPI'" :width="640">
<!-- KPI设计引导(五步法·五规则,新建时显示) -->
<div v-if="!editMode" class="design-guide">
<el-collapse v-model="guideOpen">
<el-collapse-item name="guide">
<template #title>
<span class="guide-title">📐 KPI设计引导(五步法 · 五规则)</span>
</template>
<div class="guide-steps">
<div v-for="(s, i) in guideSteps" :key="i" class="guide-step">
<span class="step-no">{{ i + 1 }}</span>
<div class="step-body">
<div class="step-title">{{ s.title }}</div>
<div class="step-desc">{{ s.desc }}</div>
<el-input v-model="designDraft[s.field]" :placeholder="s.placeholder" size="small" />
</div>
</div>
</div>
<div class="rule-checks">
<div class="rule-title">五规则实时校验</div>
<div v-for="c in designChecks" :key="c.label" class="rule-check">
<span :class="c.pass ? 'ok' : 'fail'">{{ c.pass ? '✅' : '❌' }}</span>
<span>{{ c.label }}</span>
<span v-if="c.note" class="rule-note">{{ c.note }}</span>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<el-form :model="form" label-width="110px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="KPI编码"><el-input v-model="form.kpi_code" :disabled="editMode" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="KPI名称"><el-input v-model="form.kpi_name" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="维度">
<el-select v-model="form.dimension" style="width:100%" @change="onDimensionChange">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="二级类别">
<el-select v-model="form.category" style="width:100%" :disabled="!form.dimension">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标值()"><el-input-number v-model="form.target_monthly" :min="0" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标值()">
<el-input-number v-model="form.target_quarterly" :min="0" style="width:100%" />
<div v-if="derivedPreview.quarterly !== null" class="derive-hint">↳ 自动={{ derivedPreview.quarterly }}</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标值()">
<el-input-number v-model="form.target_yearly" :min="0" style="width:100%" />
<div v-if="derivedPreview.yearly !== null" class="derive-hint">↳ 自动={{ derivedPreview.yearly }}</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, , , ..." /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="频率">
<el-select v-model="form.frequency" style="width:100%">
<el-option label="月度" value="monthly" />
<el-option label="周度" value="weekly" />
<el-option label="季度" value="quarterly" />
<el-option label="半年" value="half_year" />
<el-option label="年度" value="yearly" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="指标类型">
<el-select v-model="form.target_calc_type" style="width:100%">
<el-option label="累计型×3=可乘" value="accumulate" />
<el-option label="比率型季沿用月不可乘" value="ratio" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="数据源">
<el-select v-model="form.data_source_type" style="width:100%">
<el-option label="ERP自动" value="erp" />
<el-option label="Excel导入" value="excel" />
<el-option label="手工填报" value="manual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="负责部门"><el-select v-model="form.responsible_dept" style="width:100%"><el-option v-for="d in depts" :key="d" :label="d" :value="d" /></el-select></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人"><el-select v-model="form.responsible_user" filterable style="width:100%"><el-option v-for="u in users" :key="u" :label="u" :value="u" /></el-select></el-form-item>
</el-col>
</el-row>
<el-form-item label="计算公式"><el-input v-model="form.formula" type="textarea" :rows="2" /></el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="绿灯阈值">
<el-input v-model="form.threshold_green" 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" 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" placeholder=" <60" />
<div class="threshold-hint">触发此值紧急</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="所属Epic">
<el-select v-model="form.epic" style="width:100%">
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm=false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
</template>
</MyDialog>
<!-- 从模板创建弹窗 -->
<MyDialog v-model="showTemplateSelect" title="从模板创建KPI" :width="760">
<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-select v-model="instantiateForm.responsible_dept" style="width:100%"><el-option v-for="d in depts" :key="d" :label="d" :value="d" /></el-select></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人"><el-select v-model="instantiateForm.responsible_user" filterable style="width:100%"><el-option v-for="u in users" :key="u" :label="u" :value="u" /></el-select></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>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { kpiApi, templateApi, dashboardApi, entityApi, userApi, orgApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
import { useFormGuard, trackDialogForm } from '../composables/useFormGuard'
// ── 数据 ──
const kpis = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 20
// ── 搜索筛选 ──
const searchKeyword = ref('')
const searchDimension = ref('')
const searchCategory = ref('')
const searchStatus = ref('active')
// ── 企业选择 ──
const entities = ref<any[]>([])
const currentEntityId = ref(Number(localStorage.getItem('cma_entity_id') || 1))
const currentEntityName = ref(localStorage.getItem('cma_entity_name') || '酣客')
// ── 分类树 ──
const categoryTree = ref<any[]>([])
const defaultExpanded = ref<string[]>([])
const treeRef = ref<any>(null)
const treeFilterDims = ref<string[]>([])
const treeFilterCats = ref<string[]>([])
// ── 多选 ──
const selectedIds = ref<number[]>([])
// ── 部门/用户选项 ──
const depts = ref<string[]>([])
const users = ref<string[]>([])
async function loadDepts() {
try {
const r: any = await orgApi.nodes()
const raw: any[] = r.data || r || []
const names = raw.map((n: any) => n.name).filter(Boolean)
depts.value = [...new Set<string>(names)]
} catch (e) {
console.error('加载部门列表失败', e)
}
}
async function loadUsers() {
try {
const r: any = await userApi.list()
const raw: any[] = r.data || r || []
users.value = raw.map((u: any) => u.name).filter(Boolean)
} catch (e) {
console.error('加载用户列表失败', e)
}
}
// ── 新建/编辑表单 ──
const showForm = ref(false)
const editMode = ref(false)
const form = ref<any>({})
const saving = ref(false)
// 新建入口:重置表单 + 设计引导草稿
function openCreate() {
editMode.value = false
form.value = {}
designDraft.value = {}
guideOpen.value = ['guide']
showForm.value = true
}
// ── KPI设计引导(五步法·五规则,对应 docs/kpi-design-rule.md)──
const guideOpen = ref(['guide'])
const guideSteps = [
{ field: 'duty', title: '① 明确岗位职责', desc: '这个KPI考核谁的什么岗位?岗位存在的价值是什么?', placeholder: '如:销售岗——负责新客户开拓与回款' },
{ field: 'customer', title: '② 分析客户是谁', desc: '服务对象:内部客户(上级/下游部门)+ 外部客户(终端/渠道)', placeholder: '如:外部客户(新签/续约)+ 内部(交付/财务)' },
{ field: 'requirement', title: '③ 分析客户需求', desc: '客户对这个岗位的期望标准是什么?', placeholder: '如:快速响应、按时交付、回款可控' },
{ field: 'output', title: '④ 确定成果输出', desc: '岗位需要产出什么可交付成果?(即本KPI,填入上方表单)', placeholder: '如:新签客户数、签约金额' },
{ field: 'factors', title: '⑤ 五因素设计指标', desc: '时间/数量/质量/成本/风险——对应下方表单字段', placeholder: '如:数量=新签数(目标值)、时间=频率(月度)、质量=满意度(阈值)' },
]
const designDraft = ref<Record<string, string>>({})
const designChecks = computed(() => {
const f = form.value || {}
const dimCount = kpis.value.filter((k: any) => k.dimension === f.dimension).length
const hasTarget = f.target_monthly != null || f.target_quarterly != null || f.target_yearly != null || f.target_value != null
return [
{ label: 'KPI名称已填(SMART·具体)', pass: !!f.kpi_name },
{ label: '已选维度(BSC四维平衡)', pass: !!f.dimension },
{ label: '已填单位(可衡量)', pass: !!f.unit },
{ label: '有目标值(可衡量·五因素数量)', pass: hasTarget },
{ label: '已选频率(SMART·有时限)', pass: !!f.frequency },
{ label: '已选负责人(责任到人)', pass: !!f.responsible_user },
{ label: '本维度KPI数量≤6(规则①)', pass: dimCount < 6, note: dimCount ? `当前${DIM_MAP[f.dimension] || ''}维度已有${dimCount}个,建议聚焦` : '' },
]
})
// ── 多粒度目标派生预览(填基准值后季/年自动提示,与后端 derive_cycle_targets 一致)──
const derivedPreview = computed(() => {
const f = form.value || {}
const type = f.target_calc_type || 'ratio'
const freq = f.frequency || 'monthly'
let base: number | null = null
if (freq === 'yearly') base = f.target_yearly
else if (freq === 'quarterly' || freq === 'half_year') base = f.target_quarterly
else base = f.target_monthly
if (base == null) base = f.target_value
if (base == null) return { quarterly: null, yearly: null }
let q: number | null = f.target_quarterly
let y: number | null = f.target_yearly
if (type === 'accumulate') {
const m: number = f.target_monthly ?? base
if (q == null) q = m * 3
if (y == null) y = m * 12
} else {
if (q == null) q = base
if (y == null) y = base
}
return { quarterly: f.target_quarterly == null ? q : null, yearly: f.target_yearly == null ? y : null }
})
// ── 编辑入口:列表行 → 弹窗编辑(与新建一致)──
function openEdit(row: any) {
editMode.value = true
form.value = {
id: row.id,
kpi_code: row.kpi_code,
kpi_name: row.kpi_name,
dimension: row.dimension,
category: row.category,
target_value: row.target_value,
target_monthly: row.target_monthly,
target_quarterly: row.target_quarterly,
target_yearly: row.target_yearly,
unit: row.unit,
frequency: row.frequency,
data_source_type: row.data_source_type,
responsible_dept: row.responsible_dept,
responsible_user: row.responsible_user,
formula: row.formula,
threshold_green: row.threshold_green,
threshold_yellow: row.threshold_yellow,
threshold_red: row.threshold_red,
epic: row.epic,
target_calc_type: row.target_calc_type,
}
showForm.value = true
}
// ── 未保存离开守卫 ──
const isDirty = ref(false)
const { markDirty, markClean } = useFormGuard(isDirty)
trackDialogForm(showForm, form, markDirty)
// ── 维度映射 ──
const DIM_MAP: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const CAT_MAP: Record<string, string> = {
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
asset_efficiency: '资产效率', cash_risk: '现金流风控',
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
supply_chain: '供应链效率', delivery_quality: '交付质量', customer_mgmt: '客户管理', regulatory: '法规与社会',
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation_capability: '创新资本',
r_and_d: '研发管理',
}
const FREQ_MAP: Record<string, string> = { daily: '日', weekly: '周', monthly: '月', quarterly: '季', yearly: '年' }
const SOURCE_MAP: Record<string, string> = { erp: 'ERP', excel: 'Excel', manual: '手工' }
// 维度→二级类别 对照映射
const DIM_CAT_MAP: Record<string, string[]> = {
finance: ['revenue_growth', 'profitability', 'cost_control', 'asset_efficiency', 'cash_risk'],
customer: ['customer_scale', 'customer_concentration', 'customer_satisfaction'],
process: ['supply_chain', 'delivery_quality', 'customer_mgmt', 'regulatory', 'r_and_d'],
learning: ['talent_pipeline', 'employee_engagement', 'innovation_capability'],
}
// 二级类别选项(联动维度)
const categoryOptions = computed(() => {
const dim = form.value?.dimension
if (!dim) return []
const catKeys = DIM_CAT_MAP[dim] || []
return catKeys.map(key => ({ value: key, label: CAT_MAP[key] || key }))
})
// 所有二级类别(搜索栏用)
const allCategories = computed(() => {
const seen = new Set<string>()
const result: { value: string; label: string }[] = []
for (const key in DIM_CAT_MAP) {
for (const cat of DIM_CAT_MAP[key]) {
if (!seen.has(cat)) {
seen.add(cat)
result.push({ value: cat, label: CAT_MAP[cat] || cat })
}
}
}
return result
})
function dimLabel(d: string) { return DIM_MAP[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c }
function freqLabel(f: string) { return FREQ_MAP[f] || f }
function sourceLabel(s: string) { return SOURCE_MAP[s] || s }
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
function formatTarget(val: number, unit: string) {
if (val === null || val === undefined) return '-'
if (val >= 10000) return (val / 10000).toFixed(0) + '万' + (unit || '')
return val + (unit || '')
}
// 多粒度目标:按frequency取对应周期目标值
function targetForRow(row: any) {
const freq = row.frequency || 'monthly'
if (freq === 'quarterly' || freq === 'half_year') return row.target_quarterly ?? row.target_value
if (freq === 'yearly') return row.target_yearly ?? row.target_value
return row.target_monthly ?? row.target_value
}
function targetLabel(row: any) {
const freq = row.frequency || 'monthly'
if (freq === 'quarterly' || freq === 'half_year') return '季'
if (freq === 'yearly') return '年'
return '月'
}
// ── 加载分类树 ──
async function loadCategories() {
try {
const r: any = await kpiApi.listCategories()
categoryTree.value = [
{ key: '_all', label: '全部', count: r.total, children: (r.tree || []).map((dim: any) => ({
...dim,
children: (dim.children || []).map((cat: any) => ({ ...cat, label: CAT_MAP[cat.key] || cat.label }))
})) }
]
defaultExpanded.value = ['_all', ...(r.tree || []).map((n: any) => n.key)]
} catch (e) {
console.error('加载分类树失败', e)
}
}
// ── 树节点勾选(支持多选)──
function onTreeCheck(data: any, { checkedKeys }: any) {
// 从checkedKeys中分离出维度和分类
const dimKeys = ['finance', 'customer', 'process', 'learning']
const checkedDims = checkedKeys.filter((k: string) => dimKeys.includes(k))
const checkedCats = checkedKeys.filter((k: string) => !dimKeys.includes(k) && k !== '_all')
if (data.key === '_all' || checkedKeys.includes('_all')) {
// 勾选全部=清空过滤
treeFilterDims.value = []
treeFilterCats.value = []
} else {
treeFilterDims.value = checkedDims
treeFilterCats.value = checkedCats
}
loadKpis()
}
// ── 加载KPI列表 ──
async function loadKpis() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize, entity_id: currentEntityId.value }
if (searchKeyword.value) params.keyword = searchKeyword.value
if (searchDimension.value) params.dimension = searchDimension.value
if (searchCategory.value) params.category = searchCategory.value
if (searchStatus.value) params.status = searchStatus.value
if (treeFilterDims.value.length > 0 && !params.dimension) params.dimension = treeFilterDims.value.join(',')
if (treeFilterCats.value.length > 0 && !params.category) params.category = treeFilterCats.value.join(',')
const r: any = await kpiApi.list(params)
kpis.value = r.data || []
total.value = r.total || 0
} catch (e) {
console.error('加载KPI失败', e)
}
loading.value = false
}
// 获取当前值
async function loadActualValues() {
try {
const r: any = await dashboardApi.kpis({ role: 'ceo', period: 'month' })
const vals = r.data || []
const valMap: Record<number, any> = {}
for (const v of vals) {
valMap[v.id] = v
}
for (const kpi of kpis.value) {
if (valMap[kpi.id]) {
kpi.actual_value = valMap[kpi.id].actual_value
kpi.alert_level = valMap[kpi.id].alert_level
}
}
} catch (e) {
// 静默失败
}
}
function resetSearch() {
searchKeyword.value = ''
searchDimension.value = ''
searchCategory.value = ''
treeFilterDims.value = []
treeFilterCats.value = []
if (treeRef.value) treeRef.value.setCheckedKeys([])
page.value = 1
loadKpis()
}
// ── 多选 ──
function onSelectionChange(rows: any[]) {
selectedIds.value = rows.map((r: any) => r.id)
}
function batchDelete() {
ElMessageBox.confirm(`确定归档选中的 ${selectedIds.value.length} 条KPI`, '提示').then(async () => {
for (const id of selectedIds.value) {
try { await kpiApi.delete(id) } catch (e) {}
}
ElMessage.success('批量删除完成')
selectedIds.value = []
loadKpis()
loadCategories()
}).catch(() => {})
}
function doRestore(id: number) {
ElMessageBox.confirm('确定恢复此KPI', '提示').then(async () => {
await kpiApi.restore(id)
ElMessage.success('已恢复')
loadKpis()
}).catch(() => {})
}
function batchExport() {
const header = '编码,名称,维度,二级类别,目标值,频率,数据源,负责人,计算公式'
const rows = kpis.value.map((k: any) =>
[k.kpi_code, k.kpi_name, dimLabel(k.dimension), catLabel(k.category), k.target_value, freqLabel(k.frequency), sourceLabel(k.data_source_type), k.responsible_user, (k.formula || '')].join(',')
)
const bom = '\uFEFF' // Excel UTF-8 BOM
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'KPI字典_' + new Date().toISOString().slice(0, 10) + '.csv'
a.click()
URL.revokeObjectURL(url)
}
// ── 新建/保存 ──
function onDimensionChange(val: string) {
form.value.category = ''
}
async function saveKPI() {
if (saving.value) return
// KPI设计规范·基础门禁(仅新建模式;五步法成果输出 + SMART可衡量)
if (!editMode.value) {
if (!form.value.kpi_name || !form.value.dimension) {
ElMessage.warning('请先填写 KPI名称 和 维度(KPI设计规范·五步法)')
return
}
if (!form.value.unit || (form.value.target_monthly == null && form.value.target_quarterly == null && form.value.target_yearly == null && form.value.target_value == null)) {
ElMessage.warning('请补充 单位 和 至少一个目标值(KPI设计规范·SMART可衡量)')
return
}
}
saving.value = true
try {
// 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑)
const freq = form.value.frequency || 'monthly'
let tv: any = null
if (freq === 'quarterly' || freq === 'half_year') tv = form.value.target_quarterly
else if (freq === 'yearly') tv = form.value.target_yearly
else tv = form.value.target_monthly
if (tv != null) form.value.target_value = tv
if (editMode.value) {
await kpiApi.update(form.value.id, form.value)
ElMessage.success('保存成功')
} else {
await kpiApi.create(form.value)
ElMessage.success('创建成功')
}
showForm.value = false
markClean()
loadKpis()
loadCategories()
} catch (e) {
ElMessage.error(editMode.value ? '保存失败' : '创建失败')
} finally {
saving.value = false
}
}
function doDelete(id: number) {
ElMessageBox.confirm('确定删除此KPI', '提示').then(async () => {
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
catch (e: any) { ElMessage.error(e?.response?.data?.detail || '删除失败') }
}).catch(() => {})
}
// ── 从模板创建 ──
const showTemplateSelect = ref(false)
function openTemplateDialog() {
showTemplateSelect.value = true
loadTemplates()
}
const templateLoading = ref(false)
const templates = ref<any[]>([])
const templateKeyword = ref('')
const templateDimFilter = ref('')
const selectedTemplate = ref<any>(null)
const instantiateForm = ref<any>({})
trackDialogForm(showTemplateSelect, instantiateForm, markDirty)
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
markClean()
selectedTemplate.value = null
instantiateForm.value = {}
loadKpis()
loadCategories()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '创建失败')
}
}
// ── 企业选择 ──
async function loadEntities() {
try {
const r: any = await entityApi.list()
entities.value = r.data || []
// Find current entity name
const cur = entities.value.find((e: any) => e.id === currentEntityId.value)
if (cur) currentEntityName.value = cur.short_name || cur.name
} catch (e) {
console.error('加载企业列表失败', e)
}
}
function onEntityChange(val: number) {
const cur = entities.value.find((e: any) => e.id === val)
currentEntityName.value = cur ? (cur.short_name || cur.name) : ''
page.value = 1
loadKpis()
loadCategories()
}
onMounted(() => {
loadEntities()
loadCategories()
loadKpis()
loadDepts()
loadUsers()
})
</script>
<style scoped>
.kpi-dict-page {
height: 100%;
display: flex;
flex-direction: column;
}
/* ── KPI设计引导(五步法·五规则)── */
.design-guide {
margin-bottom: 12px;
border: 1px solid #d9e8ff;
border-radius: 8px;
background: #f8fbff;
overflow: hidden;
}
.guide-title {
font-weight: 600;
font-size: 13px;
color: #1f4e9c;
}
.guide-steps {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 8px 10px;
}
.guide-step {
display: flex;
gap: 10px;
align-items: flex-start;
}
.step-no {
width: 22px;
height: 22px;
border-radius: 50%;
background: #409EFF;
color: #fff;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.step-body {
flex: 1;
}
.step-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.step-desc {
font-size: 12px;
color: #909399;
margin: 2px 0 4px;
}
.rule-checks {
border-top: 1px dashed #d9e8ff;
padding: 10px 8px 4px;
display: flex;
flex-direction: column;
gap: 6px;
}
.rule-title {
font-size: 13px;
font-weight: 600;
color: #1f4e9c;
}
.rule-check {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #303133;
}
.rule-check .ok { color: #67C23A; }
.rule-check .fail { color: #F56C6C; }
.rule-note {
color: #E6A23C;
font-size: 12px;
}
.derive-hint {
font-size: 12px;
color: #409EFF;
margin-top: 2px;
line-height: 1.4;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.main-layout {
display: flex;
gap: 12px;
flex: 1;
overflow: hidden;
}
.left-tree {
width: 260px;
min-width: 260px;
background: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
padding: 8px 0;
overflow-y: auto;
}
.tree-header {
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 4px;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding-right: 8px;
}
.tree-node-label {
font-size: 13px;
}
.tree-count {
font-size: 11px;
min-width: 20px;
text-align: center;
}
.right-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.entity-selector {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
border: 1px solid #d0e8ff;
}
.entity-label {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.entity-name-hint {
font-size: 13px;
color: #909399;
}
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #ecf5ff;
border-radius: 4px;
margin-bottom: 8px;
}
.selected-info {
font-size: 13px;
color: #409eff;
font-weight: 500;
}
.kpi-code {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #606266;
}
.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; }
.status-none { color: #606266; }
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.total-info {
font-size: 13px;
color: #909399;
}
:deep(.el-tree-node__content) {
height: 32px;
}
</style>