Files
cma-management/frontend/src/views/KPIDetail.vue
T
Hermes CI Fix 4c6fb85cf5 fix(kpi): 阈值红黄绿读写/填写规则修复 — 统一解析器+红灯优先+建议可解析+前端校验+存量修正 (project-20260911-threshold-fix)
① 新建 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 条,含差异说明)
2026-09-11 17:25:24 +08:00

959 lines
43 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-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>