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:
Hermes CI Fix
2026-08-27 10:59:56 +08:00
parent 87cb9e7db2
commit 61936302b2
11 changed files with 1030 additions and 123 deletions
+8
View File
@@ -93,6 +93,14 @@ export const okrApi = {
api.put(`/okr/${okrId}/decomposition/milestones/${krId}`, { milestones }),
generateMilestones: (okrId: number, krId: number) =>
api.post(`/okr/${okrId}/decomposition/milestones/generate`, { kr_id: krId }),
// KR完整修复(2026-08-27): krs表CRUD
createKr: (objectiveId: number, data: any) => api.post(`/okr/${objectiveId}/krs`, data),
updateKr: (objectiveId: number, krId: number, data: any) =>
api.put(`/okr/${objectiveId}/krs/${krId}`, data),
deleteKr: (objectiveId: number, krId: number) =>
api.delete(`/okr/${objectiveId}/krs/${krId}`),
syncKrs: (objectiveId: number, krs: any[]) =>
api.put(`/okr/${objectiveId}/krs/sync`, { krs }),
}
// ── 本体三支柱追溯链 (科目↔KPI↔OKR) ──
@@ -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=">">&gt; 大于</option>
<option value="<">&lt; 小于</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()
+37 -1
View File
@@ -161,7 +161,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import { mapApi, kpiApi, okrTemplateApi } from "../api/index"
import { mapApi, kpiApi, okrTemplateApi, okrApi } from "../api/index"
import KnowledgePanel from '../components/KnowledgePanel.vue'
import api from "../api/index"
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
@@ -654,8 +654,16 @@ function onNodeSave(data: any) {
if (!data.krs || data.krs.length === 0) {
node.krs = dim.objectives[idx].krs || []
}
// 保留后端回写的 objective_idKR落库用)
if (dim.objectives[idx]._objective_id) {
node._objective_id = dim.objectives[idx]._objective_id
}
dim.objectives[idx] = { ...node, ...extra }
ElMessage.success("节点已更新")
// KR完整修复: 同步krs表(objective_id存在时直接调API
if (node._objective_id && data.krs && data.krs.length > 0) {
syncNodeKrs(node._objective_id, data.krs)
}
}
} else {
// 新增节点
@@ -673,6 +681,34 @@ function onNodeSave(data: any) {
nextTick(triggerRecalc)
}
// ── KR完整修复(2026-08-27): 节点KR同步到krs表 ──
async function syncNodeKrs(objectiveId: number, krs: any[]) {
try {
const payload = (krs || []).map((kr: any) => ({
id: kr._kr_id || kr.id || null,
title: kr.name || kr.title || "",
operator: kr.operator || ">=",
target_value: kr.target_value ?? null,
weight: kr.weight ?? null,
kpi_code: kr.kpi_code || null,
due_date: kr.due_date || null,
})).filter((k: any) => k.title)
if (payload.length === 0) return
const res: any = await okrApi.syncKrs(objectiveId, payload)
// 回写 _kr_id,避免下次重复创建
if (res?.krs) {
const idByTitle: Record<string, number> = {}
res.krs.forEach((r: any) => { idByTitle[r.title] = r.id })
krs.forEach((kr: any) => {
if (kr.name && idByTitle[kr.name]) kr._kr_id = idByTitle[kr.name]
})
}
} catch (e: any) {
// 静默失败:地图JSON仍是权威保存,krs表同步失败不阻塞画布
console.warn("[KR同步] 节点KR同步失败:", e?.response?.data?.detail || e?.message)
}
}
// ── 用户自定义O自动入库(OKR模板库第3层) ──
let userTemplateCooldown: Record<string, number> = {}
async function saveUserTemplate(data: any, layer: string) {