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 条,含差异说明)
This commit is contained in:
@@ -128,11 +128,37 @@
|
||||
<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>
|
||||
<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 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-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>
|
||||
@@ -354,6 +380,7 @@
|
||||
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'
|
||||
@@ -737,8 +764,23 @@ const fullNetworkChartOption = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 阈值语法与后端 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(兼容评分/阈值等旧逻辑)
|
||||
@@ -767,10 +809,18 @@ async function suggestThreshold() {
|
||||
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('已自动填入推荐阈值')
|
||||
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('获取阈值建议失败')
|
||||
}
|
||||
@@ -900,6 +950,7 @@ function onMapChange(val: any) {}
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user