feat(KR): 行动挂KR完成 — action_plans.kr_id字段+API+前端目标/KR选择+KR中文名自动填+地图krs端点

- action_plans表加kr_id字段(关联krs.id),create/update/list支持kr_id读写
- 创建行动选KR时自动继承objective_id;列表附kr_title
- 前端行动方案库加'所属目标/KR'两级联动选择,选中KR自动带出KPI
- OKR详情页KR卡片下显示达成行动列表(O→KR→行动三层)
- MapCanvasDialogs onKpiChange选中KPI自动填kr.name=kpi.kpi_name(中文)
- 编辑回填英文code KR名转中文显示兜底
- maps.py 新增GET /{map_id}/krs端点(MapReview战略回顾会KR进度)
- 数据修复: krs表9条英文code title→中文名(按kpi_definitions映射)
- 数据迁移: 高置信行动挂KR(费用率→kr10,应收→kr14); 地图32空KR清理
- okr list的kr_summary补metric_kpi_id(前端联动用)
- 新增6个kr_id测试(test_action_plans),全量547通过
This commit is contained in:
Hermes CI Fix
2026-08-27 12:50:30 +08:00
parent 61936302b2
commit 6b479bfe7d
10 changed files with 402 additions and 14 deletions
+1
View File
@@ -59,6 +59,7 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
@@ -649,19 +649,28 @@ function autoDivideWeights() {
ElMessage.success(`已自动平分: ${form.value.krs.map(k => k.weight).join('/')}`)
}
// ── 关联KPI变化 → 自动继承方向(从KPI阈值解析 ──
// ── 关联KPI变化 → 自动填中文名 + 继承方向(KR英文名修复 2026-08-27 ──
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 kpiName = kpi.kpi_name || kpi.kpi_code
// 选中KPI自动填中文名:名称空 / 还是英文code时替换(用户已手填中文则保留)
const curName = (kr.name || "").trim()
if (!curName || curName === kpi.kpi_code || (curName === curName.toUpperCase() && curName.includes("_"))) {
kr.name = kpiName
}
// 从KPI阈值解析方向/目标值并继承(仅当用户未手动设置时)
const threshold = kpi.threshold_green || kpi.threshold_yellow || ""
if (!threshold) return
if (!threshold) {
ElMessage.success(`已关联KPI: ${kpiName}`)
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}`)
ElMessage.success(`关联KPI: ${kpiName}方向 ${kr.operator} ${kr.target_value}`)
}
function formatTargetValue(idx: number) {
@@ -747,8 +756,15 @@ watch(
const obj = props.editingObj
const existingKrs = (obj.krs || []).map((kr: any) => {
const parsed = parseKrTarget(kr.target_value)
// 显示兜底: 旧数据KR名是英文code → 转中文(KR英文名修复 2026-08-27
let krName = kr.name || ""
const curName = String(krName).trim()
if (curName && curName === curName.toUpperCase() && curName.includes("_")) {
const hit = kpiOptions.value.find((k: any) => k.kpi_code === curName)
if (hit) krName = hit.kpi_name || hit.kpi_code
}
return {
name: kr.name || "",
name: krName || "",
operator: kr.operator || parsed.operator,
target_value: kr.target_value != null && typeof kr.target_value === "number"
? kr.target_value : parsed.value,
+51 -2
View File
@@ -71,6 +71,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="所属KR" width="160">
<template #default="{ row }">
<span v-if="row.kr_title" style="font-size:12px;">{{ row.kr_title }}</span>
<span v-else style="font-size:12px;color:#ccc;"></span>
</template>
</el-table-column>
<el-table-column label="维度" width="80">
<template #default="{ row }">
<el-tag size="small" :type="dimTagType(row.kpi_dimension)">{{ dimLabel(row.kpi_dimension) }}</el-tag>
@@ -157,6 +163,16 @@
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="所属目标">
<el-select v-model="form.objective_id" placeholder="选择OKR目标(可选)" clearable filterable style="width:100%;" :teleported="false" @change="onObjectiveChange">
<el-option v-for="o in objectiveOptions" :key="o.id" :label="`${o.title}${o.kr_count || 0} KR`" :value="o.id" />
</el-select>
</el-form-item>
<el-form-item label="所属KR">
<el-select v-model="form.kr_id" placeholder="选择KR(行动是KR的达成手段)" clearable filterable style="width:100%;" :teleported="false" :disabled="!form.objective_id" @change="onKrChange">
<el-option v-for="kr in currentKrs" :key="kr.id" :label="`${kr.title}${kr.progress || 0}%`" :value="kr.id" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="详细描述(可选)" />
</el-form-item>
@@ -331,7 +347,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { actionPlanApi, kpiApi, userApi } from '../api/index'
import { actionPlanApi, kpiApi, userApi, okrApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const route = useRoute()
@@ -352,6 +368,7 @@ const selectedRows = ref<any[]>([])
const kpiOptions = ref<any[]>([])
const userOptions = ref<string[]>([])
const objectiveOptions = ref<any[]>([])
// ── 筛选 ──
const filterStatus = ref('')
@@ -450,6 +467,33 @@ async function loadUsers() {
} catch {}
}
// ── 目标/KR加载(行动挂KR 2026-08-27 ──
async function loadObjectives() {
try {
const r: any = await okrApi.list({})
objectiveOptions.value = r.items || r.data || []
} catch {}
}
// 当前目标下的KR(两级联动)
const currentKrs = computed(() => {
const obj = objectiveOptions.value.find((o: any) => o.id === form.value.objective_id)
return obj?.key_results || []
})
function onObjectiveChange() {
form.value.kr_id = null
}
function onKrChange() {
const kr = currentKrs.value.find((k: any) => k.id === form.value.kr_id)
if (!kr) return
// 选中KR自动带出KPI(若尚未选择)
if (kr.metric_kpi_id && !form.value.kpi_id) {
form.value.kpi_id = kr.metric_kpi_id
}
}
// ── 新建/编辑 ──
const showForm = ref(false)
const formTitle = ref('')
@@ -459,6 +503,8 @@ const form = ref({
title: '',
target_value: '',
kpi_id: null as number | null,
objective_id: null as number | null,
kr_id: null as number | null,
description: '',
assignee: '',
priority: 'medium',
@@ -471,7 +517,7 @@ const form = ref({
function openCreate() {
editingId.value = null
formTitle.value = '新建行动方案'
form.value = { title: '', target_value: '', kpi_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
form.value = { title: '', target_value: '', kpi_id: null, objective_id: null, kr_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
// 如果路由携带 kpi_id 参数,自动填入
if (route.query.kpi_id) {
form.value.kpi_id = Number(route.query.kpi_id)
@@ -486,6 +532,8 @@ function editPlan(row: any) {
title: row.title,
target_value: row.target_value || '',
kpi_id: row.kpi_id,
objective_id: row.objective_id || null,
kr_id: row.kr_id || null,
description: row.description || '',
assignee: row.assignee || '',
priority: row.priority || 'medium',
@@ -613,6 +661,7 @@ onMounted(() => {
loadData()
loadKpis()
loadUsers()
loadObjectives()
})
</script>
+61 -1
View File
@@ -63,6 +63,16 @@
<span v-if="kr.due_date" class="kr-meta-item">截止: {{ kr.due_date.slice(0, 10) }}</span>
</div>
<el-progress :percentage="kr.progress || 0" :stroke-width="6" />
<!-- 行动挂KR: KR下的达成行动 (2026-08-27) -->
<div v-if="actionsOfKr(kr.id).length > 0" class="kr-actions">
<div class="kr-actions-title">达成行动{{ actionsOfKr(kr.id).length }}</div>
<div v-for="ap in actionsOfKr(kr.id)" :key="ap.id" class="kr-action-row">
<span class="kr-action-title">{{ ap.title }}</span>
<span v-if="ap.assignee" class="kr-action-meta">@{{ ap.assignee }}</span>
<el-tag size="small" :type="statusTagType(ap.status)" class="kr-action-tag">{{ statusLabel(ap.status) }}</el-tag>
<el-progress :percentage="ap.progress || 0" :stroke-width="4" style="width:80px;" />
</div>
</div>
</div>
</div>
</el-tab-pane>
@@ -136,7 +146,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { okrApi, ontologyApi } from '../api/index'
import { okrApi, ontologyApi, actionPlanApi } from '../api/index'
import OkrDecomposition from '../components/okr/OkrDecomposition.vue'
const route = useRoute()
@@ -147,6 +157,12 @@ const objective = ref<any>(null)
const trace = ref<any>(null)
const traceLoading = ref(false)
const activeTab = ref('krs')
const actionPlans = ref<any[]>([])
// KR下的行动列表(行动挂KR 2026-08-27: O→KR→行动三层)
function actionsOfKr(krId: number) {
return actionPlans.value.filter((p: any) => p.kr_id === krId)
}
const DIM_LABELS: Record<string, string> = {
finance: '财务层', customer: '客户层', process: '流程层', learning: '学习成长层',
@@ -180,6 +196,14 @@ async function loadData() {
}
}
// 加载行动方案(按kr_id关联,供KR下展示)
async function loadActions() {
try {
const r: any = await actionPlanApi.list({ page_size: 500 })
actionPlans.value = r.data || []
} catch {}
}
async function loadTrace() {
if (!okrId.value) return
traceLoading.value = true
@@ -197,6 +221,7 @@ async function loadTrace() {
onMounted(() => {
loadData()
loadTrace()
loadActions()
})
</script>
@@ -282,6 +307,41 @@ onMounted(() => {
font-size: 12px;
color: #909399;
}
/* ── KR下的达成行动 ── */
.kr-actions {
margin-top: 8px;
padding: 8px 10px;
background: #f8fafc;
border-radius: 6px;
border-left: 3px solid #409eff;
}
.kr-actions-title {
font-size: 12px;
color: #409eff;
font-weight: 500;
margin-bottom: 4px;
}
.kr-action-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.kr-action-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kr-action-meta {
color: #909399;
font-size: 11px;
}
.kr-action-tag {
flex-shrink: 0;
}
.empty-tab {
text-align: center;
color: #C0C4CC;