feat(KR): KR完整修复 — krs表打通+方向符号operator+权重自由输入
- 后端okr.py: KR读取从ActionPlan改为krs表, 新增KR CRUD API(POST/PUT/DELETE /okr/{objective_id}/krs) + 批量sync
- operator方向符号: krs表加operator/tolerance/weight/sort_order/monthly_milestones列
- progress方向感知计算(>=/>: current/target, <=/<: target/current, =: 容差), 达成→status=achieved
- 关联KPI自动继承方向(threshold_green解析: F_COST_RATIO<=18等)
- maps.py: 保存地图时自动同步objectives+krs表, JSON→krs数据迁移脚本
- 前端: 权重下拉改自由数字输入(可小数33.33) + 方向选择器(≥/≤/>/</=) + 自动平分按钮 + KPI方向继承提示
- pytest: 10个新测试(krs CRUD/方向感知/权重校验/多租户隔离) + 更新旧KR测试
- 迁移: 现有strategic_maps JSON 12条KR已写入krs表
This commit is contained in:
@@ -62,7 +62,10 @@
|
||||
<div class="mc-kr-section">
|
||||
<div class="mc-kr-header">
|
||||
<span>关键结果({{ form.krs.length }}条)</span>
|
||||
<el-button size="small" type="primary" plain @click="addKr">+ 添加KR</el-button>
|
||||
<span style="display:flex;gap:6px;">
|
||||
<el-button size="small" plain @click="autoDivideWeights">⚖️ 自动平分</el-button>
|
||||
<el-button size="small" type="primary" plain @click="addKr">+ 添加KR</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(kr, ki) in form.krs" :key="ki" class="mc-kr-row">
|
||||
@@ -73,28 +76,26 @@
|
||||
</div>
|
||||
<div class="mc-kr-row-bottom">
|
||||
<div class="mc-kr-field">
|
||||
<label>目标值 <span class="mc-required">*</span></label>
|
||||
<input v-model="kr.target_value" class="mc-input mc-kr-input" placeholder="输入数字,回车自动加%" @keydown.enter.prevent="formatTargetValue(ki)" />
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>权重</label>
|
||||
<select v-model="kr.weight" class="mc-input mc-kr-input" @change="checkWeightSum">
|
||||
<option value="10">10%</option>
|
||||
<option value="20">20%</option>
|
||||
<option value="25">25%</option>
|
||||
<option value="30">30%</option>
|
||||
<option value="33">33%</option>
|
||||
<option value="40">40%</option>
|
||||
<option value="50">50%</option>
|
||||
<option value="60">60%</option>
|
||||
<option value="70">70%</option>
|
||||
<option value="80">80%</option>
|
||||
<option value="100">100%</option>
|
||||
<label>方向 <span class="mc-required">*</span></label>
|
||||
<select v-model="kr.operator" class="mc-input mc-kr-input">
|
||||
<option value=">=">≥ 大于等于</option>
|
||||
<option value="<=">≤ 小于等于</option>
|
||||
<option value=">">> 大于</option>
|
||||
<option value="<">< 小于</option>
|
||||
<option value="=">= 等于</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>目标值 <span class="mc-required">*</span></label>
|
||||
<input v-model.number="kr.target_value" type="number" step="0.01" min="0" class="mc-input mc-kr-input" placeholder="目标数值" @input="formatTargetValue(ki)" />
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>权重(0-100,可小数)</label>
|
||||
<input v-model.number="kr.weight" type="number" step="0.01" min="0" max="100" class="mc-input mc-kr-input" placeholder="权重%" @input="checkWeightSum" />
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>关联KPI</label>
|
||||
<select v-model="kr.kpi_code" class="mc-input mc-kr-input">
|
||||
<select v-model="kr.kpi_code" class="mc-input mc-kr-input" @change="onKpiChange(ki)">
|
||||
<option value="">— 不关联 —</option>
|
||||
<option v-for="kpi in kpiOptions" :key="kpi.kpi_code" :value="kpi.kpi_code">
|
||||
{{ kpi.kpi_name || kpi.kpi_code }}
|
||||
@@ -451,7 +452,7 @@ const router = useRouter()
|
||||
const form = ref<{
|
||||
o_name: string
|
||||
o_description: string
|
||||
krs: { name: string; target_value: string; weight: string; kpi_code: string }[]
|
||||
krs: { name: string; operator: string; target_value: number | null; weight: number | null; kpi_code: string }[]
|
||||
}>({
|
||||
o_name: "",
|
||||
o_description: "",
|
||||
@@ -532,9 +533,9 @@ function skipTemplate() {
|
||||
o_name: "",
|
||||
o_description: "",
|
||||
krs: [
|
||||
{ name: "", target_value: "", weight: "33", kpi_code: "" },
|
||||
{ name: "", target_value: "", weight: "33", kpi_code: "" },
|
||||
{ name: "", target_value: "", weight: "34", kpi_code: "" },
|
||||
{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" },
|
||||
{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" },
|
||||
{ name: "", operator: ">=", target_value: null, weight: 34, kpi_code: "" },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -548,22 +549,42 @@ function nextStep() {
|
||||
form.value = {
|
||||
o_name: tmpl.name,
|
||||
o_description: tmpl.description,
|
||||
krs: tmpl.preset_krs.map((kr: any) => ({
|
||||
name: kr.name,
|
||||
target_value: kr.target_value,
|
||||
weight: String(kr.weight),
|
||||
kpi_code: kr.kpi_code || "",
|
||||
})),
|
||||
krs: tmpl.preset_krs.map((kr: any) => {
|
||||
const parsed = parseKrTarget(kr.target_value)
|
||||
return {
|
||||
name: kr.name,
|
||||
operator: parsed.operator,
|
||||
target_value: parsed.value,
|
||||
weight: Number(kr.weight) || 33,
|
||||
kpi_code: kr.kpi_code || "",
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── KR 目标值解析(兼容旧格式 "≥20%" / "<=18" → operator + 数值) ──
|
||||
function parseKrTarget(raw: any): { operator: string; value: number | null } {
|
||||
if (raw == null || raw === "") return { operator: ">=", value: null }
|
||||
if (typeof raw === "number") return { operator: ">=", value: raw }
|
||||
const s = String(raw).trim()
|
||||
let op = ">="
|
||||
let rest = s
|
||||
for (const cand of [">=", "<=", ">", "<", "="]) {
|
||||
if (s.startsWith(cand)) { op = cand; rest = s.slice(cand.length); break }
|
||||
}
|
||||
if (rest.startsWith("≥")) { op = ">="; rest = rest.slice(1) }
|
||||
else if (rest.startsWith("≤")) { op = "<="; rest = rest.slice(1) }
|
||||
const m = rest.match(/[-+]?\d*\.?\d+/)
|
||||
return { operator: op, value: m ? parseFloat(m[0]) : null }
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
dialogStep.value = 1
|
||||
selectedTemplateIdx.value = null
|
||||
}
|
||||
|
||||
const weightSum = computed(() => {
|
||||
return form.value.krs.reduce((s, kr) => s + (parseInt(kr.weight) || 0), 0)
|
||||
return form.value.krs.reduce((s, kr) => s + (parseFloat(String(kr.weight)) || 0), 0)
|
||||
})
|
||||
|
||||
// ── 从后端加载模板库(失败时静默回退到硬编码模板) ──
|
||||
@@ -609,24 +630,49 @@ function addKr() {
|
||||
ElMessage.warning('每个目标最多5个关键结果')
|
||||
return
|
||||
}
|
||||
form.value.krs.push({ name: "", target_value: "", weight: "33", kpi_code: "" })
|
||||
form.value.krs.push({ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" })
|
||||
}
|
||||
|
||||
function removeKr(idx: number) {
|
||||
form.value.krs.splice(idx, 1)
|
||||
}
|
||||
|
||||
// ── 自动平分权重(N个KR → 均分,余数给最后一个) ──
|
||||
function autoDivideWeights() {
|
||||
const n = form.value.krs.length
|
||||
if (n === 0) return
|
||||
const base = Math.floor(100 / n)
|
||||
const remainder = 100 - base * n
|
||||
form.value.krs.forEach((kr, i) => {
|
||||
kr.weight = i === n - 1 ? base + remainder : base
|
||||
})
|
||||
ElMessage.success(`已自动平分: ${form.value.krs.map(k => k.weight).join('/')}`)
|
||||
}
|
||||
|
||||
// ── 关联KPI变化 → 自动继承方向(从KPI阈值解析) ──
|
||||
function onKpiChange(ki: number) {
|
||||
const kr = form.value.krs[ki]
|
||||
if (!kr) return
|
||||
const kpi = kpiOptions.value.find((k: any) => k.kpi_code === kr.kpi_code)
|
||||
if (!kpi) return
|
||||
const threshold = kpi.threshold_green || kpi.threshold_yellow || ""
|
||||
if (!threshold) return
|
||||
const parsed = parseKrTarget(threshold)
|
||||
// 仅当用户未手动设置方向/目标值时继承
|
||||
if (!kr.operator || kr.operator === ">=") kr.operator = parsed.operator
|
||||
if (kr.target_value == null) kr.target_value = parsed.value
|
||||
ElMessage.success(`已继承KPI方向: ${kr.operator} ${kr.target_value}`)
|
||||
}
|
||||
|
||||
function formatTargetValue(idx: number) {
|
||||
// 数值输入,不需要追加%(weightSum实时校验)
|
||||
const kr = form.value.krs[idx]
|
||||
if (!kr) return
|
||||
const val = kr.target_value
|
||||
if (val && !val.includes('%') && !isNaN(parseFloat(val))) {
|
||||
const pct = parseFloat(val).toFixed(2) + '%'
|
||||
kr.target_value = pct
|
||||
// 自动同步到KR名称:如果名称是纯文字无数字,追加≥目标值
|
||||
if (kr.name && !/\d/.test(kr.name)) {
|
||||
kr.name = kr.name + '≥' + pct
|
||||
}
|
||||
if (kr.target_value == null || kr.target_value === "") return
|
||||
// 自动同步到KR名称:如果名称是纯文字无数字,追加方向+目标值
|
||||
if (kr.name && !/\d/.test(kr.name)) {
|
||||
const opSymbol = kr.operator === ">=" ? "≥" : kr.operator === "<=" ? "≤" : kr.operator
|
||||
kr.name = kr.name + opSymbol + kr.target_value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,7 +693,7 @@ function onSave() {
|
||||
ElMessage.warning(`第 ${i + 1} 条KR名称不能为空`)
|
||||
return
|
||||
}
|
||||
if (!kr.target_value?.trim()) {
|
||||
if (kr.target_value == null || kr.target_value === "") {
|
||||
ElMessage.warning(`第 ${i + 1} 条KR目标值不能为空`)
|
||||
return
|
||||
}
|
||||
@@ -666,8 +712,9 @@ function onSave() {
|
||||
dimension: props.editingLayerKey,
|
||||
krs: form.value.krs.map((kr, i) => ({
|
||||
name: kr.name,
|
||||
operator: kr.operator || ">=",
|
||||
target_value: kr.target_value,
|
||||
weight: parseInt(kr.weight) || 33,
|
||||
weight: parseFloat(String(kr.weight)) || 33,
|
||||
kpi_code: kr.kpi_code || null,
|
||||
sort_order: i + 1,
|
||||
})),
|
||||
@@ -698,16 +745,21 @@ watch(
|
||||
selectedTemplateIdx.value = null
|
||||
usedTemplate.value = null
|
||||
const obj = props.editingObj
|
||||
const existingKrs = (obj.krs || []).map((kr: any) => ({
|
||||
name: kr.name || "",
|
||||
target_value: kr.target_value || "",
|
||||
weight: String(kr.weight || 33),
|
||||
kpi_code: kr.kpi_code || "",
|
||||
}))
|
||||
const existingKrs = (obj.krs || []).map((kr: any) => {
|
||||
const parsed = parseKrTarget(kr.target_value)
|
||||
return {
|
||||
name: kr.name || "",
|
||||
operator: kr.operator || parsed.operator,
|
||||
target_value: kr.target_value != null && typeof kr.target_value === "number"
|
||||
? kr.target_value : parsed.value,
|
||||
weight: Number(kr.weight != null ? kr.weight : 33) || 33,
|
||||
kpi_code: kr.kpi_code || "",
|
||||
}
|
||||
})
|
||||
form.value = {
|
||||
o_name: obj.name || obj.o_name || "",
|
||||
o_description: obj.description || obj.o_description || "",
|
||||
krs: existingKrs.length > 0 ? existingKrs : [{ name: "", target_value: "", weight: "33", kpi_code: "" }],
|
||||
krs: existingKrs.length > 0 ? existingKrs : [{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" }],
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
|
||||
Reference in New Issue
Block a user