Files
cma-management/frontend/src/views/KPIDetail.vue
T

703 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<el-button text @click="$router.back()">< 返回KPI字典</el-button>
<div class="page-header">
<h3 class="page-title">KPI详情</h3>
</div>
<el-card v-if="kpi" style="margin-top:16px;">
<template #header>
<span class="header-title">{{ kpi.kpi_name }}</span>
<el-tag size="small" class="header-tag">{{ kpi.kpi_code }}</el-tag>
<el-tag v-if="kpi.dimension" :type="dimTagType(kpi.dimension)" size="small" class="header-tag-gap">{{ dimLabel(kpi.dimension) }}</el-tag>
<el-tag v-if="kpi.category" type="info" size="small" class="header-tag-gap">{{ catLabel(kpi.category) }}</el-tag>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="basic">
<el-form :model="kpi" label-width="120px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="KPI编码">{{ kpi.kpi_code }}</el-form-item>
<el-form-item label="KPI名称"><el-input v-model="kpi.kpi_name" /></el-form-item>
<el-form-item label="维度">
<el-select v-model="kpi.dimension" style="width:100%">
<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-form-item label="二级类别">
<el-select v-model="kpi.category" style="width:100%" :disabled="!kpi.dimension">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
<el-form-item label="目标值"><el-input-number v-model="kpi.target_value" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item>
<el-form-item label="频率">
<el-select v-model="kpi.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="kpi.responsible_dept" style="width:100%">
<el-option v-for="d in orgDeptOptions" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="kpi.responsible_user" filterable style="width:100%">
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
</el-select>
</el-form-item>
<el-form-item label="数据源">
<el-select v-model="kpi.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-form-item label="所属Epic">
<el-select v-model="kpi.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-item label="关联战略地图">
<div style="display:flex;gap:8px;width:100%;">
<el-select v-model="kpi.map_id" placeholder="选择战略地图" clearable style="flex:1;" @change="onMapChange">
<el-option v-for="m in mapOptions" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-button size="small" type="primary" plain @click="associateMap">关联</el-button>
</div>
</el-form-item>
<el-form-item label="绿灯阈值">
<div style="display:flex;gap:8px;"><el-input v-model="kpi.threshold_green" /><el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button></div>
</el-form-item>
<el-form-item label="黄灯阈值"><el-input v-model="kpi.threshold_yellow" /></el-form-item>
<el-form-item label="红灯阈值"><el-input v-model="kpi.threshold_red" /></el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="计算公式"><el-input v-model="kpi.formula" type="textarea" :rows="2" /></el-form-item>
</el-col>
</el-row>
<el-form-item><el-button type="primary" :loading="saving" @click="save">保存</el-button></el-form-item>
</el-form>
<!-- 元数据卡片 -->
<el-card shadow="never" style="margin-top:16px;">
<template #header>
<div style="display:flex;align-items:center;gap:8px;">
<span>📋 元数据卡片</span>
<el-tag v-if="!metadataComplete" type="warning" size="small" effect="dark"> 元数据不完整</el-tag>
<el-tag v-else type="success" size="small" effect="dark"> 完整</el-tag>
</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="计算公式" :span="2">
<span v-if="kpi.formula" style="font-family:monospace;">{{ kpi.formula }}</span>
<el-tag v-else type="warning" size="small">缺失</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据来源">
<span v-if="kpi.data_source && kpi.data_source !== '待补充'">{{ kpi.data_source }}</span>
<el-tag v-else type="warning" size="small">{{ kpi.data_source || '缺失' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据责任人">
<span v-if="kpi.data_owner && kpi.data_owner !== '待指定'">{{ kpi.data_owner }}</span>
<el-tag v-else type="warning" size="small">{{ kpi.data_owner || '缺失' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="单位">{{ kpi.unit || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标值">{{ kpi.target_value ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="数据源类型">{{ kpi.data_source_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="维度">{{ dimLabel(kpi.dimension) }}</el-descriptions-item>
<el-descriptions-item label="频率">{{ kpi.frequency || '-' }}</el-descriptions-item>
<el-descriptions-item label="类别" :span="2">{{ catLabel(kpi.category) || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
<el-tab-pane label="历史数据" name="history">
<v-chart :option="chartOption" autoresize class="full-width-chart" />
<el-table :data="values" size="small" class="data-table">
<el-table-column prop="period" label="期间" width="120" />
<el-table-column prop="actual_value" label="实际值" width="120" />
<el-table-column prop="source_type" label="来源" width="80" />
<el-table-column prop="data_status" label="状态" width="80">
<template #default="{ row }"><el-tag :type="row.data_status==='verified'?'success':'warning'" size="small">{{ row.data_status }}</el-tag></template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="层级分解" name="hierarchy">
<div v-loading="hierarchyLoading">
<el-alert title="KPI三级分解树:公司级 → 部门级 → 个人级" type="info" show-icon :closable="false" style="margin-bottom:12px;" />
<div v-if="hierarchyTree.length === 0" class="network-empty">暂无层级分解数据</div>
<el-tree v-else :data="hierarchyTree" :props="hierarchyProps" node-key="id" default-expand-all highlight-current>
<template #default="{ node, data }">
<span class="hierarchy-node">
<el-tag :type="hierarchyLevelTag(data.level)" size="small" style="margin-right:8px;">
{{ data.level === 1 ? '公司级' : data.level === 2 ? '部门级' : '个人级' }}
</el-tag>
<b>{{ data.kpi_name }}</b>
<span style="color:#999;margin-left:8px;font-size:12px;">{{ data.kpi_code }}</span>
<el-tag v-if="data.dimension" :type="hierarchyDimTag(data.dimension)" size="small" style="margin-left:6px;">
{{ hierarchyDimLabel(data.dimension) }}
</el-tag>
<span v-if="data.child_name" style="margin-left:8px;color:#409eff;font-size:12px;">· {{ data.child_name }}</span>
<span v-if="data.weight" style="margin-left:6px;color:#e6a23c;font-size:12px;">权重:{{ data.weight }}%</span>
</span>
</template>
</el-tree>
</div>
</el-tab-pane>
<el-tab-pane label="因果链" name="causality">
<div v-loading="networkLoading">
<el-row :gutter="16">
<el-col :span="12">
<el-card shadow="never">
<template #header><b>上游驱动</b>影响本KPI的因子</template>
<div v-if="upstream.length === 0" class="network-empty">无上游驱动</div>
<el-timeline v-else>
<el-timeline-item v-for="item in upstream" :key="item.kpi_id"
:color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'"
:timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'">
<div><b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})</div>
<div style="font-size:12px;color:#999;">
{{ item.direction === 'positive' ? '正向驱动' : '负向抑制' }}
<span v-if="item.formula"> · {{ item.formula }}</span>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header><b>下游影响</b>本KPI影响的指标</template>
<div v-if="downstream.length === 0" class="network-empty">无下游影响</div>
<el-timeline v-else>
<el-timeline-item v-for="item in downstream" :key="item.kpi_id"
:color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'"
:timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'">
<div><b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})</div>
<div style="font-size:12px;color:#999;">
{{ item.direction === 'positive' ? '正向推动' : '负向抑制' }}
<span v-if="item.formula"> · {{ item.formula }}</span>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
</el-row>
<el-divider />
<div style="margin-bottom:12px;">
<el-button type="primary" @click="showSimulate = true">模拟推演</el-button>
<el-button @click="showFullNetwork = true">查看全局因果网络图</el-button>
</div>
<!-- 因果链力导向图 -->
<el-card v-if="networkEdges.length > 0" shadow="never">
<template #header><b>因果网络图</b></template>
<v-chart :option="networkChartOption" autoresize style="height:450px;width:100%;" />
</el-card>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 模拟推演对话框 -->
<MyDialog v-model="showSimulate" title="因果链模拟推演" :width="700">
<div v-if="simulateResult">
<el-alert :title="'修改 ' + simulateResult.source.kpi_name + ' 从 ' + simulateResult.source.current_value + ' 到 ' + simulateResult.source.new_value + '(变化率 ' + simulateResult.source.change_pct + '%'"
type="info" show-icon :closable="false" style="margin-bottom:12px;" />
<el-table :data="simulateResult.impacts" size="small" max-height="400" border>
<el-table-column prop="kpi_name" label="受影响KPI" min-width="150" />
<el-table-column prop="kpi_code" label="编码" width="120" />
<el-table-column prop="dimension" label="维度" width="80">
<template #default="{ row }">{{ row.dimension === 'finance' ? '财务' : row.dimension === 'customer' ? '客户' : row.dimension === 'process' ? '流程' : '学习' }}</template>
</el-table-column>
<el-table-column prop="current_value" label="当前值" width="90" />
<el-table-column prop="predicted_value" label="预测值" width="90">
<template #default="{ row }"><span style="color:var(--bsc-customer);font-weight:600;">{{ row.predicted_value ?? '-' }}</span></template>
</el-table-column>
<el-table-column prop="change_pct" label="变化率" width="80">
<template #default="{ row }">
<span :style="{ color: row.change_pct > 0 ? 'var(--bsc-process)' : 'var(--bsc-finance)' }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span>
</template>
</el-table-column>
<el-table-column prop="path_strength" label="传递强度" width="80" />
</el-table>
</div>
<div v-else>
<el-form label-width="120px">
<el-form-item label="选择KPI">
<el-select v-model="simulateKpiId" filterable style="width:100%" placeholder="选择要修改的KPI">
<el-option v-for="k in allKpis" :key="k.id" :label="k.kpi_name + ' (' + k.kpi_code + ')'" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="当前值">{{ simulateCurrentValue ?? '无数据' }}</el-form-item>
<el-form-item label="修改为">
<el-input-number v-model="simulateNewValue" style="width:100%" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="showSimulate = false">关闭</el-button>
<el-button v-if="!simulateResult" type="primary" @click="doSimulate" :loading="simulating">执行推演</el-button>
<el-button v-else @click="resetSimulate">重新推演</el-button>
</template>
</MyDialog>
<!-- 全局因果网络图对话框 -->
<MyDialog v-model="showFullNetwork" title="全局因果网络图" :width="900">
<div v-loading="fullNetworkLoading" style="height:550px;">
<v-chart v-if="fullNetworkNodes.length > 0" :option="fullNetworkChartOption" autoresize style="height:550px;width:100%;" />
<div v-else class="full-network-loading">加载中...</div>
</div>
<template #footer>
<el-button @click="showFullNetwork = false">关闭</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import VChart from 'vue-echarts'
import 'echarts'
import { kpiApi, mapApi, kpiCausalityApi, orgApi, userApi } from '../api/index'
import api from '../api/index'
import { useFormGuard } from '../composables/useFormGuard'
const route = useRoute()
const kpi = ref<any>(null)
const values = ref<any[]>([])
const mapOptions = ref<any[]>([])
const activeTab = ref('basic')
// 因果链
const upstream = ref<any[]>([])
const downstream = ref<any[]>([])
const networkEdges = ref<any[]>([])
const networkLoading = ref(false)
const showSimulate = ref(false)
const showFullNetwork = ref(false)
const simulateKpiId = ref<number | null>(null)
const simulateNewValue = ref(0)
const simulateCurrentValue = ref<number | null>(null)
const simulateResult = ref<any>(null)
const simulating = ref(false)
const allKpis = ref<any[]>([])
const orgNodes = ref<any[]>([])
const userList = ref<any[]>([])
const fullNetworkNodes = ref<any[]>([])
const fullNetworkEdges = ref<any[]>([])
const fullNetworkLoading = ref(false)
// 编辑未保存离开确认(统一守卫 composable
const isDirty = ref(false)
useFormGuard(isDirty)
const saving = ref(false)
let kpiSnapshot: string = ''
let watchInitialized = false
// 监听 kpi 变化设置脏标记(跳过初始化赋值)
watch(() => kpi.value, () => {
if (!kpi.value) return
if (!watchInitialized) {
watchInitialized = true
return
}
isDirty.value = (JSON.stringify(kpi.value) !== kpiSnapshot)
}, { deep: true })
// 层级分解
const hierarchyTree = ref<any[]>([])
const hierarchyLoading = ref(false)
const hierarchyProps = { children: 'children', label: 'kpi_name' }
function hierarchyLevelTag(level: number) {
return level === 1 ? 'success' : level === 2 ? 'warning' : 'info'
}
function hierarchyDimTag(d: string) {
return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || ''
}
function hierarchyDimLabel(d: string) {
return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d
}
async function loadHierarchy() {
hierarchyLoading.value = true
try {
const r: any = await kpiApi.hierarchy({ entity_id: 1, kpi_id: kpi.value?.id })
hierarchyTree.value = r.tree || []
} catch (e) {
console.error('层级分解加载失败', e)
}
hierarchyLoading.value = false
}
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 categoryOptions = computed(() => {
const dim = kpi.value?.dimension
if (!dim) return []
return Object.entries(CAT_MAP)
.filter(([key]) => {
if (dim === 'finance') return ['revenue_growth','profitability','cost_control','asset_efficiency','cash_risk'].includes(key)
if (dim === 'customer') return ['customer_scale','customer_concentration','customer_satisfaction'].includes(key)
if (dim === 'process') return ['supply_chain','delivery_quality'].includes(key)
if (dim === 'learning') return ['talent_pipeline','employee_engagement','innovation'].includes(key)
return false
})
.map(([value, label]) => ({ value, label }))
})
const orgDeptOptions = computed(() => {
const names = orgNodes.value.map(n => n.name).filter(Boolean)
return [...new Set(names)]
})
const userOptions = computed(() => {
return userList.value.map(u => u.name).filter(Boolean)
})
function dimLabel(d: string) { return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c }
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
const metadataComplete = computed(() => {
if (!kpi.value) return false
const k = kpi.value
return !!(k.formula && k.formula.trim() && k.data_source && k.data_source.trim() && k.data_source !== '待补充'
&& k.data_owner && k.data_owner.trim() && k.data_owner !== '待指定'
&& k.unit && k.unit.trim() && k.target_value !== null && k.target_value !== undefined)
})
const chartOption = computed(() => ({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: values.value.map(v => v.period) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: values.value.map(v => v.actual_value), smooth: true, lineStyle: { width: 2 }, itemStyle: { color: '#409eff' }, areaStyle: { color: 'rgba(64,158,255,0.1)' } }],
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
}))
// 因果网络ECharts力导向图
const networkChartOption = computed(() => {
const nodeMap: Record<number, any> = {}
const nodes: any[] = []
const edges: any[] = []
// 中心节点
if (kpi.value) {
nodeMap[kpi.value.id] = {
id: String(kpi.value.id),
name: kpi.value.kpi_name,
symbolSize: 50,
itemStyle: { color: '#409eff' },
category: 0,
label: { fontSize: 12, fontWeight: 'bold' },
}
nodes.push(nodeMap[kpi.value.id])
}
// 上游节点
for (const item of upstream.value) {
if (!nodeMap[item.kpi_id]) {
nodeMap[item.kpi_id] = {
id: String(item.kpi_id),
name: item.kpi_name,
symbolSize: 35,
itemStyle: { color: '#67c23a' },
category: 1,
}
nodes.push(nodeMap[item.kpi_id])
}
edges.push({
source: String(item.kpi_id),
target: String(kpi.value?.id),
value: item.strength,
lineStyle: { color: item.direction === 'positive' ? '#67c23a' : '#f56c6c', width: item.strength * 5 },
label: { show: true, formatter: item.strength, fontSize: 10 },
})
}
// 下游节点
for (const item of downstream.value) {
if (!nodeMap[item.kpi_id]) {
nodeMap[item.kpi_id] = {
id: String(item.kpi_id),
name: item.kpi_name,
symbolSize: 35,
itemStyle: { color: '#e6a23c' },
category: 2,
}
nodes.push(nodeMap[item.kpi_id])
}
edges.push({
source: String(kpi.value?.id),
target: String(item.kpi_id),
value: item.strength,
lineStyle: { color: item.direction === 'positive' ? '#67c23a' : '#f56c6c', width: item.strength * 5 },
label: { show: true, formatter: item.strength, fontSize: 10 },
})
}
for (const e of networkEdges.value) {
const srcStr = String(e.source_kpi_id)
const tgtStr = String(e.target_kpi_id)
const exists = edges.some((ex: any) => ex.source === srcStr && ex.target === tgtStr)
if (!exists && nodeMap[e.source_kpi_id] && nodeMap[e.target_kpi_id]) {
edges.push({
source: srcStr,
target: tgtStr,
lineStyle: { color: '#ddd', width: 1, type: 'dashed' as const },
label: { show: false },
})
}
}
return {
tooltip: { trigger: 'item' as const, formatter: (p: any) => `${p.name}<br/>强度: ${p.value ?? '-'}` },
series: [{
type: 'graph',
layout: 'force',
force: { repulsion: 500, edgeLength: [100, 300], gravity: 0.1 },
roam: true,
draggable: true,
data: nodes,
edges: edges,
categories: [
{ name: '当前KPI', itemStyle: { color: '#409eff' } },
{ name: '上游驱动', itemStyle: { color: '#67c23a' } },
{ name: '下游影响', itemStyle: { color: '#e6a23c' } },
],
label: { show: true, position: 'right', fontSize: 10 },
edgeLabel: { show: true, fontSize: 9, color: '#666' },
lineStyle: { curveness: 0.2 },
}],
}
})
// 全局因果网络力导向图
const fullNetworkChartOption = computed(() => {
const dimColors: Record<string, string> = {
finance: '#409eff', customer: '#67c23a', process: '#e6a23c', learning: '#9b59b6',
}
const nodes = fullNetworkNodes.value.map(n => ({
id: String(n.id),
name: n.kpi_name,
symbolSize: 25,
itemStyle: { color: dimColors[n.dimension] || '#999' },
category: n.dimension === 'finance' ? 0 : n.dimension === 'customer' ? 1 : n.dimension === 'process' ? 2 : 3,
label: { fontSize: 9 },
}))
const edges = fullNetworkEdges.value.map(e => ({
source: String(e.source),
target: String(e.target),
lineStyle: {
color: e.direction === 'positive' ? 'rgba(103,194,58,0.5)' : 'rgba(245,108,108,0.5)',
width: (e.strength || 0.3) * 4,
},
value: e.strength,
}))
return {
tooltip: { trigger: 'item' as const, formatter: (p: any) => `${p.name}<br/>${p.value ? '强度:' + p.value : ''}` },
series: [{
type: 'graph',
layout: 'force',
force: { repulsion: 400, edgeLength: [80, 200], gravity: 0.05, friction: 0.1 },
roam: true,
draggable: true,
data: nodes,
edges: edges,
categories: [
{ name: '财务', itemStyle: { color: '#409eff' } },
{ name: '客户', itemStyle: { color: '#67c23a' } },
{ name: '流程', itemStyle: { color: '#e6a23c' } },
{ name: '学习', itemStyle: { color: '#9b59b6' } },
],
label: { show: true, position: 'right', fontSize: 9 },
lineStyle: { curveness: 0.2 },
}],
}
})
async function save() {
if (saving.value) return
saving.value = true
try {
await kpiApi.update(kpi.value.id, kpi.value)
ElMessage.success('保存成功')
isDirty.value = false
// 重新拉取服务端数据,确保元数据卡片状态正确
const r: any = await kpiApi.get(Number(route.params.id))
kpi.value = r.data || r
kpiSnapshot = JSON.stringify(kpi.value)
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
async function suggestThreshold() {
try {
const r: any = await api.get('/thresholds/suggest/' + kpi.value.id)
const s = r.suggestion
if (!s) { ElMessage.warning('暂无建议数据'); return }
kpi.value.threshold_green = s.green?.label || ''
kpi.value.threshold_yellow = s.yellow?.label || ''
kpi.value.threshold_red = s.red?.label || ''
ElMessage.success('已自动填入推荐阈值')
} catch (e) {
ElMessage.error('获取阈值建议失败')
}
}
async function loadCausality(kpiId: number) {
networkLoading.value = true
try {
const [upRes, downRes, listRes]: any = await Promise.all([
kpiCausalityApi.getNetwork(kpiId),
kpiCausalityApi.getNetwork(kpiId),
kpiCausalityApi.list({ source_kpi_id: kpiId }),
])
upstream.value = upRes.upstream || []
downstream.value = downRes.downstream || []
networkEdges.value = listRes.data || []
} catch (e) {
console.error('因果链加载失败', e)
}
networkLoading.value = false
}
async function doSimulate() {
if (!simulateKpiId.value) { ElMessage.warning('请选择KPI'); return }
simulating.value = true
try {
const r: any = await kpiCausalityApi.simulate({
kpi_id: simulateKpiId.value,
new_value: simulateNewValue.value,
})
simulateResult.value = r
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '推演失败')
}
simulating.value = false
}
function resetSimulate() {
simulateResult.value = null
}
watch(simulateKpiId, async (id) => {
if (!id) return
try {
const r: any = await kpiApi.get(id)
const kpiData = r.data || r
simulateCurrentValue.value = kpiData.current_value ?? null
simulateNewValue.value = kpiData.current_value ?? 0
} catch (e) {
simulateCurrentValue.value = null
}
})
async function loadFullNetwork() {
fullNetworkLoading.value = true
try {
const r: any = await kpiCausalityApi.getFullNetwork()
fullNetworkNodes.value = r.nodes || []
fullNetworkEdges.value = r.edges || []
} catch (e) {
ElMessage.error('加载因果网络失败')
}
fullNetworkLoading.value = false
}
watch(showFullNetwork, (val) => {
if (val && fullNetworkNodes.value.length === 0) {
loadFullNetwork()
}
})
onMounted(async () => {
const r: any = await kpiApi.get(Number(route.params.id))
kpi.value = r.data || r
// 保存初始快照用于脏检测
kpiSnapshot = JSON.stringify(kpi.value)
// 获取历史值
try {
const kv: any = await api.get('/kpis/' + kpi.value.id)
values.value = kv.values || []
} catch(e) {}
// 加载战略地图列表
try {
const mr: any = await mapApi.list()
mapOptions.value = mr.data || []
} catch(e) {}
// 加载因果链
await loadCausality(Number(route.params.id))
// 加载层级分解
await loadHierarchy()
// 加载KPI列表(用于模拟推演选择器)
try {
const kr: any = await kpiApi.list({ page_size: 100 })
allKpis.value = kr.data || []
} catch(e) {}
// 加载部门列表
try {
const or: any = await orgApi.nodes()
orgNodes.value = Array.isArray(or) ? or : or.data || []
} catch(e) {}
// 加载用户列表
try {
const ur: any = await userApi.list()
userList.value = Array.isArray(ur) ? ur : ur.data || []
} catch(e) {}
})
async function associateMap() {
if (!kpi.value) return
try {
await kpiApi.associateMap(kpi.value.id, { map_id: kpi.value.map_id || null })
ElMessage.success('地图关联成功')
const r: any = await kpiApi.get(Number(route.params.id))
kpi.value = r.data || r
kpiSnapshot = JSON.stringify(kpi.value)
isDirty.value = false
} catch (e) {
ElMessage.error('关联失败')
}
}
function onMapChange(val: any) {}
</script>
<style scoped>
.kpi-detail-page { }
.kpi-detail-page .header-card .header-title { font-size: 16px; font-weight: 600; }
.kpi-detail-page .header-card .header-tag { margin-left: 8px; }
.kpi-detail-page .header-card .header-tag-gap { margin-left: 4px; }
.full-width-chart { height: 300px; width: 100%; }
.data-table { width: 100%; margin-top: 12px; }
.network-empty { color: #999; text-align: center; padding: 20px; }
.full-network-loading { text-align: center; padding: 40px; color: #999; }
</style>