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