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

654 lines
27 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>
<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-input v-model="kpi.responsible_dept" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="kpi.responsible_user" /></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" @click="save">保存</el-button></el-form-item>
</el-form>
</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, onBeforeUnmount } from 'vue'
import { useRoute, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import VChart from 'vue-echarts'
import 'echarts'
import { kpiApi, mapApi, kpiCausalityApi } from '../api/index'
import api from '../api/index'
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 fullNetworkNodes = ref<any[]>([])
const fullNetworkEdges = ref<any[]>([])
const fullNetworkLoading = ref(false)
// 编辑未保存离开确认
const isDirty = ref(false)
let kpiSnapshot: string = ''
let watchInitialized = false
// 路由离开确认
onBeforeRouteLeave((to, from, next) => {
if (isDirty.value) {
ElMessageBox.confirm('有未保存的修改,确定离开吗?', '提示', {
confirmButtonText: '离开',
cancelButtonText: '取消',
type: 'warning',
}).then(() => next()).catch(() => next(false))
} else {
next()
}
})
// 页面关闭/刷新确认
function onBeforeUnloadHandler(e: BeforeUnloadEvent) {
if (isDirty.value) {
e.preventDefault()
e.returnValue = ''
}
}
// 组件卸载时清理事件
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnloadHandler)
})
// 监听 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 }))
})
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 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() {
try {
await kpiApi.update(kpi.value.id, kpi.value)
ElMessage.success('保存成功')
isDirty.value = false
kpiSnapshot = JSON.stringify(kpi.value)
} catch (e) {
ElMessage.error('保存失败')
}
}
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 () => {
// 注册页面关闭/刷新确认
window.addEventListener('beforeunload', onBeforeUnloadHandler)
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) {}
})
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>