① 新建 app/utils/threshold_parser.py 统一解析/判定(单一事实源):
- 支持负数(>=-100000)、闭区间 A~B、多条件 C1|C2;空串=未设置
- 判定顺序 red→green→yellow:红灯优先(消除宽黄灯遮蔽红灯),
green 先于 yellow(green 是主条件,yellow 是更宽兜底带)
② kpis.py / alert_generator.py 双实现收敛到统一解析器(消除重复维护)
③ thresholds.py suggest 输出可解析表达式(原黄色/适中型生成 `52.33~107.67`、`偏离55%` 等
区间/文字 → 用户点"自动建议"填入后静默失效;另修复大数目标生成 1.6e+06 科学计数法)
④ KPIDetail.vue 三个阈值输入加 placeholder/tooltip/帮助文案 + 保存前格式校验(非法即拒)
⑤ 存量 4 个不可解析表达式修正(id=46 C_REBATE_RATE / id=327 BH_SALES_10),
改前 mysqldump 备份 backend/scripts/backups/kpi_definitions.bak-20260911.sql +
threshold_rows_before_20260911.json(可回滚);不可解析 5→0
⑥ 单测 97 条 + 红黄绿判定回归 367 判定点(旧→新差异 14 条,含差异说明)
959 lines
43 KiB
Vue
959 lines
43 KiB
Vue
<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-card shadow="never" style="margin-top:12px;margin-bottom:4px;">
|
||
<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="目标值(月)">
|
||
{{ fmtDerived(kpi, 'monthly') }}
|
||
<el-tag v-if="isDerived(kpi, 'monthly')" size="small" type="info" effect="plain">自动</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标值(季)">
|
||
{{ fmtDerived(kpi, 'quarterly') }}
|
||
<el-tag v-if="isDerived(kpi, 'quarterly')" size="small" type="info" effect="plain">自动</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标值(年)">
|
||
{{ fmtDerived(kpi, 'yearly') }}
|
||
<el-tag v-if="isDerived(kpi, 'yearly')" size="small" type="info" effect="plain">自动</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="指标类型">
|
||
{{ kpi.target_calc_type === 'accumulate' ? '累计型' : kpi.target_calc_type === 'ratio' ? '比率型' : (kpi.target_calc_type || '-') }}
|
||
</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-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_monthly" :min="0" style="width:100%" /></el-form-item>
|
||
<el-form-item label="目标值(季度)"><el-input-number v-model="kpi.target_quarterly" :min="0" style="width:100%" /></el-form-item>
|
||
<el-form-item label="目标值(年度)"><el-input-number v-model="kpi.target_yearly" :min="0" style="width:100%" /></el-form-item>
|
||
<el-form-item label="指标类型">
|
||
<el-select v-model="kpi.target_calc_type" style="width:100%">
|
||
<el-option label="累计型(月×3=季,可乘)" value="accumulate" />
|
||
<el-option label="比率型(季沿用月,不可乘)" value="ratio" />
|
||
</el-select>
|
||
</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="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="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>
|
||
<template #label>
|
||
绿灯阈值
|
||
<el-tooltip :content="THRESHOLD_HELP_TEXT" placement="top">
|
||
<el-icon style="margin-left:4px;vertical-align:-2px;cursor:help;color:#909399;"><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</template>
|
||
<div style="display:flex;gap:8px;width:100%;">
|
||
<el-input v-model="kpi.threshold_green" placeholder="如 >=800 / 52.33~107.67" clearable style="width:100%" />
|
||
<el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<template #label>
|
||
黄灯阈值
|
||
<el-tooltip :content="THRESHOLD_HELP_TEXT" placement="top">
|
||
<el-icon style="margin-left:4px;vertical-align:-2px;cursor:help;color:#909399;"><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</template>
|
||
<el-input v-model="kpi.threshold_yellow" placeholder="如 >=800 / 52.33~107.67" clearable style="width:100%" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<template #label>
|
||
红灯阈值
|
||
<el-tooltip :content="THRESHOLD_HELP_TEXT" placement="top">
|
||
<el-icon style="margin-left:4px;vertical-align:-2px;cursor:help;color:#909399;"><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</template>
|
||
<el-input v-model="kpi.threshold_red" placeholder="如 >=800 / 52.33~107.67" clearable style="width:100%" />
|
||
</el-form-item>
|
||
<div style="color:#909399;font-size:12px;line-height:1.5;margin-top:-6px;">{{ THRESHOLD_HELP_TEXT }}</div>
|
||
</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-tab-pane>
|
||
|
||
<el-tab-pane label="历史数据" name="history">
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:8px;">
|
||
<BotSourceTag :updated-at="latestBotValue?.updated_at || ''" :source="latestBotSource" />
|
||
<el-button size="small" type="primary" plain @click="openAddValueDialog" style="margin-left:10px;">➕ 录入数据</el-button>
|
||
</div>
|
||
<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="100" />
|
||
<el-table-column label="实际值" width="130">
|
||
<template #default="{ row }">
|
||
<span :style="row.level === 'red' ? 'color:#f56c6c;font-weight:600' : (row.level === 'yellow' ? 'color:#e6a23c;font-weight:600' : '')">
|
||
{{ row.actual_value != null ? formatValue(row.actual_value) : '--' }}{{ row.unit || '' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="目标值" width="120">
|
||
<template #default="{ row }">{{ row.target_value != null ? formatValue(row.target_value) + (row.unit || '') : '--' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="偏差" width="90">
|
||
<template #default="{ row }"><span v-if="row.deviation_pct != null" :style="row.level === 'red' ? 'color:#f56c6c' : (row.level === 'yellow' ? 'color:#e6a23c' : 'color:#67c23a')">{{ row.deviation_pct > 0 ? '+' : '' }}{{ row.deviation_pct }}%</span><span v-else>--</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="判定" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.level === 'green'" type="success" size="small">绿</el-tag>
|
||
<el-tag v-else-if="row.level === 'yellow'" type="warning" size="small">黄</el-tag>
|
||
<el-tag v-else-if="row.level === 'red'" type="danger" size="small">红</el-tag>
|
||
<el-tag v-else type="info" size="small">—</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="来源" width="90">
|
||
<template #default="{ row }">{{ sourceLabel(row.source_type) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }"><el-tag :type="row.data_status==='verified'?'success':'warning'" size="small">{{ statusLabel(row.data_status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column prop="source_batch" label="数据批次" min-width="160" show-overflow-tooltip />
|
||
</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 }})
|
||
<span v-if="item.verify_status"
|
||
:style="{ display:'inline-block', marginLeft:'8px', padding:'0 8px', borderRadius:'10px', fontSize:'12px', lineHeight:'20px', color: verifyBadge(item.verify_status).color, background: verifyBadge(item.verify_status).bg }">
|
||
{{ verifyBadge(item.verify_status).text }}
|
||
</span>
|
||
</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 }})
|
||
<span v-if="item.verify_status"
|
||
:style="{ display:'inline-block', marginLeft:'8px', padding:'0 8px', borderRadius:'10px', fontSize:'12px', lineHeight:'20px', color: verifyBadge(item.verify_status).color, background: verifyBadge(item.verify_status).bg }">
|
||
{{ verifyBadge(item.verify_status).text }}
|
||
</span>
|
||
</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>
|
||
|
||
<!-- 录入数据对话框 -->
|
||
<MyDialog v-model="showAddValue" title="录入KPI数据" :width="420">
|
||
<el-form label-width="80px">
|
||
<el-form-item label="KPI">
|
||
<span>{{ kpi?.kpi_code }} {{ kpi?.kpi_name }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="期间">
|
||
<el-date-picker v-model="addValueForm.period" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:200px;" />
|
||
</el-form-item>
|
||
<el-form-item label="实际值">
|
||
<el-input-number v-model="addValueForm.actual_value" :precision="2" :step="1" style="width:200px;" />
|
||
<span style="margin-left:6px;color:#999;font-size:12px;">{{ kpi?.unit || '' }}</span>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="showAddValue = false">取消</el-button>
|
||
<el-button type="primary" :loading="addValueSaving" @click="submitAddValue">保存</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 { QuestionFilled } from '@element-plus/icons-vue'
|
||
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'
|
||
|
||
import BotSourceTag from '../components/BotSourceTag.vue'
|
||
|
||
const route = useRoute()
|
||
const kpi = ref<any>(null)
|
||
const values = ref<any[]>([])
|
||
|
||
// ── C区标注:该KPI最近一次Bot回填(source_type=bot 的最新值;无则不显示) ──
|
||
const latestBotValue = computed(() => {
|
||
const botVals = values.value.filter((v: any) => v.source_type === 'bot' && (v.updated_at || v.source_batch))
|
||
if (!botVals.length) return null
|
||
return botVals.slice().sort((a: any, b: any) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')))[0]
|
||
})
|
||
const latestBotSource = computed(() => latestBotValue.value?.source_batch || '')
|
||
|
||
// ── 人工录入数据 ──
|
||
const showAddValue = ref(false)
|
||
const addValueSaving = ref(false)
|
||
const addValueForm = ref({ period: '', actual_value: 0 })
|
||
function openAddValueDialog() {
|
||
const now = new Date()
|
||
addValueForm.value = {
|
||
period: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`,
|
||
actual_value: 0,
|
||
}
|
||
showAddValue.value = true
|
||
}
|
||
async function submitAddValue() {
|
||
if (!addValueForm.value.period) { ElMessage.warning('请选择期间'); return }
|
||
addValueSaving.value = true
|
||
try {
|
||
const r: any = await api.post(`/kpis/${kpi.value.id}/values`, {
|
||
period: addValueForm.value.period,
|
||
actual_value: addValueForm.value.actual_value,
|
||
})
|
||
ElMessage.success(r.message || '已保存')
|
||
showAddValue.value = false
|
||
// 刷新历史数据
|
||
const kv: any = await api.get('/kpis/' + kpi.value.id)
|
||
values.value = kv.values || []
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.response?.data?.detail || e?.message || '保存失败')
|
||
} finally {
|
||
addValueSaving.value = false
|
||
}
|
||
}
|
||
|
||
// 数值格式化(历史数据对齐显示用)
|
||
function formatValue(v: any) {
|
||
if (v === null || v === undefined || isNaN(Number(v))) return '--'
|
||
const n = Number(v)
|
||
// 整数不带小数,非整数保留2位
|
||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||
}
|
||
|
||
// 来源中文映射
|
||
function sourceLabel(s: string) {
|
||
const map: Record<string, string> = {
|
||
excel: 'Excel导入', ledger: '账簿提取', manual: '手动录入',
|
||
'excel-import': 'Excel导入', forecast: '预测', sales_ranking: '销售排行',
|
||
'bot-bridge-v2': 'Bot桥接', verify: '验证数据', erp: 'ERP',
|
||
}
|
||
return map[s] || s || '—'
|
||
}
|
||
|
||
// 状态中文映射
|
||
function statusLabel(s: string) {
|
||
const map: Record<string, string> = {
|
||
verified: '已验证', estimated: '估算', pending: '待处理', error: '错误',
|
||
}
|
||
return map[s] || s || '—'
|
||
}
|
||
|
||
// 因果链验证状态映射(三层验证机制 2026-08-27)
|
||
function verifyBadge(s: string) {
|
||
const map: Record<string, { text: string; color: string; bg: string }> = {
|
||
data_verified: { text: '✅ 数据证实', color: '#67c23a', bg: 'rgba(103,194,58,.12)' },
|
||
human_verified: { text: '✅ 人工确认', color: '#409eff', bg: 'rgba(64,158,255,.12)' },
|
||
disputed: { text: '⚠️ 存疑', color: '#e6a23c', bg: 'rgba(230,162,60,.12)' },
|
||
pending: { text: '⏳ 待检', color: '#909399', bg: 'rgba(144,147,153,.12)' },
|
||
}
|
||
return map[s] || { text: s || '—', color: '#909399', bg: 'rgba(144,147,153,.12)' }
|
||
}
|
||
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 }
|
||
|
||
// ── 多粒度目标派生显示(与后端 derive_cycle_targets 一致)──
|
||
function derivedFor(k: any): { monthly: number | null; quarterly: number | null; yearly: number | null; flags: any } {
|
||
if (k?.derived_targets) return { ...k.derived_targets, flags: k.derived_flags || {} }
|
||
// 本地兜底(旧数据无 derived_targets 时)
|
||
const f = k || {}
|
||
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
|
||
const flags = { monthly: false, quarterly: false, yearly: false }
|
||
let q: number | null = f.target_quarterly
|
||
let y: number | null = f.target_yearly
|
||
if (base != null) {
|
||
if (type === 'accumulate') {
|
||
const m: number = f.target_monthly ?? base
|
||
if (q == null) { q = m * 3; flags.quarterly = true }
|
||
if (y == null) { y = m * 12; flags.yearly = true }
|
||
} else {
|
||
if (q == null) { q = base; flags.quarterly = true }
|
||
if (y == null) { y = base; flags.yearly = true }
|
||
}
|
||
}
|
||
return { monthly: f.target_monthly, quarterly: q, yearly: y, flags }
|
||
}
|
||
function fmtDerived(k: any, cycle: 'monthly' | 'quarterly' | 'yearly') {
|
||
const d = derivedFor(k)
|
||
const v = d[cycle]
|
||
return v == null ? '-' : String(v)
|
||
}
|
||
function isDerived(k: any, cycle: 'monthly' | 'quarterly' | 'yearly') {
|
||
return !!derivedFor(k).flags?.[cycle]
|
||
}
|
||
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 },
|
||
}],
|
||
}
|
||
})
|
||
|
||
// 阈值语法与后端 app/utils/threshold_parser.py 保持一致
|
||
const THRESHOLD_CLAUSE_RE = /^(?:-?\d+(?:\.\d+)?\s*~\s*-?\d+(?:\.\d+)?|(?:>=|<=|!=|>|<|=)\s*-?\d+(?:\.\d+)?)$/
|
||
const THRESHOLD_HELP_TEXT = '支持格式:>=800 / <=25 / >60 / <20 / =100 / !=0(数值可为负数,如 <-100000);区间 52.33~107.67(含端点);多条件用 | 连接,如 <=36|>=124。'
|
||
function isValidThreshold(v: any): boolean {
|
||
const s = String(v ?? '').trim()
|
||
if (!s) return true
|
||
return s.split(/[||]/).every(c => THRESHOLD_CLAUSE_RE.test(c.trim()))
|
||
}
|
||
|
||
async function save() {
|
||
if (saving.value) return
|
||
const invalid = ([['绿灯阈值', kpi.value.threshold_green], ['黄灯阈值', kpi.value.threshold_yellow], ['红灯阈值', kpi.value.threshold_red]] as [string, any][])
|
||
.filter(([, v]) => !isValidThreshold(v))
|
||
if (invalid.length) {
|
||
ElMessage.error(`${invalid.map(([n]) => n).join('、')}格式非法;${THRESHOLD_HELP_TEXT}`)
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
// 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑)
|
||
const freq = kpi.value.frequency || 'monthly'
|
||
let tv: any = null
|
||
if (freq === 'quarterly' || freq === 'half_year') tv = kpi.value.target_quarterly
|
||
else if (freq === 'yearly') tv = kpi.value.target_yearly
|
||
else tv = kpi.value.target_monthly
|
||
if (tv != null) kpi.value.target_value = tv
|
||
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 }
|
||
const g = s.green?.label || ''
|
||
const y = s.yellow?.label || ''
|
||
const rd = s.red?.label || ''
|
||
// 建议值必须自身可解析(防填入不可解析格式 → 静默失效)
|
||
if (!isValidThreshold(g) || !isValidThreshold(y) || !isValidThreshold(rd)) {
|
||
ElMessage.error('自动建议生成值格式非法,请检查后端 suggest 接口')
|
||
return
|
||
}
|
||
kpi.value.threshold_green = g
|
||
kpi.value.threshold_yellow = y
|
||
kpi.value.threshold_red = rd
|
||
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; }
|
||
.threshold-help { cursor: help; color: #909399; font-size: 12px; line-height: 28px; white-space: nowrap; }
|
||
.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>
|