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:
@@ -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=">">> 大于</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()
|
||||
|
||||
@@ -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_id(KR落库用)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user